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')}

{/* Profile Name - read-only when editing */}
setName(e.target.value)} disabled={!!profile} 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`} placeholder={t('kProfiles.modal.profileNamePlaceholder')} required={!profile} />
{/* K-Value - always editable */}
{ // Allow typing any decimal value const val = e.target.value; if (val === '' || /^\d*\.?\d*$/.test(val)) { setKValue(val); } }} onBlur={(e) => { // Format to 3 decimal places on blur const num = parseFloat(e.target.value); if (!isNaN(num)) { setKValue((Math.trunc(num * 1000) / 1000).toFixed(3)); } }} 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 font-mono" placeholder={t('kProfiles.modal.kValuePlaceholder')} required />

{t('kProfiles.modal.kValueHelp')}

{/* Filament - read-only when editing */}
{!profile && knownFilaments.length === 0 && (

{t('kProfiles.modal.noFilamentsHelp')}

)}
{/* Flow Type and Nozzle Size - read-only when editing */}
{/* Extruder - only show for dual-nozzle printers */} {isDualNozzle && (
{profile ? ( // Read-only display for editing
{profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
) : ( // Checkboxes for new profile - can select both
)}
)} {/* Notes */}