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
@@ -1,6 +1,5 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { SpoolCatalogSettings } from '../../components/SpoolCatalogSettings';
@@ -20,17 +19,6 @@ vi.mock('../../api/client', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({}),
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
patchSpoolmanFilament: vi.fn().mockResolvedValue({
id: 1,
name: 'PLA Basic',
material: 'PLA',
color_hex: 'FF0000',
color_name: 'Red',
weight: 1000,
spool_weight: 196,
vendor: { id: 1, name: 'Bambu Lab' },
}),
},
ApiError: class ApiError extends Error {
status: number;
@@ -41,87 +29,15 @@ vi.mock('../../api/client', () => ({
},
}));
import { api, ApiError } from '../../api/client';
import { api } from '../../api/client';
const sampleFilament = {
id: 1,
name: 'PLA Basic',
material: 'PLA',
color_hex: 'FF0000',
color_name: 'Red',
weight: 1000,
spool_weight: 196,
vendor: { id: 1, name: 'Bambu Lab' },
};
describe('SpoolCatalogSettings — mode switching', () => {
describe('SpoolCatalogSettings — local catalog UI', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getSpoolCatalog).mockResolvedValue([]);
});
it('hides Spoolman table and shows local CRUD buttons when Spoolman is disabled (400)', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
new ApiError('disabled', 400)
);
render(<SpoolCatalogSettings />);
await waitFor(() => {
// Local mode: Add button visible
expect(screen.getByText('common.add')).toBeTruthy();
});
// Spoolman table columns must NOT appear
expect(screen.queryByText('settings.catalog.material')).toBeNull();
expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
// Spoolman catalog title must NOT appear
expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
});
it('shows Spoolman error row when Spoolman is unreachable (503)', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
new ApiError('unreachable', 503)
);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('inventory.spoolmanCatalogLoadFailed')).toBeTruthy();
});
// Local CRUD buttons must NOT appear in Spoolman mode
expect(screen.queryByText('common.add')).toBeNull();
});
it('shows empty state when Spoolman returns an empty list', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
});
// Local CRUD buttons must NOT appear
expect(screen.queryByText('common.add')).toBeNull();
});
it('renders Spoolman filament rows with vendor and name combined', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
});
it('(local mode) shows Export, Import, Reset, Add buttons when Spoolman disabled', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
new ApiError('disabled', 400)
);
it('shows local CRUD buttons regardless of Spoolman state', async () => {
render(<SpoolCatalogSettings />);
await waitFor(() => {
@@ -133,341 +49,20 @@ describe('SpoolCatalogSettings — mode switching', () => {
expect(screen.getByText('common.reset')).toBeTruthy();
});
it('(spoolman mode) hides Export, Import, Reset, Add buttons when Spoolman is enabled', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
it('renders the local Spool Catalog header and column layout', async () => {
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
expect(screen.getByText('settings.catalog.spoolCatalog')).toBeTruthy();
});
expect(screen.queryByText('common.add')).toBeNull();
expect(screen.queryByText('common.export')).toBeNull();
expect(screen.queryByText('common.import')).toBeNull();
expect(screen.queryByText('common.reset')).toBeNull();
});
it('(spoolman mode) renders correct column headers — Name, Material, Weight, Spool Weight', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('common.name')).toBeTruthy();
});
expect(screen.getByText('settings.catalog.material')).toBeTruthy();
expect(screen.getByText('common.name')).toBeTruthy();
expect(screen.getByText('settings.catalog.weight')).toBeTruthy();
expect(screen.getByText('settings.catalog.spoolWeight')).toBeTruthy();
});
expect(screen.getByText('settings.catalog.type')).toBeTruthy();
it('(spoolman mode) renders all data fields for a filament row', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
// Material column
expect(screen.getByText('PLA')).toBeTruthy();
// Filament weight
expect(screen.getByText('1000g')).toBeTruthy();
// Spool (empty) weight
expect(screen.getByText('196g')).toBeTruthy();
});
it('(spoolman mode) renders color swatch with correct background color', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
{ ...sampleFilament, color_hex: 'FF5500' },
]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
const bg = (swatch as HTMLElement).style.backgroundColor;
// Accepts both hex-like and rgb() representations
expect(bg).toBeTruthy();
expect(bg).not.toBe('');
});
it('(spoolman mode) renders fallback color when color_hex is null', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
{ ...sampleFilament, color_hex: null },
]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
expect((swatch as HTMLElement).style.backgroundColor).toContain('128');
});
it('(spoolman mode) renders dash for null material, weight, and spool_weight', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
{ ...sampleFilament, material: null, weight: null, spool_weight: null },
]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
// All three nullable fields must show '—', not 'nullg' or empty string
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(3);
});
it('(spoolman mode) shows Spoolman catalog title, not local catalog title', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('settings.spoolmanFilamentCatalogTitle')).toBeTruthy();
});
expect(screen.queryByText('settings.catalog.spoolCatalog')).toBeNull();
});
it('(spoolman mode) shows pencil edit button in each filament row', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
const editButtons = screen.getAllByLabelText('common.edit');
expect(editButtons.length).toBeGreaterThanOrEqual(1);
});
it('(spoolman mode) clicking pencil shows name and weight inputs', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.name')).toBeTruthy();
expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
});
});
it('(spoolman mode) name input is pre-filled with current name', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
const nameInput = screen.getByLabelText('common.name') as HTMLInputElement;
expect(nameInput.value).toBe('PLA Basic');
});
});
it('(spoolman mode) weight input is pre-filled with current spool_weight', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
const weightInput = screen.getByLabelText('settings.catalog.spoolWeight') as HTMLInputElement;
expect(weightInput.value).toBe('196');
});
});
it('(spoolman mode) cancel edit restores read-only display', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.cancel')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.cancel'));
await waitFor(() => {
expect(screen.queryByLabelText('common.name')).toBeNull();
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
});
it('(spoolman mode) empty name input disables save button', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.name')).toBeTruthy();
});
const nameInput = screen.getByLabelText('common.name');
fireEvent.change(nameInput, { target: { value: '' } });
const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
it('(spoolman mode) saving name-only calls patchSpoolmanFilament without modal', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.name')).toBeTruthy();
});
const nameInput = screen.getByLabelText('common.name');
fireEvent.change(nameInput, { target: { value: 'PLA Basic Renamed' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => {
expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(1, { name: 'PLA Basic Renamed' });
});
// Modal must NOT appear
expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
});
it('(spoolman mode) saving changed spool_weight opens SpoolWeightUpdateModal', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
});
const weightInput = screen.getByLabelText('settings.catalog.spoolWeight');
fireEvent.change(weightInput, { target: { value: '100' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => {
expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
});
});
it('(spoolman mode) confirming option B calls patchSpoolmanFilament with keep_existing_spools=false', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
// Confirm with option B selected by default
fireEvent.click(screen.getByText('common.confirm'));
await waitFor(() => {
expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
1,
expect.objectContaining({ spool_weight: 100, keep_existing_spools: false }),
);
});
});
it('(spoolman mode) confirming option A calls patchSpoolmanFilament with keep_existing_spools=true', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
// Select option A (keep existing)
const radios = screen.getAllByRole('radio');
fireEvent.click(radios[1]); // Option A = second radio = keepExisting=true
fireEvent.click(screen.getByText('common.confirm'));
await waitFor(() => {
expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
1,
expect.objectContaining({ spool_weight: 100, keep_existing_spools: true }),
);
});
});
it('(spoolman mode) negative weight input disables save button', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '-5' } });
const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
// No Spoolman-only columns leak in
expect(screen.queryByText('settings.catalog.material')).toBeNull();
expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
});
});
@@ -1,97 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { SpoolWeightUpdateModal } from '../../components/SpoolWeightUpdateModal';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const defaultProps = {
isOpen: true,
filamentName: 'PLA Basic',
oldWeight: 250,
newWeight: 196,
onConfirm: vi.fn(),
onClose: vi.fn(),
};
describe('SpoolWeightUpdateModal', () => {
it('renders filament name, old weight, and new weight', () => {
render(<SpoolWeightUpdateModal {...defaultProps} />);
expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
expect(screen.getByText(/PLA Basic/)).toBeTruthy();
expect(screen.getByText(/250g → 196g/)).toBeTruthy();
});
it('renders option labels', () => {
render(<SpoolWeightUpdateModal {...defaultProps} />);
expect(screen.getByText('settings.catalog.applyToAllSpools')).toBeTruthy();
expect(screen.getByText('settings.catalog.keepExistingSpoolWeight')).toBeTruthy();
});
it('option B (apply to all) is selected by default', () => {
render(<SpoolWeightUpdateModal {...defaultProps} />);
const radios = screen.getAllByRole('radio') as HTMLInputElement[];
// First radio = apply-to-all (Option B, keepExisting=false)
expect(radios[0].checked).toBe(true);
expect(radios[1].checked).toBe(false);
});
it('calls onConfirm(false) when option B is selected and Confirm clicked', () => {
const onConfirm = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(onConfirm).toHaveBeenCalledWith(false);
});
it('calls onConfirm(true) when option A is selected and Confirm clicked', () => {
const onConfirm = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
const radios = screen.getAllByRole('radio');
fireEvent.click(radios[1]); // Option A: keep existing
fireEvent.click(screen.getByText('common.confirm'));
expect(onConfirm).toHaveBeenCalledWith(true);
});
it('calls onClose on Cancel click', () => {
const onClose = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(onClose).toHaveBeenCalled();
});
it('does not call onConfirm on Cancel click', () => {
const onConfirm = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(onConfirm).not.toHaveBeenCalled();
});
it('renders dash when oldWeight is null', () => {
render(<SpoolWeightUpdateModal {...defaultProps} oldWeight={null} />);
expect(screen.getByText(/— → 196g/)).toBeTruthy();
});
it('returns null when isOpen is false', () => {
render(<SpoolWeightUpdateModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
});
});
+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>
);
}