import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Gauge,
Loader2,
RefreshCw,
Printer,
Plus,
X,
AlertCircle,
WifiOff,
Trash2,
Search,
Copy,
Download,
Upload,
CheckSquare,
Square,
StickyNote,
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
interface KProfileCardProps {
profile: KProfile;
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
const truncateK = (value: string) => {
const num = parseFloat(value);
return (Math.trunc(num * 1000) / 1000).toFixed(3);
};
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
const getFlowTypeLabel = (nozzleId: string) => {
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
return 'S'; // Standard Flow (default)
};
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
const getNozzleTypePrefix = (nozzleId: string) => {
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
return match ? match[1] : 'HH00';
};
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
const extractFilamentName = (profileName: string) => {
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
// Remove common prefixes - check both underscore and space separators
const prefixes = [
'High Flow_', 'High Flow ', // underscore or space
'Standard_', 'Standard ',
'HF_', 'HF ',
'S_', 'S ',
];
for (const prefix of prefixes) {
if (profileName.startsWith(prefix)) {
return profileName.slice(prefix.length);
}
}
// If no prefix found, check for underscore separator
const underscoreIdx = profileName.indexOf('_');
if (underscoreIdx > 0) {
return profileName.slice(underscoreIdx + 1);
}
return profileName;
};
function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {
const flowType = getFlowTypeLabel(profile.nozzle_id);
const diameter = profile.nozzle_diameter;
const handleClick = () => {
if (selectionMode && onToggleSelect) {
onToggleSelect();
} else {
onEdit();
}
};
return (
{selectionMode && (
)}
{!selectionMode && onCopy && (
)}
);
}
interface KProfileModalProps {
profile?: KProfile;
printerId: number;
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
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;
onSave: () => void;
onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
hasPermission: (permission: Permission) => boolean;
}
function KProfileModal({
profile,
printerId,
nozzleDiameter,
existingProfiles = [],
builtinFilaments = [],
isDualNozzle = false,
initialNote = '',
initialNoteKey = null,
onClose,
onSave,
onSaveNote,
hasPermission,
}: KProfileModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [name, setName] = useState(profile?.name || '');
const [kValue, setKValue] = useState(
profile?.k_value ? truncateK(profile.k_value) : '0.020'
);
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
// Split nozzle into type and diameter
const [nozzleType, setNozzleType] = useState(
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
);
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
);
// For new profiles on dual-nozzle: allow selecting multiple extruders
// For editing: use single extruder from the profile
const [selectedExtruders, setSelectedExtruders] = useState(
profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0] // Default: both extruders for new dual-nozzle profiles
);
const [isSyncing, setIsSyncing] = useState(false);
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
const [note, setNote] = useState(initialNote);
// Extract unique filaments from existing K-profiles on the printer
// Use builtin filament table for accurate name resolution (filament_id → name)
// Falls back to extracting from profile name for custom/unknown presets
const knownFilaments = React.useMemo(() => {
// Build lookup map from builtin filament names (includes cloud presets from parent)
const builtinMap = new Map();
for (const bf of builtinFilaments) {
builtinMap.set(bf.filament_id, bf.name);
}
const filamentMap = new Map();
for (const p of existingProfiles) {
if (p.filament_id && !filamentMap.has(p.filament_id)) {
// Prefer builtin name (accurate), fall back to extracting from profile name
const builtinName = builtinMap.get(p.filament_id);
const filamentName = builtinName || extractFilamentName(p.name || '');
filamentMap.set(p.filament_id, {
id: p.filament_id,
name: filamentName || p.filament_id,
});
}
}
return Array.from(filamentMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [existingProfiles, builtinFilaments]);
const saveMutation = useMutation({
mutationFn: (data: KProfileCreate) => {
console.log('[KProfile] Calling API...');
return api.setKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast(t('kProfiles.toast.profileSaved'));
// 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
setIsSyncing(true);
// Add delay before closing to give printer time to process the save
// onSave will trigger refetch in the parent component
setTimeout(() => {
setIsSyncing(false);
onSave();
}, 2500);
},
onError: (error: Error) => {
console.error('[KProfile] Save error:', error);
showToast(error.message, 'error');
setIsSyncing(false);
},
});
const deleteMutation = useMutation({
mutationFn: (data: KProfileDelete) => {
console.log('[KProfile] Deleting profile...');
return api.deleteKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast(t('kProfiles.toast.profileDeleted'));
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add longer delay for delete - printer needs more time to process
// before it can return the updated profile list
setTimeout(() => {
setIsSyncing(false);
onClose();
}, 4000);
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
showToast(error.message, 'error');
setIsSyncing(false);
},
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = () => {
if (!profile) return;
deleteMutation.mutate({
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,
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate at least one extruder is selected for dual-nozzle
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
return;
}
// Format k_value to 6 decimal places for Bambu protocol
const formattedKValue = parseFloat(kValue).toFixed(6);
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// For editing or single extruder: just save one profile
if (profile || selectedExtruders.length === 1) {
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
};
console.log('[KProfile] Saving profile:', payload);
saveMutation.mutate(payload);
return;
}
// For new profiles with multiple extruders: use batch endpoint
setIsSyncing(true);
setSavingProgress({ current: 1, total: selectedExtruders.length });
// 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,
}));
console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
try {
await api.setKProfilesBatch(printerId, batchPayload);
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
// 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(t('kProfiles.toast.failedToSaveBatch'), 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
}
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
// Wait for final sync before closing
// onSave will trigger refetch in the parent component
setTimeout(() => {
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
onSave();
}, 3000);
};
return (
{/* Syncing overlay */}
{isSyncing && (
{savingProgress.total > 1
? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
: t('kProfiles.modal.syncing')}
{t('kProfiles.modal.pleaseWait')}
)}
{profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
{t('kProfiles.deleteConfirm.title')}
{t('kProfiles.deleteConfirm.cannotUndo')}
{t('kProfiles.deleteConfirm.message', { name: profile?.name })}
)}
);
}
type ExtruderFilter = 'all' | 'left' | 'right';
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() {
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [selectedPrinter, setSelectedPrinter] = useState(null);
// 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(null);
const [showAddModal, setShowAddModal] = useState(false);
const [copyingProfile, setCopyingProfile] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [extruderFilter, setExtruderFilter] = useState('all');
const [flowTypeFilter, setFlowTypeFilter] = useState('all');
// Load sort option from localStorage
const [sortOption, setSortOption] = useState(() => {
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>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
// Helper to create unique profile key for selection - wrapped in useCallback to prevent re-renders
const getProfileKey = useCallback((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
const { data: printers, isLoading: printersLoading } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Get K-profiles for selected printer (filtered by nozzle diameter)
const {
data: kprofiles,
isLoading: kprofilesLoading,
isFetching,
error: kprofilesError,
refetch: refetchProfiles,
} = useQuery({
queryKey: ['kprofiles', 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,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh fetch
gcTime: 0, // Don't cache results
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 builtin filament names for accurate filament_id → name resolution
const { data: builtinFilaments } = useQuery({
queryKey: ['builtinFilaments'],
queryFn: () => api.getBuiltinFilaments(),
staleTime: 300000, // Cache for 5 minutes (static data)
});
// Fetch filament_id → name mapping for user cloud presets (P* IDs)
const { data: filamentIdMap } = useQuery({
queryKey: ['filamentIdMap'],
queryFn: () => api.getFilamentIdMap(),
staleTime: 300000, // Cache for 5 minutes
});
// 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
const isOfflineError = kprofilesError?.message?.includes('not connected');
// Auto-select first connected printer
useEffect(() => {
if (!selectedPrinter && printers && printers.length > 0) {
const activePrinter = printers.find((p) => p.is_active);
if (activePrinter) {
setSelectedPrinter(activePrinter.id);
}
}
}, [selectedPrinter, printers]);
// Refetch profiles when printer selection changes
useEffect(() => {
if (selectedPrinter) {
// Delay refetch to ensure query is enabled after state update
const timer = setTimeout(() => {
refetchProfiles();
}, 150);
return () => clearTimeout(timer);
}
}, [selectedPrinter, nozzleDiameter]); // eslint-disable-line react-hooks/exhaustive-deps
// Get connected printers for display
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
// Build filament lookup for name resolution (builtin + user cloud presets)
const builtinFilamentMap = React.useMemo(() => {
const map = new Map();
if (builtinFilaments) {
for (const bf of builtinFilaments) {
map.set(bf.filament_id, bf.name);
}
}
// Also add user cloud presets (P* filament_ids resolved from cloud details)
if (filamentIdMap) {
for (const [fid, name] of Object.entries(filamentIdMap)) {
if (!map.has(fid)) {
map.set(fid, name);
}
}
}
return map;
}, [builtinFilaments, filamentIdMap]);
// Enriched builtin filaments array (builtin + cloud presets merged)
// Pass this to modals so they have the full filament name lookup
const enrichedBuiltinFilaments = React.useMemo(() => {
return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({
filament_id: fid,
name,
}));
}, [builtinFilamentMap]);
// Resolve filament name: builtin table first, then extract from profile name
const resolveFilamentName = React.useCallback((profile: KProfile) => {
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
}, [builtinFilamentMap]);
// Filter and sort profiles
// Note: nozzle diameter filtering is done server-side via MQTT request
const filteredProfiles = React.useMemo(() => {
if (!kprofiles?.profiles) return [];
const filtered = kprofiles.profiles.filter((p) => {
// Search filter - match name or filament_id (case-insensitive)
const query = searchQuery.toLowerCase();
const matchesSearch =
!query ||
p.name.toLowerCase().includes(query) ||
p.filament_id.toLowerCase().includes(query);
// Extruder filter
const matchesExtruder =
extruderFilter === 'all' ||
(extruderFilter === 'left' && p.extruder_id === 1) ||
(extruderFilter === 'right' && p.extruder_id === 0);
// Flow type filter (HH = High Flow, HS = Standard)
const matchesFlowType =
flowTypeFilter === 'all' ||
(flowTypeFilter === 'hf' && p.nozzle_id.startsWith('HH')) ||
(flowTypeFilter === 's' && p.nozzle_id.startsWith('HS'));
return matchesSearch && matchesExtruder && matchesFlowType;
});
// Sort profiles
return filtered.sort((a, b) => {
switch (sortOption) {
case 'k_value':
return parseFloat(a.k_value) - parseFloat(b.k_value);
case 'filament':
return resolveFilamentName(a).localeCompare(resolveFilamentName(b));
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]);
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
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(t('kProfiles.toast.noProfilesToExport'), '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(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
// 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(t('kProfiles.toast.invalidFileFormat'), '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(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
refetchProfiles();
} catch (err) {
console.error('Import error:', err);
showToast(t('kProfiles.toast.failedToParseImport'), 'error');
}
};
input.click();
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
// 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(t('kProfiles.toast.profilesDeleted', { count: deleted }));
setBulkDeleteInProgress(false);
setShowBulkDeleteConfirm(false);
setSelectionMode(false);
setSelectedProfiles(new Set());
refetchProfiles();
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
// 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(t('kProfiles.toast.failedToSaveNote'), 'error');
}
}, [selectedPrinter, refetchNotes, showToast, t]);
// 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) {
return (
);
}
if (!printers || printers.length === 0) {
return (
{t('kProfiles.noPrintersConfigured')}
{t('kProfiles.addPrinterInSettings')}
);
}
if (connectedPrinters.length === 0) {
return (
{t('kProfiles.noActivePrinters')}
{t('kProfiles.enablePrinterConnection')}
);
}
return (
<>
{/* Loading overlay when refetching profiles (not initial load) */}
{isFetching && !kprofilesLoading && (
{t('kProfiles.loadingProfiles')}
)}
{/* Printer & Nozzle Selector */}
{/* Search & Filter Row */}
setSearchQuery(e.target.value)}
placeholder={t('kProfiles.searchPlaceholder')}
className="w-full pl-10 pr-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"
/>
{isDualNozzle && (
)}
{/* Toolbar Row */}
{selectionMode ? (
<>
>
) : (
)}
{/* K-Profiles Grid */}
{kprofilesLoading ? (
) : isOfflineError ? (
{t('kProfiles.printerOffline')}
{t('kProfiles.printerOfflineDesc')}
) : filteredProfiles.length > 0 ? (
isDualNozzle ? (
// Dual-nozzle: show Left/Right columns
{/* Left Extruder (extruder_id 1 on Bambu) */}
{t('kProfiles.leftExtruder')}
{filteredProfiles
.filter((p) => p.extruder_id === 1)
.map((profile) => (
setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
{/* Right Extruder (extruder_id 0 on Bambu) */}
{t('kProfiles.rightExtruder')}
{filteredProfiles
.filter((p) => p.extruder_id === 0)
.map((profile) => (
setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
) : (
// Single-nozzle: show all profiles in one list
{filteredProfiles.map((profile) => (
setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
)
) : searchQuery || extruderFilter !== 'all' || flowTypeFilter !== 'all' ? (
{t('kProfiles.noMatchingProfiles')}
{t('kProfiles.noMatchingProfilesDesc')}
) : (
{t('kProfiles.noKProfiles')}
{t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
)}
{/* Edit Modal */}
{editingProfile && selectedPrinter && (() => {
const { note, key } = getNoteWithKey(editingProfile);
return (
{
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 && (
{
setShowAddModal(false);
refetchProfiles(); // Refetch after close
}}
onSave={() => {
setShowAddModal(false);
refetchProfiles();
}}
/>
)}
{/* Copy Modal - opens add modal with prefilled values from source profile */}
{copyingProfile && selectedPrinter && (
{
setCopyingProfile(null);
refetchProfiles();
}}
onSave={() => {
setCopyingProfile(null);
refetchProfiles();
}}
/>
)}
{/* Bulk Delete Confirmation Modal */}
{showBulkDeleteConfirm && (
{t('kProfiles.bulkDelete.title')}
{t('kProfiles.bulkDelete.cannotUndo')}
{t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
)}
>
);
}