|
@@ -1,5 +1,5 @@
|
|
|
-import React, { useState, useEffect } from 'react';
|
|
|
|
|
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
|
|
+import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
+import { useQuery, useMutation } from '@tanstack/react-query';
|
|
|
import {
|
|
import {
|
|
|
Gauge,
|
|
Gauge,
|
|
|
Loader2,
|
|
Loader2,
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
WifiOff,
|
|
WifiOff,
|
|
|
Trash2,
|
|
Trash2,
|
|
|
Search,
|
|
Search,
|
|
|
|
|
+ Copy,
|
|
|
|
|
+ Download,
|
|
|
|
|
+ Upload,
|
|
|
|
|
+ CheckSquare,
|
|
|
|
|
+ Square,
|
|
|
|
|
+ StickyNote,
|
|
|
} from 'lucide-react';
|
|
} from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
import { api } from '../api/client';
|
|
|
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
|
|
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
|
|
@@ -21,6 +27,11 @@ import { useToast } from '../contexts/ToastContext';
|
|
|
interface KProfileCardProps {
|
|
interface KProfileCardProps {
|
|
|
profile: KProfile;
|
|
profile: KProfile;
|
|
|
onEdit: () => void;
|
|
onEdit: () => void;
|
|
|
|
|
+ onCopy?: () => void;
|
|
|
|
|
+ selectionMode?: boolean;
|
|
|
|
|
+ isSelected?: boolean;
|
|
|
|
|
+ onToggleSelect?: () => void;
|
|
|
|
|
+ note?: string; // Note text to display as preview
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
|
|
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
|
|
@@ -64,33 +75,71 @@ const extractFilamentName = (profileName: string) => {
|
|
|
return profileName;
|
|
return profileName;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-function KProfileCard({ profile, onEdit }: KProfileCardProps) {
|
|
|
|
|
|
|
+function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {
|
|
|
const flowType = getFlowTypeLabel(profile.nozzle_id);
|
|
const flowType = getFlowTypeLabel(profile.nozzle_id);
|
|
|
const diameter = profile.nozzle_diameter;
|
|
const diameter = profile.nozzle_diameter;
|
|
|
- const profileName = profile.name || 'Unnamed';
|
|
|
|
|
- // Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
|
|
|
|
|
- const filamentName = extractFilamentName(profile.name || '');
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const handleClick = () => {
|
|
|
|
|
+ if (selectionMode && onToggleSelect) {
|
|
|
|
|
+ onToggleSelect();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ onEdit();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <button
|
|
|
|
|
- onClick={onEdit}
|
|
|
|
|
- className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
|
|
|
|
|
- >
|
|
|
|
|
- <div className="flex items-center gap-2">
|
|
|
|
|
- <span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
|
|
|
|
|
- {truncateK(profile.k_value)}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span className="text-white text-sm truncate flex-1" title={profileName}>
|
|
|
|
|
- {profileName}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span className="text-xs text-bambu-gray whitespace-nowrap">
|
|
|
|
|
- {flowType} {diameter}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
|
|
|
|
|
- Filament: {filamentName || profile.filament_id}
|
|
|
|
|
- </div>
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ {selectionMode && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={onToggleSelect}
|
|
|
|
|
+ className="text-bambu-gray hover:text-white transition-colors p-1"
|
|
|
|
|
+ >
|
|
|
|
|
+ {isSelected ? (
|
|
|
|
|
+ <CheckSquare className="w-4 h-4 text-bambu-green" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Square className="w-4 h-4" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleClick}
|
|
|
|
|
+ className={`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${isSelected ? 'ring-1 ring-bambu-green' : ''}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
|
|
|
|
|
+ {truncateK(profile.k_value)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-white text-sm truncate flex-1" title={profile.name}>
|
|
|
|
|
+ {profile.name || 'Unnamed'}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {note && (
|
|
|
|
|
+ <span title="Has note">
|
|
|
|
|
+ <StickyNote className="w-3 h-3 text-yellow-500" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <span className="text-xs text-bambu-gray whitespace-nowrap">
|
|
|
|
|
+ {flowType} {diameter}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {note && (
|
|
|
|
|
+ <div className="text-xs mt-0.5 truncate text-yellow-500/70" title={note}>
|
|
|
|
|
+ Note: {note.length > 50 ? note.substring(0, 50) + '...' : note}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {!selectionMode && onCopy && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ onCopy();
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="text-bambu-gray hover:text-white transition-colors p-1"
|
|
|
|
|
+ title="Copy profile"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Copy className="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -100,8 +149,11 @@ interface KProfileModalProps {
|
|
|
nozzleDiameter: string;
|
|
nozzleDiameter: string;
|
|
|
existingProfiles?: KProfile[]; // Existing profiles for filament selection
|
|
existingProfiles?: KProfile[]; // Existing profiles for filament selection
|
|
|
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
|
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
|
|
|
|
+ initialNote?: string; // Initial note value for the profile
|
|
|
|
|
+ initialNoteKey?: string | null; // Key the note was stored under (for clearing)
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
|
onSave: () => void;
|
|
onSave: () => void;
|
|
|
|
|
+ onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function KProfileModal({
|
|
function KProfileModal({
|
|
@@ -110,11 +162,13 @@ function KProfileModal({
|
|
|
nozzleDiameter,
|
|
nozzleDiameter,
|
|
|
existingProfiles = [],
|
|
existingProfiles = [],
|
|
|
isDualNozzle = false,
|
|
isDualNozzle = false,
|
|
|
|
|
+ initialNote = '',
|
|
|
|
|
+ initialNoteKey = null,
|
|
|
onClose,
|
|
onClose,
|
|
|
onSave,
|
|
onSave,
|
|
|
|
|
+ onSaveNote,
|
|
|
}: KProfileModalProps) {
|
|
}: KProfileModalProps) {
|
|
|
const { showToast } = useToast();
|
|
const { showToast } = useToast();
|
|
|
- const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
const [name, setName] = useState(profile?.name || '');
|
|
const [name, setName] = useState(profile?.name || '');
|
|
|
const [kValue, setKValue] = useState(
|
|
const [kValue, setKValue] = useState(
|
|
@@ -135,6 +189,7 @@ function KProfileModal({
|
|
|
);
|
|
);
|
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
|
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
|
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
|
|
|
|
+ const [note, setNote] = useState(initialNote);
|
|
|
|
|
|
|
|
// Extract unique filaments from existing K-profiles on the printer
|
|
// Extract unique filaments from existing K-profiles on the printer
|
|
|
// These have valid filament_ids that the printer recognizes
|
|
// These have valid filament_ids that the printer recognizes
|
|
@@ -162,12 +217,26 @@ function KProfileModal({
|
|
|
onSuccess: (result) => {
|
|
onSuccess: (result) => {
|
|
|
console.log('[KProfile] Save success:', result);
|
|
console.log('[KProfile] Save success:', result);
|
|
|
showToast('K-profile saved');
|
|
showToast('K-profile saved');
|
|
|
|
|
+ // Save note if it changed (including clearing it)
|
|
|
|
|
+ if (onSaveNote && note !== initialNote) {
|
|
|
|
|
+ let profileKey: string;
|
|
|
|
|
+ if (note === '' && initialNoteKey) {
|
|
|
|
|
+ // Clearing note: use the same key it was stored under
|
|
|
|
|
+ profileKey = initialNoteKey;
|
|
|
|
|
+ } else if (profile && profile.slot_id > 0) {
|
|
|
|
|
+ // Editing: use setting_id if available, or composite key with slot_id
|
|
|
|
|
+ profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // New profile: use name as key (will be matched when profile is loaded)
|
|
|
|
|
+ profileKey = `name_${name}_${filamentId}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ onSaveNote(profileKey, note);
|
|
|
|
|
+ }
|
|
|
// Show syncing indicator while printer processes the command
|
|
// Show syncing indicator while printer processes the command
|
|
|
setIsSyncing(true);
|
|
setIsSyncing(true);
|
|
|
- // Add delay before refreshing to give printer time to process the save
|
|
|
|
|
- // Bambu printers can be slow to apply K-profile changes
|
|
|
|
|
|
|
+ // Add delay before closing to give printer time to process the save
|
|
|
|
|
+ // onSave will trigger refetch in the parent component
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
- queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
|
|
|
|
|
setIsSyncing(false);
|
|
setIsSyncing(false);
|
|
|
onSave();
|
|
onSave();
|
|
|
}, 2500);
|
|
}, 2500);
|
|
@@ -189,13 +258,12 @@ function KProfileModal({
|
|
|
showToast('K-profile deleted');
|
|
showToast('K-profile deleted');
|
|
|
// Show syncing indicator while printer processes the command
|
|
// Show syncing indicator while printer processes the command
|
|
|
setIsSyncing(true);
|
|
setIsSyncing(true);
|
|
|
- // Add delay before refreshing to give printer time to process the delete
|
|
|
|
|
- // Bambu printers can be slow to apply K-profile changes
|
|
|
|
|
|
|
+ // Add longer delay for delete - printer needs more time to process
|
|
|
|
|
+ // before it can return the updated profile list
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
- queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
|
|
|
|
|
setIsSyncing(false);
|
|
setIsSyncing(false);
|
|
|
onClose();
|
|
onClose();
|
|
|
- }, 2500);
|
|
|
|
|
|
|
+ }, 4000);
|
|
|
},
|
|
},
|
|
|
onError: (error: Error) => {
|
|
onError: (error: Error) => {
|
|
|
console.error('[KProfile] Delete error:', error);
|
|
console.error('[KProfile] Delete error:', error);
|
|
@@ -249,49 +317,48 @@ function KProfileModal({
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // For new profiles with multiple extruders: save sequentially
|
|
|
|
|
|
|
+ // For new profiles with multiple extruders: use batch endpoint
|
|
|
setIsSyncing(true);
|
|
setIsSyncing(true);
|
|
|
- setSavingProgress({ current: 0, total: selectedExtruders.length });
|
|
|
|
|
|
|
+ setSavingProgress({ current: 1, total: selectedExtruders.length });
|
|
|
|
|
|
|
|
- for (let i = 0; i < selectedExtruders.length; i++) {
|
|
|
|
|
- const extruderId = selectedExtruders[i];
|
|
|
|
|
- const payload = {
|
|
|
|
|
- name: name,
|
|
|
|
|
- k_value: formattedKValue,
|
|
|
|
|
- filament_id: filamentId,
|
|
|
|
|
- nozzle_id: nozzleId,
|
|
|
|
|
- nozzle_diameter: modalDiameter,
|
|
|
|
|
- extruder_id: extruderId,
|
|
|
|
|
- setting_id: undefined,
|
|
|
|
|
- slot_id: 0,
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ // Build payload for all selected extruders
|
|
|
|
|
+ const batchPayload = selectedExtruders.map(extruderId => ({
|
|
|
|
|
+ name: name,
|
|
|
|
|
+ k_value: formattedKValue,
|
|
|
|
|
+ filament_id: filamentId,
|
|
|
|
|
+ nozzle_id: nozzleId,
|
|
|
|
|
+ nozzle_diameter: modalDiameter,
|
|
|
|
|
+ extruder_id: extruderId,
|
|
|
|
|
+ setting_id: undefined,
|
|
|
|
|
+ slot_id: 0,
|
|
|
|
|
+ }));
|
|
|
|
|
|
|
|
- setSavingProgress({ current: i + 1, total: selectedExtruders.length });
|
|
|
|
|
- console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
|
|
|
|
|
|
|
+ console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
|
|
|
|
|
|
|
|
- try {
|
|
|
|
|
- await api.setKProfile(printerId, payload);
|
|
|
|
|
- // Wait between saves to let printer process
|
|
|
|
|
- if (i < selectedExtruders.length - 1) {
|
|
|
|
|
- await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
|
- }
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
|
|
|
|
|
- showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
|
|
|
|
|
- setIsSyncing(false);
|
|
|
|
|
- setSavingProgress({ current: 0, total: 0 });
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.setKProfilesBatch(printerId, batchPayload);
|
|
|
|
|
+ showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
|
|
|
|
|
+ // Save note for new batch profiles
|
|
|
|
|
+ if (onSaveNote && note) {
|
|
|
|
|
+ const profileKey = `name_${name}_${filamentId}`;
|
|
|
|
|
+ onSaveNote(profileKey, note);
|
|
|
}
|
|
}
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('[KProfile] Failed to save batch:', error);
|
|
|
|
|
+ showToast('Failed to save K-profiles', 'error');
|
|
|
|
|
+ setIsSyncing(false);
|
|
|
|
|
+ setSavingProgress({ current: 0, total: 0 });
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
|
|
|
|
|
|
|
+ setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
|
|
|
// Wait for final sync before closing
|
|
// Wait for final sync before closing
|
|
|
|
|
+ // onSave will trigger refetch in the parent component
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
- queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
|
|
|
|
|
setIsSyncing(false);
|
|
setIsSyncing(false);
|
|
|
setSavingProgress({ current: 0, total: 0 });
|
|
setSavingProgress({ current: 0, total: 0 });
|
|
|
onSave();
|
|
onSave();
|
|
|
- }, 2500);
|
|
|
|
|
|
|
+ }, 3000);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
@@ -425,7 +492,7 @@ function KProfileModal({
|
|
|
if (!profile && filamentId && !name) {
|
|
if (!profile && filamentId && !name) {
|
|
|
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
|
|
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
|
|
|
if (selectedFilament) {
|
|
if (selectedFilament) {
|
|
|
- const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
|
|
|
|
|
|
|
+ const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
|
|
|
setName(`${flowLabel} ${selectedFilament.name}`);
|
|
setName(`${flowLabel} ${selectedFilament.name}`);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -502,6 +569,21 @@ function KProfileModal({
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
|
|
+ {/* Notes */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ value={note}
|
|
|
|
|
+ onChange={(e) => setNote(e.target.value)}
|
|
|
|
|
+ placeholder="Add notes about this profile..."
|
|
|
|
|
+ rows={2}
|
|
|
|
|
+ className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
|
|
|
|
|
+ />
|
|
|
|
|
+ <p className="text-xs text-bambu-gray mt-1">
|
|
|
|
|
+ Notes are saved in Bambusy, not on the printer
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div className="flex gap-2 pt-4">
|
|
<div className="flex gap-2 pt-4">
|
|
|
{profile && (
|
|
{profile && (
|
|
|
<Button
|
|
<Button
|
|
@@ -595,15 +677,52 @@ function KProfileModal({
|
|
|
|
|
|
|
|
type ExtruderFilter = 'all' | 'left' | 'right';
|
|
type ExtruderFilter = 'all' | 'left' | 'right';
|
|
|
type FlowTypeFilter = 'all' | 'hf' | 's';
|
|
type FlowTypeFilter = 'all' | 'hf' | 's';
|
|
|
|
|
+type SortOption = 'name' | 'k_value' | 'filament';
|
|
|
|
|
+
|
|
|
|
|
+// localStorage keys
|
|
|
|
|
+const STORAGE_KEYS = {
|
|
|
|
|
+ NOZZLE_DIAMETER: 'bambusy_kprofiles_nozzle',
|
|
|
|
|
+ SORT_OPTION: 'bambusy_kprofiles_sort',
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
export function KProfilesView() {
|
|
export function KProfilesView() {
|
|
|
|
|
+ const { showToast } = useToast();
|
|
|
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
|
|
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
|
|
|
- const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
|
|
|
|
|
|
|
+ // Load nozzle diameter from localStorage
|
|
|
|
|
+ const [nozzleDiameter, setNozzleDiameter] = useState(() => {
|
|
|
|
|
+ const saved = localStorage.getItem(STORAGE_KEYS.NOZZLE_DIAMETER);
|
|
|
|
|
+ return saved || '0.4';
|
|
|
|
|
+ });
|
|
|
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
|
|
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
|
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
|
|
|
+ const [copyingProfile, setCopyingProfile] = useState<KProfile | null>(null);
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
|
|
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
|
|
|
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
|
|
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
|
|
|
|
|
+ // Load sort option from localStorage
|
|
|
|
|
+ const [sortOption, setSortOption] = useState<SortOption>(() => {
|
|
|
|
|
+ const saved = localStorage.getItem(STORAGE_KEYS.SORT_OPTION);
|
|
|
|
|
+ return (saved as SortOption) || 'name';
|
|
|
|
|
+ });
|
|
|
|
|
+ // Bulk selection mode
|
|
|
|
|
+ // Use composite key: `${slot_id}_${extruder_id}` since slot_id alone is not unique across extruders
|
|
|
|
|
+ const [selectionMode, setSelectionMode] = useState(false);
|
|
|
|
|
+ const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
|
|
|
|
|
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
|
|
|
|
+ const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ // Helper to create unique profile key for selection
|
|
|
|
|
+ const getProfileKey = (profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`;
|
|
|
|
|
+
|
|
|
|
|
+ // Save nozzle diameter to localStorage when it changes
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ localStorage.setItem(STORAGE_KEYS.NOZZLE_DIAMETER, nozzleDiameter);
|
|
|
|
|
+ }, [nozzleDiameter]);
|
|
|
|
|
+
|
|
|
|
|
+ // Save sort option to localStorage when it changes
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ localStorage.setItem(STORAGE_KEYS.SORT_OPTION, sortOption);
|
|
|
|
|
+ }, [sortOption]);
|
|
|
|
|
|
|
|
// Get available printers
|
|
// Get available printers
|
|
|
const { data: printers, isLoading: printersLoading } = useQuery({
|
|
const { data: printers, isLoading: printersLoading } = useQuery({
|
|
@@ -611,21 +730,47 @@ export function KProfilesView() {
|
|
|
queryFn: api.getPrinters,
|
|
queryFn: api.getPrinters,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Get K-profiles for selected printer
|
|
|
|
|
|
|
+ // Get K-profiles for selected printer (filtered by nozzle diameter)
|
|
|
const {
|
|
const {
|
|
|
data: kprofiles,
|
|
data: kprofiles,
|
|
|
isLoading: kprofilesLoading,
|
|
isLoading: kprofilesLoading,
|
|
|
|
|
+ isFetching,
|
|
|
error: kprofilesError,
|
|
error: kprofilesError,
|
|
|
refetch: refetchProfiles,
|
|
refetch: refetchProfiles,
|
|
|
} = useQuery({
|
|
} = useQuery({
|
|
|
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
|
|
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
|
|
|
- queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
|
|
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ console.log('[KProfiles] Fetching profiles for printer', selectedPrinter, 'nozzle', nozzleDiameter);
|
|
|
|
|
+ const result = await api.getKProfiles(selectedPrinter!, nozzleDiameter);
|
|
|
|
|
+ console.log('[KProfiles] Received profiles:', result?.profiles?.length || 0, 'profiles');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ },
|
|
|
enabled: !!selectedPrinter,
|
|
enabled: !!selectedPrinter,
|
|
|
retry: false,
|
|
retry: false,
|
|
|
staleTime: 0, // Always consider data stale to ensure fresh fetch
|
|
staleTime: 0, // Always consider data stale to ensure fresh fetch
|
|
|
|
|
+ gcTime: 0, // Don't cache results
|
|
|
refetchOnMount: 'always', // Always refetch when component mounts
|
|
refetchOnMount: 'always', // Always refetch when component mounts
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
|
|
|
|
|
+ const { data: allProfiles } = useQuery({
|
|
|
|
|
+ queryKey: ['kprofiles', selectedPrinter, '0.4'],
|
|
|
|
|
+ queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
|
|
|
|
|
+ enabled: !!selectedPrinter,
|
|
|
|
|
+ staleTime: 60000, // Cache for 1 minute
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch K-profile notes (stored locally)
|
|
|
|
|
+ const {
|
|
|
|
|
+ data: notesData,
|
|
|
|
|
+ refetch: refetchNotes,
|
|
|
|
|
+ } = useQuery({
|
|
|
|
|
+ queryKey: ['kprofile-notes', selectedPrinter],
|
|
|
|
|
+ queryFn: () => api.getKProfileNotes(selectedPrinter!),
|
|
|
|
|
+ enabled: !!selectedPrinter,
|
|
|
|
|
+ staleTime: 30000, // Cache for 30 seconds
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// Check if error is due to printer not being connected
|
|
// Check if error is due to printer not being connected
|
|
|
const isOfflineError = kprofilesError?.message?.includes('not connected');
|
|
const isOfflineError = kprofilesError?.message?.includes('not connected');
|
|
|
|
|
|
|
@@ -653,11 +798,12 @@ export function KProfilesView() {
|
|
|
// Get connected printers for display
|
|
// Get connected printers for display
|
|
|
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
|
|
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
|
|
|
|
|
|
|
|
- // Filter profiles based on search query, extruder filter, and flow type
|
|
|
|
|
|
|
+ // Filter and sort profiles
|
|
|
|
|
+ // Note: nozzle diameter filtering is done server-side via MQTT request
|
|
|
const filteredProfiles = React.useMemo(() => {
|
|
const filteredProfiles = React.useMemo(() => {
|
|
|
if (!kprofiles?.profiles) return [];
|
|
if (!kprofiles?.profiles) return [];
|
|
|
|
|
|
|
|
- return kprofiles.profiles.filter((p) => {
|
|
|
|
|
|
|
+ const filtered = kprofiles.profiles.filter((p) => {
|
|
|
// Search filter - match name or filament_id (case-insensitive)
|
|
// Search filter - match name or filament_id (case-insensitive)
|
|
|
const query = searchQuery.toLowerCase();
|
|
const query = searchQuery.toLowerCase();
|
|
|
const matchesSearch =
|
|
const matchesSearch =
|
|
@@ -679,12 +825,239 @@ export function KProfilesView() {
|
|
|
|
|
|
|
|
return matchesSearch && matchesExtruder && matchesFlowType;
|
|
return matchesSearch && matchesExtruder && matchesFlowType;
|
|
|
});
|
|
});
|
|
|
- }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Sort profiles
|
|
|
|
|
+ return filtered.sort((a, b) => {
|
|
|
|
|
+ switch (sortOption) {
|
|
|
|
|
+ case 'k_value':
|
|
|
|
|
+ return parseFloat(a.k_value) - parseFloat(b.k_value);
|
|
|
|
|
+ case 'filament':
|
|
|
|
|
+ return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
|
|
|
|
|
+ case 'name':
|
|
|
|
|
+ default:
|
|
|
|
|
+ return a.name.localeCompare(b.name);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
|
|
|
|
|
|
|
|
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
|
|
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
|
|
|
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
|
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
|
|
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
|
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
|
|
|
|
|
|
|
|
|
+ // Keyboard shortcuts
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
+ // Don't trigger shortcuts when typing in input fields
|
|
|
|
|
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Don't trigger when modal is open
|
|
|
|
|
+ if (editingProfile || showAddModal || copyingProfile) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (e.key === 'r' || e.key === 'R') {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ } else if (e.key === 'n' || e.key === 'N') {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ setShowAddModal(true);
|
|
|
|
|
+ } else if (e.key === 'Escape' && selectionMode) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ setSelectionMode(false);
|
|
|
|
|
+ setSelectedProfiles(new Set());
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
+ return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
+ }, [editingProfile, showAddModal, copyingProfile, selectionMode, refetchProfiles]);
|
|
|
|
|
+
|
|
|
|
|
+ // Export profiles to JSON file
|
|
|
|
|
+ const handleExport = useCallback(() => {
|
|
|
|
|
+ if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
|
|
|
|
|
+ showToast('No profiles to export', 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const exportData = {
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ exported_at: new Date().toISOString(),
|
|
|
|
|
+ printer: selectedPrinterData?.name || 'Unknown',
|
|
|
|
|
+ nozzle_diameter: nozzleDiameter,
|
|
|
|
|
+ profiles: kprofiles.profiles.map(p => ({
|
|
|
|
|
+ name: p.name,
|
|
|
|
|
+ k_value: p.k_value,
|
|
|
|
|
+ filament_id: p.filament_id,
|
|
|
|
|
+ nozzle_id: p.nozzle_id,
|
|
|
|
|
+ nozzle_diameter: p.nozzle_diameter,
|
|
|
|
|
+ extruder_id: p.extruder_id,
|
|
|
|
|
+ })),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
|
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
|
|
+ const a = document.createElement('a');
|
|
|
|
|
+ a.href = url;
|
|
|
|
|
+ a.download = `kprofiles_${selectedPrinterData?.name || 'printer'}_${nozzleDiameter}mm_${new Date().toISOString().split('T')[0]}.json`;
|
|
|
|
|
+ document.body.appendChild(a);
|
|
|
|
|
+ a.click();
|
|
|
|
|
+ document.body.removeChild(a);
|
|
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
|
|
+ showToast(`Exported ${kprofiles.profiles.length} profiles`);
|
|
|
|
|
+ }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
|
|
|
|
|
+
|
|
|
|
|
+ // Import profiles from JSON file
|
|
|
|
|
+ const handleImport = useCallback(() => {
|
|
|
|
|
+ const input = document.createElement('input');
|
|
|
|
|
+ input.type = 'file';
|
|
|
|
|
+ input.accept = '.json';
|
|
|
|
|
+ input.onchange = async (e) => {
|
|
|
|
|
+ const file = (e.target as HTMLInputElement).files?.[0];
|
|
|
|
|
+ if (!file) return;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const text = await file.text();
|
|
|
|
|
+ const data = JSON.parse(text);
|
|
|
|
|
+
|
|
|
|
|
+ if (!data.profiles || !Array.isArray(data.profiles)) {
|
|
|
|
|
+ showToast('Invalid file format', 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Import profiles one by one
|
|
|
|
|
+ let imported = 0;
|
|
|
|
|
+ for (const p of data.profiles) {
|
|
|
|
|
+ if (!p.name || !p.k_value || !p.filament_id) continue;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.setKProfile(selectedPrinter!, {
|
|
|
|
|
+ name: p.name,
|
|
|
|
|
+ k_value: parseFloat(p.k_value).toFixed(6),
|
|
|
|
|
+ filament_id: p.filament_id,
|
|
|
|
|
+ nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
|
|
|
|
|
+ nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
|
|
|
|
|
+ extruder_id: p.extruder_id ?? 0,
|
|
|
|
|
+ slot_id: 0, // Always create new
|
|
|
|
|
+ });
|
|
|
|
|
+ imported++;
|
|
|
|
|
+ // Small delay between imports
|
|
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to import profile:', p.name, err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Import error:', err);
|
|
|
|
|
+ showToast('Failed to parse import file', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ input.click();
|
|
|
|
|
+ }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
|
|
|
|
|
+
|
|
|
|
|
+ // Toggle profile selection using composite key
|
|
|
|
|
+ const toggleProfileSelection = useCallback((profileKey: string) => {
|
|
|
|
|
+ setSelectedProfiles(prev => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ if (next.has(profileKey)) {
|
|
|
|
|
+ next.delete(profileKey);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ next.add(profileKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // Select all visible profiles
|
|
|
|
|
+ const selectAllProfiles = useCallback(() => {
|
|
|
|
|
+ setSelectedProfiles(new Set(filteredProfiles.map(p => getProfileKey(p))));
|
|
|
|
|
+ }, [filteredProfiles, getProfileKey]);
|
|
|
|
|
+
|
|
|
|
|
+ // Delete selected profiles
|
|
|
|
|
+ const handleBulkDelete = useCallback(() => {
|
|
|
|
|
+ if (selectedProfiles.size === 0) return;
|
|
|
|
|
+ setShowBulkDeleteConfirm(true);
|
|
|
|
|
+ }, [selectedProfiles.size]);
|
|
|
|
|
+
|
|
|
|
|
+ // Execute the actual bulk delete
|
|
|
|
|
+ const executeBulkDelete = useCallback(async () => {
|
|
|
|
|
+ const profilesToDelete = filteredProfiles.filter(p => selectedProfiles.has(getProfileKey(p)));
|
|
|
|
|
+ setBulkDeleteInProgress(true);
|
|
|
|
|
+
|
|
|
|
|
+ let deleted = 0;
|
|
|
|
|
+ for (const profile of profilesToDelete) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.deleteKProfile(selectedPrinter!, {
|
|
|
|
|
+ slot_id: profile.slot_id,
|
|
|
|
|
+ extruder_id: profile.extruder_id,
|
|
|
|
|
+ nozzle_id: profile.nozzle_id,
|
|
|
|
|
+ nozzle_diameter: profile.nozzle_diameter,
|
|
|
|
|
+ filament_id: profile.filament_id,
|
|
|
|
|
+ setting_id: profile.setting_id,
|
|
|
|
|
+ });
|
|
|
|
|
+ deleted++;
|
|
|
|
|
+ // Small delay between deletes
|
|
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 300));
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to delete profile:', profile.name, err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ showToast(`Deleted ${deleted} profiles`);
|
|
|
|
|
+ setBulkDeleteInProgress(false);
|
|
|
|
|
+ setShowBulkDeleteConfirm(false);
|
|
|
|
|
+ setSelectionMode(false);
|
|
|
|
|
+ setSelectedProfiles(new Set());
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
|
|
|
|
|
+
|
|
|
|
|
+ // Generate possible keys for a profile (for notes lookup)
|
|
|
|
|
+ // Returns array of keys to check: setting_id, slot-based, name-based
|
|
|
|
|
+ const getProfileKeys = useCallback((profile: KProfile): string[] => {
|
|
|
|
|
+ const keys: string[] = [];
|
|
|
|
|
+ if (profile.setting_id) {
|
|
|
|
|
+ keys.push(profile.setting_id);
|
|
|
|
|
+ }
|
|
|
|
|
+ // Slot-based key (for profiles without setting_id)
|
|
|
|
|
+ keys.push(`slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`);
|
|
|
|
|
+ // Name-based key (for newly created profiles)
|
|
|
|
|
+ keys.push(`name_${profile.name}_${profile.filament_id}`);
|
|
|
|
|
+ return keys;
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // Save note for a profile
|
|
|
|
|
+ const handleSaveNote = useCallback(async (profileKey: string, noteText: string) => {
|
|
|
|
|
+ if (!selectedPrinter) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.setKProfileNote(selectedPrinter, profileKey, noteText);
|
|
|
|
|
+ refetchNotes();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to save note:', err);
|
|
|
|
|
+ showToast('Failed to save note', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [selectedPrinter, refetchNotes, showToast]);
|
|
|
|
|
+
|
|
|
|
|
+ // Get note for a profile (checks all possible keys)
|
|
|
|
|
+ // Returns { note, key } so we know which key the note was stored under
|
|
|
|
|
+ const getNoteWithKey = useCallback((profile: KProfile): { note: string; key: string | null } => {
|
|
|
|
|
+ if (!notesData?.notes) return { note: '', key: null };
|
|
|
|
|
+ const keys = getProfileKeys(profile);
|
|
|
|
|
+ for (const key of keys) {
|
|
|
|
|
+ if (notesData.notes[key]) {
|
|
|
|
|
+ return { note: notesData.notes[key], key };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { note: '', key: null };
|
|
|
|
|
+ }, [notesData, getProfileKeys]);
|
|
|
|
|
+
|
|
|
|
|
+ // Simple getter for display purposes
|
|
|
|
|
+ const getNote = useCallback((profile: KProfile) => {
|
|
|
|
|
+ return getNoteWithKey(profile).note;
|
|
|
|
|
+ }, [getNoteWithKey]);
|
|
|
|
|
+
|
|
|
if (printersLoading) {
|
|
if (printersLoading) {
|
|
|
return (
|
|
return (
|
|
|
<div className="flex justify-center py-12">
|
|
<div className="flex justify-center py-12">
|
|
@@ -723,6 +1096,14 @@ export function KProfilesView() {
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
|
|
+ {/* Loading overlay when refetching profiles (not initial load) */}
|
|
|
|
|
+ {isFetching && !kprofilesLoading && (
|
|
|
|
|
+ <div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
|
|
|
|
|
+ <Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
|
|
|
|
|
+ <p className="text-white font-medium">Loading K-Profiles...</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Printer & Nozzle Selector */}
|
|
{/* Printer & Nozzle Selector */}
|
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
|
<div className="flex-1 min-w-48">
|
|
<div className="flex-1 min-w-48">
|
|
@@ -758,9 +1139,9 @@ export function KProfilesView() {
|
|
|
<Button
|
|
<Button
|
|
|
variant="secondary"
|
|
variant="secondary"
|
|
|
onClick={() => refetchProfiles()}
|
|
onClick={() => refetchProfiles()}
|
|
|
- disabled={kprofilesLoading}
|
|
|
|
|
|
|
+ disabled={isFetching}
|
|
|
>
|
|
>
|
|
|
- <RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
|
|
|
|
|
|
|
+ <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
|
|
|
Refresh
|
|
Refresh
|
|
|
</Button>
|
|
</Button>
|
|
|
<Button onClick={() => setShowAddModal(true)}>
|
|
<Button onClick={() => setShowAddModal(true)}>
|
|
@@ -771,7 +1152,7 @@ export function KProfilesView() {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Search & Filter Row */}
|
|
{/* Search & Filter Row */}
|
|
|
- <div className="flex flex-wrap gap-4 mb-6">
|
|
|
|
|
|
|
+ <div className="flex flex-wrap gap-4 mb-4">
|
|
|
<div className="flex-1 min-w-48 relative">
|
|
<div className="flex-1 min-w-48 relative">
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
|
<input
|
|
<input
|
|
@@ -806,6 +1187,81 @@ export function KProfilesView() {
|
|
|
<option value="s">S Only</option>
|
|
<option value="s">S Only</option>
|
|
|
</select>
|
|
</select>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div className="w-32">
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={sortOption}
|
|
|
|
|
+ onChange={(e) => setSortOption(e.target.value as SortOption)}
|
|
|
|
|
+ className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="name">Sort: Name</option>
|
|
|
|
|
+ <option value="k_value">Sort: K-Value</option>
|
|
|
|
|
+ <option value="filament">Sort: Filament</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Toolbar Row */}
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 mb-6">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={handleExport}
|
|
|
|
|
+ disabled={!kprofiles?.profiles?.length}
|
|
|
|
|
+ title="Export profiles to JSON"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Download className="w-4 h-4" />
|
|
|
|
|
+ Export
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={handleImport}
|
|
|
|
|
+ title="Import profiles from JSON"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Upload className="w-4 h-4" />
|
|
|
|
|
+ Import
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <div className="flex-1" />
|
|
|
|
|
+ {selectionMode ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={selectAllProfiles}
|
|
|
|
|
+ title="Select all visible profiles"
|
|
|
|
|
+ >
|
|
|
|
|
+ <CheckSquare className="w-4 h-4" />
|
|
|
|
|
+ Select All
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={handleBulkDelete}
|
|
|
|
|
+ disabled={selectedProfiles.size === 0}
|
|
|
|
|
+ className="text-red-500 hover:bg-red-500/10"
|
|
|
|
|
+ title={`Delete ${selectedProfiles.size} selected profiles`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 className="w-4 h-4" />
|
|
|
|
|
+ Delete ({selectedProfiles.size})
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setSelectionMode(false);
|
|
|
|
|
+ setSelectedProfiles(new Set());
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="w-4 h-4" />
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={() => setSelectionMode(true)}
|
|
|
|
|
+ disabled={!filteredProfiles.length}
|
|
|
|
|
+ title="Enter selection mode for bulk delete"
|
|
|
|
|
+ >
|
|
|
|
|
+ <CheckSquare className="w-4 h-4" />
|
|
|
|
|
+ Select
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* K-Profiles Grid */}
|
|
{/* K-Profiles Grid */}
|
|
@@ -839,9 +1295,14 @@ export function KProfilesView() {
|
|
|
.filter((p) => p.extruder_id === 1)
|
|
.filter((p) => p.extruder_id === 1)
|
|
|
.map((profile) => (
|
|
.map((profile) => (
|
|
|
<KProfileCard
|
|
<KProfileCard
|
|
|
- key={profile.slot_id}
|
|
|
|
|
|
|
+ key={getProfileKey(profile)}
|
|
|
profile={profile}
|
|
profile={profile}
|
|
|
onEdit={() => setEditingProfile(profile)}
|
|
onEdit={() => setEditingProfile(profile)}
|
|
|
|
|
+ onCopy={() => setCopyingProfile(profile)}
|
|
|
|
|
+ selectionMode={selectionMode}
|
|
|
|
|
+ isSelected={selectedProfiles.has(getProfileKey(profile))}
|
|
|
|
|
+ onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
|
|
|
|
|
+ note={getNote(profile)}
|
|
|
/>
|
|
/>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
@@ -854,9 +1315,14 @@ export function KProfilesView() {
|
|
|
.filter((p) => p.extruder_id === 0)
|
|
.filter((p) => p.extruder_id === 0)
|
|
|
.map((profile) => (
|
|
.map((profile) => (
|
|
|
<KProfileCard
|
|
<KProfileCard
|
|
|
- key={profile.slot_id}
|
|
|
|
|
|
|
+ key={getProfileKey(profile)}
|
|
|
profile={profile}
|
|
profile={profile}
|
|
|
onEdit={() => setEditingProfile(profile)}
|
|
onEdit={() => setEditingProfile(profile)}
|
|
|
|
|
+ onCopy={() => setCopyingProfile(profile)}
|
|
|
|
|
+ selectionMode={selectionMode}
|
|
|
|
|
+ isSelected={selectedProfiles.has(getProfileKey(profile))}
|
|
|
|
|
+ onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
|
|
|
|
|
+ note={getNote(profile)}
|
|
|
/>
|
|
/>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
@@ -867,9 +1333,14 @@ export function KProfilesView() {
|
|
|
<div className="space-y-1">
|
|
<div className="space-y-1">
|
|
|
{filteredProfiles.map((profile) => (
|
|
{filteredProfiles.map((profile) => (
|
|
|
<KProfileCard
|
|
<KProfileCard
|
|
|
- key={profile.slot_id}
|
|
|
|
|
|
|
+ key={getProfileKey(profile)}
|
|
|
profile={profile}
|
|
profile={profile}
|
|
|
onEdit={() => setEditingProfile(profile)}
|
|
onEdit={() => setEditingProfile(profile)}
|
|
|
|
|
+ onCopy={() => setCopyingProfile(profile)}
|
|
|
|
|
+ selectionMode={selectionMode}
|
|
|
|
|
+ isSelected={selectedProfiles.has(getProfileKey(profile))}
|
|
|
|
|
+ onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
|
|
|
|
|
+ note={getNote(profile)}
|
|
|
/>
|
|
/>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
@@ -901,29 +1372,118 @@ export function KProfilesView() {
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{/* Edit Modal */}
|
|
{/* Edit Modal */}
|
|
|
- {editingProfile && selectedPrinter && (
|
|
|
|
|
|
|
+ {editingProfile && selectedPrinter && (() => {
|
|
|
|
|
+ const { note, key } = getNoteWithKey(editingProfile);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <KProfileModal
|
|
|
|
|
+ profile={editingProfile}
|
|
|
|
|
+ printerId={selectedPrinter}
|
|
|
|
|
+ nozzleDiameter={nozzleDiameter}
|
|
|
|
|
+ existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
|
|
|
|
+ isDualNozzle={isDualNozzle}
|
|
|
|
|
+ initialNote={note}
|
|
|
|
|
+ initialNoteKey={key}
|
|
|
|
|
+ onSaveNote={handleSaveNote}
|
|
|
|
|
+ onClose={() => {
|
|
|
|
|
+ console.log('[KProfiles] Edit modal onClose - refetching profiles...');
|
|
|
|
|
+ setEditingProfile(null);
|
|
|
|
|
+ refetchProfiles(); // Refetch after close (handles delete case)
|
|
|
|
|
+ }}
|
|
|
|
|
+ onSave={() => {
|
|
|
|
|
+ setEditingProfile(null);
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Add Modal */}
|
|
|
|
|
+ {showAddModal && selectedPrinter && (
|
|
|
<KProfileModal
|
|
<KProfileModal
|
|
|
- profile={editingProfile}
|
|
|
|
|
printerId={selectedPrinter}
|
|
printerId={selectedPrinter}
|
|
|
nozzleDiameter={nozzleDiameter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
|
- existingProfiles={kprofiles?.profiles}
|
|
|
|
|
|
|
+ existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
|
|
isDualNozzle={isDualNozzle}
|
|
isDualNozzle={isDualNozzle}
|
|
|
- onClose={() => setEditingProfile(null)}
|
|
|
|
|
- onSave={() => setEditingProfile(null)}
|
|
|
|
|
|
|
+ onSaveNote={handleSaveNote}
|
|
|
|
|
+ onClose={() => {
|
|
|
|
|
+ setShowAddModal(false);
|
|
|
|
|
+ refetchProfiles(); // Refetch after close
|
|
|
|
|
+ }}
|
|
|
|
|
+ onSave={() => {
|
|
|
|
|
+ setShowAddModal(false);
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ }}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Add Modal */}
|
|
|
|
|
- {showAddModal && selectedPrinter && (
|
|
|
|
|
|
|
+ {/* Copy Modal - opens add modal with prefilled values from source profile */}
|
|
|
|
|
+ {copyingProfile && selectedPrinter && (
|
|
|
<KProfileModal
|
|
<KProfileModal
|
|
|
printerId={selectedPrinter}
|
|
printerId={selectedPrinter}
|
|
|
nozzleDiameter={nozzleDiameter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
|
- existingProfiles={kprofiles?.profiles}
|
|
|
|
|
|
|
+ existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
|
|
isDualNozzle={isDualNozzle}
|
|
isDualNozzle={isDualNozzle}
|
|
|
- onClose={() => setShowAddModal(false)}
|
|
|
|
|
- onSave={() => setShowAddModal(false)}
|
|
|
|
|
|
|
+ onSaveNote={handleSaveNote}
|
|
|
|
|
+ // Pass profile data but without slot_id to create a new profile
|
|
|
|
|
+ profile={{
|
|
|
|
|
+ ...copyingProfile,
|
|
|
|
|
+ slot_id: 0, // Force new profile creation
|
|
|
|
|
+ name: `${copyingProfile.name} (Copy)`, // Indicate it's a copy
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClose={() => {
|
|
|
|
|
+ setCopyingProfile(null);
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ }}
|
|
|
|
|
+ onSave={() => {
|
|
|
|
|
+ setCopyingProfile(null);
|
|
|
|
|
+ refetchProfiles();
|
|
|
|
|
+ }}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Bulk Delete Confirmation Modal */}
|
|
|
|
|
+ {showBulkDeleteConfirm && (
|
|
|
|
|
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
|
|
|
+ <Card className="w-full max-w-sm">
|
|
|
|
|
+ <CardContent className="p-6">
|
|
|
|
|
+ <div className="flex items-center gap-3 mb-4">
|
|
|
|
|
+ <div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
|
|
|
|
|
+ <Trash2 className="w-5 h-5 text-red-500" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
|
|
|
|
|
+ <p className="text-sm text-bambu-gray">This cannot be undone</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-bambu-gray mb-6">
|
|
|
|
|
+ Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <div className="flex gap-3">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={() => setShowBulkDeleteConfirm(false)}
|
|
|
|
|
+ disabled={bulkDeleteInProgress}
|
|
|
|
|
+ className="flex-1"
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={executeBulkDelete}
|
|
|
|
|
+ disabled={bulkDeleteInProgress}
|
|
|
|
|
+ className="flex-1 bg-red-500 hover:bg-red-600 text-white"
|
|
|
|
|
+ >
|
|
|
|
|
+ {bulkDeleteInProgress ? (
|
|
|
|
|
+ <Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Trash2 className="w-4 h-4" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ Delete
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</>
|
|
</>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|