import { useState, useMemo, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Upload, Loader2, Search, Trash2, ChevronDown, ChevronUp, HardDrive, Droplet, Settings2, Layers, AlertCircle, } from 'lucide-react'; import { api } from '../api/client'; import type { LocalPreset } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; // Known material types for name-parsing fallback const MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'PVA', 'HIPS', 'PP', 'PET', 'NYLON']; const FILAMENT_TYPE_COLORS: Record = { PLA: 'E8E8E8', PETG: '4A90D9', ABS: 'E67E22', ASA: 'D35400', TPU: '9B59B6', PC: 'BDC3C7', PA: '2ECC71', NYLON: '2ECC71', PVA: 'F1C40F', HIPS: '95A5A6', PP: 'ECF0F1', PET: '3498DB', }; // Extract material type from preset name as fallback function parseMaterialFromName(name: string): string | null { const upper = name.toUpperCase(); for (const mat of MATERIAL_TYPES) { if (new RegExp(`\\b${mat}\\b`).test(upper)) return mat; } return null; } // Extract vendor from preset name (text before the material type) function parseVendorFromName(name: string): string | null { // Strip printer/nozzle suffix first (e.g. "@BBL X1C") const clean = name.replace(/@.+$/, '').trim(); const upper = clean.toUpperCase(); for (const mat of MATERIAL_TYPES) { const idx = upper.indexOf(mat); if (idx > 0) { const vendor = clean.slice(0, idx).trim(); // Skip if vendor looks like a generic prefix (e.g., "Generic", "Bambu") if (vendor && vendor.length > 1) return vendor; } } return null; } function PresetCard({ preset, onDelete, onExpand, isExpanded, }: { preset: LocalPreset; onDelete: (id: number) => void; onExpand: (id: number | null) => void; isExpanded: boolean; }) { const { t } = useTranslation(); const { hasPermission } = useAuth(); // Resolve material type: DB field → parse from name const material = preset.filament_type || parseMaterialFromName(preset.name); // Resolve vendor: DB field → parse from name const vendor = preset.filament_vendor || parseVendorFromName(preset.name); // Parse colour for swatch — try explicit colour, then fall back to material type let colourHex: string | null = null; let hasExplicitColour = false; if (preset.default_filament_colour) { try { const parsed = JSON.parse(preset.default_filament_colour); const raw = Array.isArray(parsed) ? parsed[0] : parsed; if (typeof raw === 'string' && /^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) { colourHex = raw.replace('#', '').slice(0, 6); hasExplicitColour = true; } } catch { const raw = preset.default_filament_colour; if (/^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) { colourHex = raw.replace('#', '').slice(0, 6); hasExplicitColour = true; } } } if (!colourHex && material) { colourHex = FILAMENT_TYPE_COLORS[material.toUpperCase()] || null; } return (
{/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */} {preset.preset_type === 'filament' && (
)} {preset.name}
{/* 2) Material tag — fallback to name parsing */} {material && ( {material} )} {/* 3) Vendor — fallback to name parsing */} {vendor && ( {vendor} )} {t('profiles.localProfiles.badge')}
{/* 4) Only delete, no edit */} {hasPermission('settings:update') && ( )}
{/* 5) Expanded detail — show meaningful fields, hide self-inherits */} {isExpanded && (
{material && (
{t('profiles.localProfiles.filamentType')} {material}
)} {vendor && (
{t('profiles.localProfiles.vendor')} {vendor}
)} {preset.nozzle_temp_min != null && preset.nozzle_temp_max != null && (
{t('profiles.localProfiles.nozzleTemp')} {preset.nozzle_temp_min}–{preset.nozzle_temp_max}°C
)} {preset.filament_cost && (
{t('profiles.localProfiles.cost')} {preset.filament_cost}
)} {preset.filament_density && (
{t('profiles.localProfiles.density')} {preset.filament_density} g/cm³
)} {preset.pressure_advance && (
{t('profiles.localProfiles.pressureAdvance')} {preset.pressure_advance}
)} {preset.compatible_printers && (
{t('profiles.localProfiles.compatiblePrinters')} {(() => { try { return JSON.parse(preset.compatible_printers).join(', '); } catch { return preset.compatible_printers; } })()}
)} {/* Only show inherits if different from own name */} {preset.inherits && preset.inherits !== preset.name && (
{t('profiles.localProfiles.inheritsFrom')} {preset.inherits}
)}
{t('profiles.localProfiles.source')} {preset.source}
)} ); } export function LocalProfilesView() { const { t } = useTranslation(); const { hasPermission } = useAuth(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [searchQuery, setSearchQuery] = useState(''); const [expandedId, setExpandedId] = useState(null); const [isDragging, setIsDragging] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); const { data: presets, isLoading } = useQuery({ queryKey: ['localPresets'], queryFn: () => api.getLocalPresets(), }); const importMutation = useMutation({ mutationFn: async (files: FileList) => { const results = []; for (const file of Array.from(files)) { const formData = new FormData(); formData.append('file', file); results.push(await api.importLocalPresets(formData)); } return results; }, onSuccess: (results) => { queryClient.invalidateQueries({ queryKey: ['localPresets'] }); let totalImported = 0; let totalSkipped = 0; let totalErrors = 0; for (const r of results) { totalImported += r.imported; totalSkipped += r.skipped; totalErrors += r.errors.length; } if (totalImported > 0) { showToast(t('profiles.localProfiles.toast.importSuccess', { count: totalImported })); } if (totalSkipped > 0) { showToast(t('profiles.localProfiles.toast.importSkipped', { count: totalSkipped }), 'warning'); } if (totalErrors > 0) { showToast(t('profiles.localProfiles.toast.importError', { count: totalErrors }), 'error'); } }, onError: (err: Error) => { showToast(err.message, 'error'); }, }); const deleteMutation = useMutation({ mutationFn: (id: number) => api.deleteLocalPreset(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['localPresets'] }); setDeleteConfirm(null); showToast(t('profiles.localProfiles.toast.deleted')); }, }); const handleFiles = useCallback((files: FileList | null) => { if (!files || files.length === 0) return; importMutation.mutate(files); }, [importMutation]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }, [handleFiles]); const filterPresets = useCallback((list: LocalPreset[]) => { if (!searchQuery) return list; const q = searchQuery.toLowerCase(); return list.filter(p => p.name.toLowerCase().includes(q) || p.filament_type?.toLowerCase().includes(q) || p.filament_vendor?.toLowerCase().includes(q) ); }, [searchQuery]); const filaments = useMemo(() => filterPresets(presets?.filament || []), [presets?.filament, filterPresets]); const printers = useMemo(() => filterPresets(presets?.printer || []), [presets?.printer, filterPresets]); const processes = useMemo(() => filterPresets(presets?.process || []), [presets?.process, filterPresets]); const totalCount = filaments.length + printers.length + processes.length; if (isLoading) { return (
); } return (
{/* Import Zone */} {hasPermission('settings:update') && (
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${ isDragging ? 'border-bambu-green bg-bambu-green/10' : 'border-bambu-dark-tertiary hover:border-bambu-gray' }`} > handleFiles(e.target.files)} /> {importMutation.isPending ? (
{t('profiles.localProfiles.importing')}
) : ( <>

{t('profiles.localProfiles.import')}

{t('profiles.localProfiles.importDesc')}

)}
)} {/* Search Bar */} {totalCount > 0 && (
setSearchQuery(e.target.value)} placeholder={t('profiles.localProfiles.search')} className="w-full pl-9 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" />
)} {/* No Presets */} {totalCount === 0 && !isLoading && (

{t('profiles.localProfiles.noPresets')}

{t('profiles.localProfiles.importDesc')}

)} {/* 3-Column Preset Lists */} {totalCount > 0 && (
{/* Filament Column */} {filaments.length > 0 && (

{t('profiles.localProfiles.filament')}

({filaments.length})
{filaments.map(p => ( setDeleteConfirm(id)} onExpand={setExpandedId} isExpanded={expandedId === p.id} /> ))}
)} {/* Process Column */} {processes.length > 0 && (

{t('profiles.localProfiles.process')}

({processes.length})
{processes.map(p => ( setDeleteConfirm(id)} onExpand={setExpandedId} isExpanded={expandedId === p.id} /> ))}
)} {/* Printer Column */} {printers.length > 0 && (

{t('profiles.localProfiles.printer')}

({printers.length})
{printers.map(p => ( setDeleteConfirm(id)} onExpand={setExpandedId} isExpanded={expandedId === p.id} /> ))}
)}
)} {/* Delete Confirmation Modal */} {deleteConfirm !== null && (

{t('profiles.localProfiles.deleteConfirmTitle')}

{t('profiles.localProfiles.deleteConfirm')}

)}
); }