Files
bambuddy/frontend/src/components/LocalProfilesView.tsx
T
maziggy 1d344a8536 fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
The app was built dark-first, so hundreds of hardcoded Tailwind semantic
text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had
no dark: variant. With darkMode:'class' they applied in light theme too,
producing washed-out text on pale tints and white cards — including the three
reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging
banner). Give each a theme-aware pair: a darker readable shade in light theme
with the original pinned to dark:, so dark theme is unchanged. ~100 files.

The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy
kiosk are left untouched. Plain text-white is already theme-aware via the
existing index.css .text-white override, so it needed no changes.
2026-07-07 09:54:10 +02:00

521 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, LocalPresetsResponse } 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<string, string> = {
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 (
<Card className="bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80 transition-colors">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */}
{preset.preset_type === 'filament' && (
<div
className={`w-4 h-4 rounded-full border border-black/20 flex-shrink-0 ${
!hasExplicitColour && !colourHex ? 'opacity-25' : !hasExplicitColour ? 'opacity-50' : ''
}`}
style={{ backgroundColor: colourHex ? `#${colourHex}` : '#666' }}
/>
)}
<span className="text-sm font-medium text-white truncate">{preset.name}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* 2) Material tag — fallback to name parsing */}
{material && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
{material}
</span>
)}
{/* 3) Vendor — fallback to name parsing */}
{vendor && (
<span className="text-xs text-bambu-gray">{vendor}</span>
)}
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
{t('profiles.localProfiles.badge')}
</span>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* 4) Only delete, no edit */}
{hasPermission('settings:update') && (
<button
onClick={() => onDelete(preset.id)}
className="p-1 text-bambu-gray hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('profiles.localProfiles.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => onExpand(isExpanded ? null : preset.id)}
className="p-1 text-bambu-gray hover:text-white transition-colors"
>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</div>
</div>
{/* 5) Expanded detail — show meaningful fields, hide self-inherits */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-bambu-dark-tertiary text-xs space-y-1.5">
{material && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.filamentType')}</span>
<span className="text-white">{material}</span>
</div>
)}
{vendor && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.vendor')}</span>
<span className="text-white">{vendor}</span>
</div>
)}
{preset.nozzle_temp_min != null && preset.nozzle_temp_max != null && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.nozzleTemp')}</span>
<span className="text-white">{preset.nozzle_temp_min}–{preset.nozzle_temp_max}°C</span>
</div>
)}
{preset.filament_cost && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.cost')}</span>
<span className="text-white">{preset.filament_cost}</span>
</div>
)}
{preset.filament_density && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.density')}</span>
<span className="text-white">{preset.filament_density} g/cm³</span>
</div>
)}
{preset.pressure_advance && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.pressureAdvance')}</span>
<span className="text-white">{preset.pressure_advance}</span>
</div>
)}
{preset.compatible_printers && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.compatiblePrinters')}</span>
<span className="text-white truncate ml-2">
{(() => { try { return JSON.parse(preset.compatible_printers).join(', '); } catch { return preset.compatible_printers; } })()}
</span>
</div>
)}
{/* Only show inherits if different from own name */}
{preset.inherits && preset.inherits !== preset.name && (
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.inheritsFrom')}</span>
<span className="text-white truncate ml-2">{preset.inherits}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-bambu-gray">{t('profiles.localProfiles.source')}</span>
<span className="text-white capitalize">{preset.source}</span>
</div>
</div>
)}
</CardContent>
</Card>
);
}
export function LocalProfilesView() {
const { t } = useTranslation();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [searchQuery, setSearchQuery] = useState('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(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'] });
// The SliceModal reads from a separate `slicerPresets` query that lists
// cloud + local + standard in one shot. Without this second invalidation
// freshly-imported profiles wouldn't appear in the SliceModal dropdown
// until that query's staleTime elapsed plus a refocus / remount (#1581).
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
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: (_, id) => {
// Optimistically drop the row from the cached list so the rendered table
// updates the instant the DELETE returns. Without this the row stays
// visible until invalidateQueries' background refetch completes, and a
// quick re-click on the same row opens a second delete-confirm modal
// that resolves to a 404 (server already deleted it). The cache holds a
// grouped response (filament / printer / process), not a flat list.
queryClient.setQueryData<LocalPresetsResponse>(['localPresets'], (old) => {
if (!old) return old;
return {
filament: old.filament.filter((p) => p.id !== id),
printer: old.printer.filter((p) => p.id !== id),
process: old.process.filter((p) => p.id !== id),
};
});
queryClient.invalidateQueries({ queryKey: ['localPresets'] });
// Match the import path: the SliceModal's `slicerPresets` query needs
// to be invalidated too, otherwise the deleted preset keeps appearing
// in the slice dropdown until its 60s staleTime expires plus a
// refocus / remount (#1581).
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
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;
// Count of imported presets BEFORE the search filter — drives whether the
// search bar shows at all. Gating the search bar on totalCount (post-filter)
// made it vanish the moment a query matched nothing, leaving the user unable
// to clear or edit their search without a page refresh (#1470).
const hasAnyPresets =
(presets?.filament?.length ?? 0) +
(presets?.printer?.length ?? 0) +
(presets?.process?.length ?? 0) >
0;
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
{/* Import Zone */}
{hasPermission('settings:update') && (
<div
onDragOver={(e) => { 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'
}`}
>
<input
type="file"
accept=".json,.zip,.orca_filament,.bbscfg,.bbsflmt"
multiple
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={(e) => handleFiles(e.target.files)}
/>
{importMutation.isPending ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
<span className="text-bambu-gray">{t('profiles.localProfiles.importing')}</span>
</div>
) : (
<>
<Upload className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
<p className="text-sm text-white font-medium">{t('profiles.localProfiles.import')}</p>
<p className="text-xs text-bambu-gray mt-1">{t('profiles.localProfiles.importDesc')}</p>
</>
)}
</div>
)}
{/* Search Bar */}
{hasAnyPresets && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
)}
{/* No presets imported at all */}
{!hasAnyPresets && !isLoading && (
<div className="text-center py-12">
<HardDrive className="w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50" />
<p className="text-bambu-gray">{t('profiles.localProfiles.noPresets')}</p>
<p className="text-xs text-bambu-gray/60 mt-1">{t('profiles.localProfiles.importDesc')}</p>
</div>
)}
{/* Presets exist, but the search query matched none of them */}
{hasAnyPresets && totalCount === 0 && !isLoading && (
<div className="text-center py-12">
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50" />
<p className="text-bambu-gray">{t('profiles.localProfiles.noSearchResults')}</p>
</div>
)}
{/* 3-Column Preset Lists */}
{totalCount > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Filament Column */}
{filaments.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Droplet className="w-4 h-4 text-bambu-green" />
<h3 className="text-sm font-medium text-white">
{t('profiles.localProfiles.filament')}
</h3>
<span className="text-xs text-bambu-gray">({filaments.length})</span>
</div>
<div className="space-y-2">
{filaments.map(p => (
<PresetCard
key={p.id}
preset={p}
onDelete={(id) => setDeleteConfirm(id)}
onExpand={setExpandedId}
isExpanded={expandedId === p.id}
/>
))}
</div>
</div>
)}
{/* Process Column */}
{processes.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<h3 className="text-sm font-medium text-white">
{t('profiles.localProfiles.process')}
</h3>
<span className="text-xs text-bambu-gray">({processes.length})</span>
</div>
<div className="space-y-2">
{processes.map(p => (
<PresetCard
key={p.id}
preset={p}
onDelete={(id) => setDeleteConfirm(id)}
onExpand={setExpandedId}
isExpanded={expandedId === p.id}
/>
))}
</div>
</div>
)}
{/* Printer Column */}
{printers.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Settings2 className="w-4 h-4 text-orange-600 dark:text-orange-400" />
<h3 className="text-sm font-medium text-white">
{t('profiles.localProfiles.printer')}
</h3>
<span className="text-xs text-bambu-gray">({printers.length})</span>
</div>
<div className="space-y-2">
{printers.map(p => (
<PresetCard
key={p.id}
preset={p}
onDelete={(id) => setDeleteConfirm(id)}
onExpand={setExpandedId}
isExpanded={expandedId === p.id}
/>
))}
</div>
</div>
)}
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm !== null && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-6 max-w-sm mx-4">
<div className="flex items-center gap-2 mb-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
<h3 className="text-white font-medium">{t('profiles.localProfiles.deleteConfirmTitle')}</h3>
</div>
<p className="text-sm text-bambu-gray mb-4">{t('profiles.localProfiles.deleteConfirm')}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => setDeleteConfirm(null)}>
{t('profiles.localProfiles.cancel')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => deleteMutation.mutate(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
{t('profiles.localProfiles.delete')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}