From 1a44b87cdc875fa92d23976eff44e48bb5dc727b Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Tue, 20 Oct 2026 19:58:08 +0200 Subject: [PATCH] fix: count suffixed renames, roll back failed theme saves, keep reset dialog open on error --- backend/src/services/categoryService.ts | 17 +++++++----- backend/tests/categoryReset.test.ts | 8 ++++-- frontend/src/App.tsx | 27 +++++++++++++++---- frontend/src/components/Layout/AppLayout.tsx | 2 +- .../ConfigurationDialog/AppearancePanel.tsx | 18 ++++++++++--- .../Layout/ConfigurationDialog/index.tsx | 2 +- frontend/src/components/Layout/TopBar.tsx | 2 +- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/i18n/locales/fr.json | 1 + frontend/src/i18n/locales/lt.json | 1 + .../pages/ModelsPage/CategoryResetDialog.tsx | 4 ++- frontend/src/pages/ModelsPage/index.tsx | 3 ++- 13 files changed, 65 insertions(+), 22 deletions(-) diff --git a/backend/src/services/categoryService.ts b/backend/src/services/categoryService.ts index 1ee53ca..07c3018 100644 --- a/backend/src/services/categoryService.ts +++ b/backend/src/services/categoryService.ts @@ -2,7 +2,7 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "../db"; import { HttpError } from "../utils/fileUtils"; import { DEFAULT_CATEGORIES, type DefaultCategoryNode } from "../seedData/defaultCategories"; -import { availableModelName, reorganizeManagedPrints } from "./printService"; +import { availableModelName, reorganizeManagedPrints, sanitizePathSegment } from "./printService"; export const CATEGORY_KINDS = ["category", "folder"] as const; export type CategoryKind = (typeof CATEGORY_KINDS)[number]; @@ -188,24 +188,27 @@ async function planCategoryReset(db: Prisma.TransactionClient, userId: string): const modelsIn = new Map(); for (const p of prints) modelsIn.set(p.categoryId!, (modelsIn.get(p.categoryId!) ?? 0) + 1); - // A moved model is renamed when its name is already taken where it lands, by a model there or one moved earlier. + // A moved model is renamed as availableModelName will at reset: suffixed past names taken where it lands, by a model + // there or one moved earlier. let renamed = 0; if (moves.length) { - const names = [...new Set(moves.map((m) => m.name.trim().toLowerCase()))]; const targets = [...new Set(moves.map((m) => m.target).filter((t): t is string => t !== null))]; const staying = await db.print.findMany({ where: { userId, - nameNormalized: { in: names }, OR: [{ categoryId: { in: targets } }, ...(moves.some((m) => !m.target) ? [{ categoryId: null }] : [])], }, select: { categoryId: true, nameNormalized: true }, }); const taken = new Set(staying.map((p) => `${p.categoryId}\u0000${p.nameNormalized}`)); for (const m of moves) { - const key = `${m.target}\u0000${m.name.trim().toLowerCase()}`; - if (taken.has(key)) renamed++; - else taken.add(key); + const base = sanitizePathSegment(m.name, "Model"); + let candidate = base; + for (let suffix = 2; taken.has(`${m.target}\u0000${candidate.trim().toLowerCase()}`); suffix++) { + candidate = `${base} (${suffix})`; + } + taken.add(`${m.target}\u0000${candidate.trim().toLowerCase()}`); + if (candidate !== m.name) renamed++; } } diff --git a/backend/tests/categoryReset.test.ts b/backend/tests/categoryReset.test.ts index 97f9731..06786dd 100644 --- a/backend/tests/categoryReset.test.ts +++ b/backend/tests/categoryReset.test.ts @@ -111,15 +111,18 @@ describe("POST /api/categories/reset-defaults", () => { const extra = await create(`Extra ${stamp}`, child.id); const nested = await create(`Nested ${stamp}`, extra); const fromNested = await createPrint(`Clash ${stamp}`, nested); + // Lands after the first one has taken its name. + const suffixed = await createPrint(`Clash ${stamp} (2)`, nested); + await prisma.print.update({ where: { id: suffixed.id }, data: { createdAt: new Date(Date.now() + 1000) } }); const planned = await preview(); expect(planned.added).toEqual([]); expect(planned.restored).toEqual([]); expect(planned.removed).toEqual([ { path: [firstRoot.name, firstChild.name, `Extra ${stamp}`], models: 0 }, - { path: [firstRoot.name, firstChild.name, `Extra ${stamp}`, `Nested ${stamp}`], models: 1 }, + { path: [firstRoot.name, firstChild.name, `Extra ${stamp}`, `Nested ${stamp}`], models: 2 }, ]); - expect(planned).toMatchObject({ models_moved: 1, models_uncategorized: 0, models_renamed: 1 }); + expect(planned).toMatchObject({ models_moved: 2, models_uncategorized: 0, models_renamed: 2 }); expect((await reset()).status).toBe(200); const after = await list(); @@ -131,5 +134,6 @@ describe("POST /api/categories/reset-defaults", () => { expect(moved.categoryId).toBe(child.id); expect(moved.categorySource).toBe("MANUAL"); expect(moved.name).toBe(`Clash ${stamp} (2)`); + expect((await prisma.print.findUniqueOrThrow({ where: { id: suffixed.id } })).name).toBe(`Clash ${stamp} (2) (2)`); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a54571..dd1c528 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import AiPage from "./pages/AiPage"; import { healthApi, type HealthInfo } from "./api/health"; import { authApi, type AuthUser } from "./api/auth"; import { settingsApi, type PreviewMode } from "./api/settings"; +import { UnauthorizedError } from "./api/client"; import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth"; import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings"; import { useResolvedTheme } from "./hooks/useResolvedTheme"; @@ -63,7 +64,7 @@ type AppShellProps = { previewMode: PreviewMode; setPreviewMode: (mode: PreviewMode) => void; themeSelection: ThemeSelection; - onThemeChange: (theme: ThemeSelection) => void; + onThemeChange: (theme: ThemeSelection) => Promise; resolvedTheme: ResolvedTheme; muiTheme: ReturnType; onUnauthorized: () => void; @@ -356,10 +357,6 @@ export default function App() { active = false; }; }, [token]); - const handleThemeChange = React.useCallback((selected: ThemeSelection) => { - setThemeSelection(selected); - void settingsApi.updateTheme({ theme: selected }).catch(() => {}); - }, []); React.useEffect(() => { saveSettings(settings); }, [settings]); @@ -386,6 +383,26 @@ export default function App() { setShowExpiredToast(true); }, []); + // Picks can overlap; only the latest one's failure puts the theme back. + const latestThemeSave = React.useRef(0); + /** Resolves false when the save failed and the theme went back. */ + const handleThemeChange = React.useCallback( + async (selected: ThemeSelection): Promise => { + const previous = themeSelection; + const save = ++latestThemeSave.current; + setThemeSelection(selected); + try { + await settingsApi.updateTheme({ theme: selected }); + return true; + } catch (err) { + if (save === latestThemeSave.current) setThemeSelection(previous); + if (err instanceof UnauthorizedError) handleUnauthorized(); + return false; + } + }, + [themeSelection, handleUnauthorized], + ); + const handleLogout = () => { // Fire-and-forget: local logout proceeds immediately. void authApi.logout(); diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 03eb01c..38068fd 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -41,7 +41,7 @@ type AppLayoutProps = { makerworldCookie: string; onUpdateMakerWorld: (patch: Partial) => void; user: AuthUser | null; - onThemeChange: (theme: ThemeSelection) => void; + onThemeChange: (theme: ThemeSelection) => Promise; children: React.ReactNode; }; diff --git a/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx b/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx index a44c527..83fddb1 100644 --- a/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx +++ b/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx @@ -43,7 +43,7 @@ const MAX_SEGMENTED_LANGUAGES = 4; type Props = { theme: ThemeSelection; - onThemeChange: (theme: ThemeSelection) => void; + onThemeChange: (theme: ThemeSelection) => Promise; onUnauthorized?: () => void; }; @@ -69,11 +69,18 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized } themeSaves.current = request.catch(() => undefined); return request; }; + const [themeError, setThemeError] = React.useState(null); const [cardPreviewError, setCardPreviewError] = React.useState(null); const [hoverSlideshow, setHoverSlideshow] = useHoverSlideshowEnabled(); const [hoverSlideshowError, setHoverSlideshowError] = React.useState(null); const confirm = useConfirm(); + const changeTheme = async (next: ThemeSelection) => { + setThemeError(null); + // A lapsed session also signs out, where the theme lives. + if (!(await onThemeChange(next))) setThemeError(t("appearance.themeFailed")); + }; + const changeCardPreview = async (next: CardPreview) => { setCardPreviewError(null); try { @@ -162,7 +169,7 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized } }); if (!confirmed) return; // Only what differs is saved; each keeps its own error message if its save fails. - if (theme !== DEFAULT_THEME) onThemeChange(DEFAULT_THEME); + if (theme !== DEFAULT_THEME) void changeTheme(DEFAULT_THEME); // A new accent puts the render colour back to following it. if (!accentAtDefault) void changeAccent(DEFAULT_ACCENT); if (cardPreview !== null && cardPreview !== DEFAULT_CARD_PREVIEW) void changeCardPreview(DEFAULT_CARD_PREVIEW); @@ -217,9 +224,14 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized } void changeTheme(next)} options={THEMES.map((value) => ({ value, label: t(`appearance.themes.${value}`) }))} /> + {themeError && ( + + {themeError} + + )}
{t("appearance.accent")} diff --git a/frontend/src/components/Layout/ConfigurationDialog/index.tsx b/frontend/src/components/Layout/ConfigurationDialog/index.tsx index b17d6e5..e1c9026 100644 --- a/frontend/src/components/Layout/ConfigurationDialog/index.tsx +++ b/frontend/src/components/Layout/ConfigurationDialog/index.tsx @@ -24,7 +24,7 @@ type Props = { open: ConfigurationTab | null; onClose: () => void; theme: ThemeSelection; - onThemeChange: (theme: ThemeSelection) => void; + onThemeChange: (theme: ThemeSelection) => Promise; isAdmin: boolean; makerworldCookie: string; onUpdateMakerWorld: (patch: Partial) => void; diff --git a/frontend/src/components/Layout/TopBar.tsx b/frontend/src/components/Layout/TopBar.tsx index 4c39a26..8843a96 100644 --- a/frontend/src/components/Layout/TopBar.tsx +++ b/frontend/src/components/Layout/TopBar.tsx @@ -29,7 +29,7 @@ type Props = { onUnauthorized?: () => void; user: AuthUser | null; theme: ThemeSelection; - onThemeChange: (theme: ThemeSelection) => void; + onThemeChange: (theme: ThemeSelection) => Promise; onOpenProfile: () => void; onLogout: () => void; }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c9833b1..af50e80 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1578,6 +1578,7 @@ "off": "Off", "accent": "Accent color", "accentFailed": "Couldn't save the accent color.", + "themeFailed": "Couldn't save the theme.", "accents": { "green": "Thingport green", "blue": "Thingiverse blue", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index f86096d..38bacc9 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -1578,6 +1578,7 @@ "off": "Desactivado", "accent": "Color de acento", "accentFailed": "No se pudo guardar el color de acento.", + "themeFailed": "No se pudo guardar el tema.", "accents": { "green": "Verde Thingport", "blue": "Azul Thingiverse", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 6e13f30..300d09a 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1591,6 +1591,7 @@ "off": "Désactivé", "accent": "Couleur d’accent", "accentFailed": "Impossible d’enregistrer la couleur d’accent.", + "themeFailed": "Impossible d’enregistrer le thème.", "accents": { "green": "Vert Thingport", "blue": "Bleu Thingiverse", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 072664c..05382d2 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -1644,6 +1644,7 @@ "off": "Išjungta", "accent": "Akcento spalva", "accentFailed": "Nepavyko išsaugoti akcento spalvos.", + "themeFailed": "Nepavyko išsaugoti temos.", "accents": { "green": "Thingport žalia", "blue": "Thingiverse mėlyna", diff --git a/frontend/src/pages/ModelsPage/CategoryResetDialog.tsx b/frontend/src/pages/ModelsPage/CategoryResetDialog.tsx index 14c0da1..01f157b 100644 --- a/frontend/src/pages/ModelsPage/CategoryResetDialog.tsx +++ b/frontend/src/pages/ModelsPage/CategoryResetDialog.tsx @@ -25,7 +25,7 @@ const fadeIn = keyframes` type Props = { open: boolean; onClose: () => void; - /** Resets; reports its own failure. */ + /** Resets; reports its own failure, then rejects to keep the dialog open. */ onConfirm: () => Promise; onUnauthorized?: () => void; }; @@ -129,6 +129,8 @@ export default function CategoryResetDialog({ open, onClose, onConfirm, onUnauth try { await onConfirm(); onClose(); + } catch { + // onConfirm has reported it; staying open keeps the preview for a retry. } finally { setResetting(false); } diff --git a/frontend/src/pages/ModelsPage/index.tsx b/frontend/src/pages/ModelsPage/index.tsx index 058ef59..10526f0 100644 --- a/frontend/src/pages/ModelsPage/index.tsx +++ b/frontend/src/pages/ModelsPage/index.tsx @@ -223,7 +223,7 @@ export default function ModelsPage({ } }; - // Models in removed categories move, so the list reloads too. + // Models in removed categories move, so the list reloads too. Rethrows so the reset dialog stays open to retry. const resetCategories = async () => { try { const next = await categoriesApi.resetDefaults(); @@ -232,6 +232,7 @@ export default function ModelsPage({ refreshLibrary(); } catch (err) { handleError(err, t("models:errors.resetCategoriesFailed")); + throw err; } };