refactor(settings): Spool Catalog now identical in internal and Spoolman modes

Drop the Spoolman-mode hijack that replaced the local spool tare catalog
  with an inline filament editor — two unrelated concepts that should never
  have shared a card. Spool Catalog now renders the same way in both modes;
  Spoolman users edit filament name and spool_weight in Spoolman's own UI.

  Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
  on the Filament settings page whenever Spoolman was disabled.

  - frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
  - frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
  - SpoolCatalogSettings test file rewritten to match the simplified component
  - PATCH /spoolman/inventory/filaments/{id} backend route left in place
This commit is contained in:
maziggy
2026-05-17 13:48:05 +02:00
parent bff240e90a
commit 74dcaf5142
9 changed files with 300 additions and 1149 deletions
+187 -435
View File
@@ -1,34 +1,20 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';
import { api, ApiError } from '../api/client';
import type { SpoolCatalogEntry, SpoolmanFilamentEntry } from '../api/client';
import { api } from '../api/client';
import type { SpoolCatalogEntry } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { ConfirmModal } from './ConfirmModal';
import { SpoolWeightUpdateModal } from './SpoolWeightUpdateModal';
export function SpoolCatalogSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Spoolman inline-edit state
const [editingFilamentId, setEditingFilamentId] = useState<number | null>(null);
const [editingFilamentName, setEditingFilamentName] = useState('');
const [editingFilamentWeight, setEditingFilamentWeight] = useState('');
const [pendingWeightEdit, setPendingWeightEdit] = useState<{
filamentId: number;
name: string;
oldWeight: number | null;
newWeight: number;
} | null>(null);
// Add/Edit form state
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
@@ -44,86 +30,6 @@ export function SpoolCatalogSettings() {
const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
// Spoolman filament query — hoisted to determine display mode
const {
data: spoolmanFilaments,
isLoading: spoolmanLoading,
error: spoolmanError,
} = useQuery<SpoolmanFilamentEntry[], Error>({
queryKey: ['spoolman-inventory-filaments'],
queryFn: () => api.getSpoolmanInventoryFilaments(),
retry: false, // Spoolman may be intentionally disabled (400) — don't retry
staleTime: 60_000,
});
// 400 = Spoolman explicitly disabled; all other states (data / 503 / …) mean Spoolman mode
const isSpoolmanDisabled =
!spoolmanLoading &&
spoolmanError instanceof ApiError &&
spoolmanError.status === 400;
const isSpoolmanMode = !spoolmanLoading && !isSpoolmanDisabled;
const patchFilamentMutation = useMutation<
SpoolmanFilamentEntry,
Error,
{ filamentId: number; data: { name?: string; spool_weight?: number | null; keep_existing_spools?: boolean } }
>({
mutationFn: ({ filamentId, data }) => api.patchSpoolmanFilament(filamentId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
showToast(t('settings.catalog.filamentUpdated'), 'success');
setEditingFilamentId(null);
setEditingFilamentName('');
setEditingFilamentWeight('');
setPendingWeightEdit(null);
},
onError: (error: Error) => {
if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else if (error instanceof ApiError && error.status === 422) {
showToast(t('settings.catalog.filamentUpdateInvalid'), 'error');
} else {
showToast(t('settings.catalog.filamentUpdateFailed'), 'error');
}
},
});
const handleFilamentSave = (f: SpoolmanFilamentEntry) => {
const nameChanged = editingFilamentName.trim() !== f.name;
const weightChanged = editingFilamentWeight !== '' && editingFilamentWeight !== String(f.spool_weight ?? '');
const parsedWeight = editingFilamentWeight !== '' ? parseFloat(editingFilamentWeight) : null;
if (weightChanged && parsedWeight !== null && !isNaN(parsedWeight) && parsedWeight > 0) {
setPendingWeightEdit({
filamentId: f.id,
name: nameChanged ? editingFilamentName.trim() : f.name,
oldWeight: f.spool_weight,
newWeight: parsedWeight,
});
} else {
const data: { name?: string } = {};
if (nameChanged && editingFilamentName.trim()) data.name = editingFilamentName.trim();
if (Object.keys(data).length > 0) {
patchFilamentMutation.mutate({ filamentId: f.id, data });
} else {
setEditingFilamentId(null);
}
}
};
const handleWeightModalConfirm = (keepExisting: boolean) => {
if (!pendingWeightEdit) return;
const { filamentId, name, newWeight } = pendingWeightEdit;
const currentFilament = spoolmanFilaments?.find(f => f.id === filamentId);
const nameChanged = currentFilament && name !== currentFilament.name;
const data: { name?: string; spool_weight: number; keep_existing_spools: boolean } = {
spool_weight: newWeight,
keep_existing_spools: keepExisting,
};
if (nameChanged) data.name = name;
patchFilamentMutation.mutate({ filamentId, data });
};
const loadCatalog = useCallback(async () => {
try {
const entries = await api.getSpoolCatalog();
@@ -144,11 +50,6 @@ export function SpoolCatalogSettings() {
entry.name.toLowerCase().includes(search.toLowerCase())
);
const filteredSpoolmanFilaments = (spoolmanFilaments ?? []).filter(f =>
f.name.toLowerCase().includes(search.toLowerCase()) ||
(f.vendor?.name ?? '').toLowerCase().includes(search.toLowerCase())
);
const handleAdd = async () => {
if (!formName.trim() || !formWeight) {
showToast(t('settings.catalog.nameWeightRequired'), 'error');
@@ -313,55 +214,47 @@ export function SpoolCatalogSettings() {
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-bambu-gray" />
<h2 className="text-lg font-semibold text-white">
{isSpoolmanMode
? t('settings.spoolmanFilamentCatalogTitle')
: t('settings.catalog.spoolCatalog')}
{t('settings.catalog.spoolCatalog')}
</h2>
<span className="text-sm text-bambu-gray">
({spoolmanLoading ? '…' : isSpoolmanMode ? (spoolmanFilaments?.length ?? 0) : catalog.length})
</span>
<span className="text-sm text-bambu-gray">({catalog.length})</span>
</div>
{/* CRUD buttons — local mode only */}
{!isSpoolmanMode && !spoolmanLoading && (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleExport}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.exportTooltip')}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.export')}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.importTooltip')}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.import')}</span>
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<button
onClick={() => setShowResetConfirm(true)}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.resetTooltip')}
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.reset')}</span>
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleExport}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.exportTooltip')}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.export')}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.importTooltip')}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.import')}</span>
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<button
onClick={() => setShowResetConfirm(true)}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.resetTooltip')}
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.reset')}</span>
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
{/* Bulk-delete bar — local mode only */}
{!isSpoolmanMode && selectedIds.size > 0 && (
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
<span className="text-sm text-red-400">
{t('settings.catalog.selectedCount', { count: selectedIds.size })}
@@ -384,14 +277,10 @@ export function SpoolCatalogSettings() {
</CardHeader>
<CardContent className="space-y-4">
{/* Description */}
<p className="text-sm text-bambu-gray">
{isSpoolmanMode
? t('settings.spoolmanFilamentCatalogDesc')
: t('settings.catalog.spoolCatalogDescription')}
{t('settings.catalog.spoolCatalogDescription')}
</p>
{/* Search — always shown */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
@@ -403,302 +292,174 @@ export function SpoolCatalogSettings() {
/>
</div>
{/* Mode-determination loading spinner */}
{spoolmanLoading && (
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0">
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.catalog.namePlaceholder')}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<input
type="number"
className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
placeholder="g"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
<span className="text-bambu-gray shrink-0">g</span>
<button
onClick={handleAdd}
disabled={saving}
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
<button
onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
)}
{/* ── LOCAL MODE ── */}
{!spoolmanLoading && !isSpoolmanMode && (
<>
{/* Add form */}
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0">
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.catalog.namePlaceholder')}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<input
type="number"
className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
placeholder="g"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
<span className="text-bambu-gray shrink-0">g</span>
<button
onClick={handleAdd}
disabled={saving}
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
<button
onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Local catalog table */}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
) : (
<div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-2 py-2 w-10">
<input
type="checkbox"
checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
onChange={toggleSelectAll}
className="w-4 h-4 accent-bambu-green cursor-pointer"
/>
</th>
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
<th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
<th className="px-4 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
{search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
</td>
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
{editingId === entry.id ? (
<>
<td className="px-2 py-2">
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
className="w-4 h-4 accent-bambu-green cursor-pointer"
/>
</td>
<td className="px-4 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</td>
<td className="px-4 py-2">
<input
type="number"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
</td>
<td className="px-4 py-2 text-center">
<span className="text-xs text-bambu-gray">-</span>
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleUpdate(entry.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-2 py-2">
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
className="w-4 h-4 accent-bambu-green cursor-pointer"
/>
</td>
<td className="px-4 py-2 text-white">{entry.name}</td>
<td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
<td className="px-4 py-2 text-center">
{entry.is_default ? (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
{t('settings.catalog.default')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
{t('settings.catalog.custom')}
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => startEdit(entry)}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteEntry(entry)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</>
)}
{/* ── SPOOLMAN MODE ── */}
{!spoolmanLoading && isSpoolmanMode && (
) : (
<div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
{spoolmanError ? (
<p className="px-4 py-8 text-center text-sm text-red-400">
{t('inventory.spoolmanCatalogLoadFailed')}
</p>
) : filteredSpoolmanFilaments.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-bambu-gray">
{t('inventory.noSpoolmanFilaments')}
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-2 py-2 w-10">
<input
type="checkbox"
checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
onChange={toggleSelectAll}
className="w-4 h-4 accent-bambu-green cursor-pointer"
/>
</th>
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
<th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
<th className="px-4 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<th className="px-3 py-2 w-8"></th>
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
<th className="px-4 py-2 text-left text-bambu-gray font-medium w-28">{t('settings.catalog.material')}</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-28">{t('settings.catalog.spoolWeight')}</th>
<th className="px-3 py-2 w-20"></th>
<td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
{search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
</td>
</tr>
</thead>
<tbody>
{filteredSpoolmanFilaments.map(f => {
const isEditing = editingFilamentId === f.id;
const isSaving = patchFilamentMutation.isPending && editingFilamentId === f.id;
return (
<tr key={f.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
<td className="px-3 py-2">
<span
className="w-4 h-4 rounded-full block shrink-0 border border-white/20"
style={{ backgroundColor: f.color_hex ? `#${f.color_hex.replace('#', '')}` : '#808080' }}
aria-label={t('inventory.spoolmanFilamentColorSwatch')}
/>
</td>
<td className="px-4 py-2 text-white truncate max-w-0">
{isEditing ? (
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
{editingId === entry.id ? (
<>
<td className="px-2 py-2">
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
className="w-4 h-4 accent-bambu-green cursor-pointer"
/>
</td>
<td className="px-4 py-2">
<input
type="text"
value={editingFilamentName}
onChange={e => setEditingFilamentName(e.target.value)}
className="w-full bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green"
aria-label={t('common.name')}
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
) : (
<span className="block truncate">
{f.vendor?.name ? `${f.vendor.name} — ` : ''}{f.name}
</span>
)}
</td>
<td className="px-4 py-2 text-bambu-gray">{f.material ?? '—'}</td>
<td className="px-4 py-2 text-right font-mono text-white">
{f.weight ? `${f.weight}g` : '—'}
</td>
<td className="px-4 py-2 text-right font-mono text-bambu-gray">
{isEditing ? (
</td>
<td className="px-4 py-2">
<input
type="number"
value={editingFilamentWeight}
onChange={e => setEditingFilamentWeight(e.target.value)}
min="0"
step="1"
className="w-20 bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green text-right"
aria-label={t('settings.catalog.spoolWeight')}
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
) : (
f.spool_weight != null ? `${f.spool_weight}g` : '—'
)}
</td>
<td className="px-3 py-2">
{isEditing ? (
<div className="flex items-center gap-1 justify-end">
</td>
<td className="px-4 py-2 text-center">
<span className="text-xs text-bambu-gray">-</span>
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleFilamentSave(f)}
disabled={isSaving || !editingFilamentName.trim() || (editingFilamentWeight !== '' && (isNaN(parseFloat(editingFilamentWeight)) || parseFloat(editingFilamentWeight) <= 0))}
className="p-1 text-bambu-green hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={t('common.save')}
onClick={() => handleUpdate(entry.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button
onClick={() => { setEditingFilamentId(null); setEditingFilamentName(''); setEditingFilamentWeight(''); }}
className="p-1 text-bambu-gray hover:text-white"
aria-label={t('common.cancel')}
>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
) : (
<button
onClick={() => {
setEditingFilamentId(f.id);
setEditingFilamentName(f.name);
setEditingFilamentWeight(f.spool_weight != null ? String(f.spool_weight) : '');
}}
className="p-1 text-bambu-gray hover:text-white"
aria-label={t('common.edit')}
>
<Pencil className="w-4 h-4" />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</td>
</>
) : (
<>
<td className="px-2 py-2">
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
className="w-4 h-4 accent-bambu-green cursor-pointer"
/>
</td>
<td className="px-4 py-2 text-white">{entry.name}</td>
<td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
<td className="px-4 py-2 text-center">
{entry.is_default ? (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
{t('settings.catalog.default')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
{t('settings.catalog.custom')}
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => startEdit(entry)}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteEntry(entry)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
{/* Confirmation modals — local mode only */}
{!isSpoolmanMode && deleteEntry && (
{deleteEntry && (
<ConfirmModal
title={t('settings.catalog.deleteEntry')}
message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
@@ -709,7 +470,7 @@ export function SpoolCatalogSettings() {
/>
)}
{!isSpoolmanMode && showBulkDeleteConfirm && (
{showBulkDeleteConfirm && (
<ConfirmModal
title={t('settings.catalog.deleteSelected')}
message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}
@@ -720,7 +481,7 @@ export function SpoolCatalogSettings() {
/>
)}
{!isSpoolmanMode && showResetConfirm && (
{showResetConfirm && (
<ConfirmModal
title={t('settings.catalog.resetCatalog')}
message={t('settings.catalog.resetConfirm')}
@@ -730,15 +491,6 @@ export function SpoolCatalogSettings() {
onCancel={() => setShowResetConfirm(false)}
/>
)}
<SpoolWeightUpdateModal
isOpen={pendingWeightEdit !== null}
filamentName={pendingWeightEdit?.name ?? ''}
oldWeight={pendingWeightEdit?.oldWeight ?? null}
newWeight={pendingWeightEdit?.newWeight ?? 0}
onConfirm={handleWeightModalConfirm}
onClose={() => setPendingWeightEdit(null)}
/>
</Card>
);
}
@@ -1,102 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface SpoolWeightUpdateModalProps {
isOpen: boolean;
filamentName: string;
oldWeight: number | null;
newWeight: number;
onConfirm: (keepExisting: boolean) => void;
onClose: () => void;
}
export function SpoolWeightUpdateModal({
isOpen,
filamentName,
oldWeight,
newWeight,
onConfirm,
onClose,
}: SpoolWeightUpdateModalProps) {
const { t } = useTranslation();
const [keepExisting, setKeepExisting] = useState(false);
useEffect(() => {
if (isOpen) setKeepExisting(false);
}, [isOpen]);
if (!isOpen) return null;
const oldWeightLabel = oldWeight !== null ? `${oldWeight}g` : '—';
const newWeightLabel = `${newWeight}g`;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-white mb-1">
{t('settings.catalog.updateSpoolWeight')}
</h3>
<p className="text-bambu-gray text-sm mb-4">
{filamentName}: {oldWeightLabel} → {newWeightLabel}
</p>
<div className="space-y-3">
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
<input
type="radio"
name="weight-update-mode"
checked={!keepExisting}
onChange={() => setKeepExisting(false)}
className="mt-1 accent-bambu-green"
/>
<div>
<div className="text-sm font-medium text-white">
{t('settings.catalog.applyToAllSpools')}
</div>
<div className="text-xs text-bambu-gray mt-0.5">
{t('settings.catalog.applyToAllSpoolsDesc')}
</div>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
<input
type="radio"
name="weight-update-mode"
checked={keepExisting}
onChange={() => setKeepExisting(true)}
className="mt-1 accent-bambu-green"
/>
<div>
<div className="text-sm font-medium text-white">
{t('settings.catalog.keepExistingSpoolWeight')}
</div>
<div className="text-xs text-bambu-gray mt-0.5">
{t('settings.catalog.keepExistingSpoolWeightDesc')}
</div>
</div>
</label>
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onClose} className="flex-1">
{t('common.cancel')}
</Button>
<Button
onClick={() => onConfirm(keepExisting)}
className="flex-1 bg-bambu-green hover:bg-bambu-green-dark"
>
{t('common.confirm')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}