From 3ae4f212178578edac394dfdbdb5cbb331c91e61 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 18:15:29 +0200 Subject: [PATCH] refactor(catalog): separate Studio data hooks and presentation --- src/components/admin/studio/README.md | 23 +++ .../admin/studio/furniture-inspector.tsx | 53 +------ .../admin/studio/furniture-jobs.tsx | 90 +---------- src/components/admin/studio/import-review.tsx | 7 +- src/components/admin/studio/studio-client.tsx | 147 ++++-------------- src/components/admin/studio/studio-filters.ts | 51 ++++++ .../admin/studio/use-furniture-inspection.ts | 54 +++++++ .../admin/studio/use-furniture-jobs.ts | 90 +++++++++++ .../admin/studio/use-studio-translations.ts | 96 ++++++++++++ 9 files changed, 348 insertions(+), 263 deletions(-) create mode 100644 src/components/admin/studio/README.md create mode 100644 src/components/admin/studio/studio-filters.ts create mode 100644 src/components/admin/studio/use-furniture-inspection.ts create mode 100644 src/components/admin/studio/use-furniture-jobs.ts create mode 100644 src/components/admin/studio/use-studio-translations.ts diff --git a/src/components/admin/studio/README.md b/src/components/admin/studio/README.md new file mode 100644 index 00000000..2f9a94f8 --- /dev/null +++ b/src/components/admin/studio/README.md @@ -0,0 +1,23 @@ +# Catalog Studio code map + +`studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree. + +## Client responsibilities + +- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array. +- `use-studio-translations.ts`: language selection, persisted defaults and translation requests. +- `use-furniture-jobs.ts`: queue submission, retry identity, polling and completion notifications. +- `furniture-jobs.tsx`: job history and retry controls. +- `use-furniture-inspection.ts`: cancellable inspection requests and explicit rechecks. +- `furniture-inspector.tsx`: local component status, source comparison and repair controls. +- `import-review.tsx`: import review and attachment confirmation. +- `studio-types.ts`: shared client data types. + +## Server responsibilities + +- `src/lib/services/furniture-inspection.ts`: reads SQL, catalog offers, furnidata and asset presence. +- `src/lib/furni/studio-inspection.ts`: shared validation, comparison and completeness classification. +- `src/lib/services/furniture-repair.ts`: preflight, preservation options and post-repair verification. +- `src/lib/services/furni-job-worker.ts`: durable queued work and per-item results. + +Keep client modules free of filesystem/database imports. Background refreshes must not reset open editors or unsaved input. Completion status must come from verification, not optimistic UI flags. diff --git a/src/components/admin/studio/furniture-inspector.tsx b/src/components/admin/studio/furniture-inspector.tsx index 5ff0703e..56d4188c 100644 --- a/src/components/admin/studio/furniture-inspector.tsx +++ b/src/components/admin/studio/furniture-inspector.tsx @@ -1,64 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { adminFetch } from "@/lib/admin-fetch"; import { compareFurniture, type FurnitureInspection, furnitureHealth, } from "@/lib/furni/studio-inspection"; import type { FurniItem } from "./studio-types"; +import { useFurnitureInspection } from "./use-furniture-inspection"; -export function useFurnitureInspection(classnames: string[]) { - const [attempt, setAttempt] = useState(0); - const key = JSON.stringify({ classnames, attempt }); - const [state, setState] = useState<{ - key: string; - items: FurnitureInspection[]; - error: string; - loading: boolean; - }>({ key: "", items: [], error: "", loading: true }); - useEffect(() => { - const abort = new AbortController(); - setState({ key, items: [], error: "", loading: true }); - void (async () => { - try { - const names = JSON.parse(key).classnames as string[]; - const inspected: FurnitureInspection[] = []; - for (let offset = 0; offset < names.length; offset += 500) { - const res = await adminFetch("/api/admin/studio/inspect", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - classnames: names.slice(offset, offset + 500), - }), - signal: abort.signal, - }); - const data = await res.json(); - if (!res.ok || !Array.isArray(data.items)) - throw new Error(data.error || "Could not inspect local furniture"); - inspected.push(...data.items); - } - if (!abort.signal.aborted) - setState({ key, items: inspected, error: "", loading: false }); - } catch (error) { - if (!abort.signal.aborted) - setState({ - key, - items: [], - error: (error as Error).message, - loading: false, - }); - } - })(); - return () => abort.abort(); - }, [key]); - return { - ...state, - loading: state.key !== key || state.loading, - retry: () => setAttempt((value) => value + 1), - }; -} const status = (value: boolean | null | undefined) => value == null ? "Not verified" : value ? "Present" : "Missing"; export function FurnitureComparison({ diff --git a/src/components/admin/studio/furniture-jobs.tsx b/src/components/admin/studio/furniture-jobs.tsx index b3fa53eb..720f842a 100644 --- a/src/components/admin/studio/furniture-jobs.tsx +++ b/src/components/admin/studio/furniture-jobs.tsx @@ -1,94 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { adminFetch } from "@/lib/admin-fetch"; -import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; -export function useFurnitureJobs(onComplete: () => void) { - const [jobs, setJobs] = useState([]), - [busy, setBusy] = useState(false), - [error, setError] = useState(""); - const completed = useRef(new Set()), - callback = useRef(onComplete); - callback.current = onComplete; - const request = useRef<{ payload: string; id: string } | null>(null); - const submitting = useRef(false); - const refresh = useCallback(async () => { - try { - const response = await adminFetch("/api/admin/studio/import-jobs"); - const data = await response.json(); - if (!response.ok) - throw Error( - data.error || `Import history unavailable (${response.status})`, - ); - setJobs(data.jobs); - setError(""); - let changed = false; - for (const job of data.jobs as ImportJob[]) - if ( - (job.state === "completed" || job.state === "interrupted") && - !completed.current.has(job.id) - ) { - completed.current.add(job.id); - changed = true; - } - if (changed) callback.current(); - } catch (error) { - setError( - error instanceof Error ? error.message : "Cannot load import history", - ); - } - }, []); - useEffect(() => { - void refresh(); - const timer = setInterval(() => void refresh(), 5000); - return () => clearInterval(timer); - }, [refresh]); - const submit = async ( - items: ImportJobItem[], - options: { - sourceId?: string; - translate: boolean; - langs?: string[]; - mode?: "repair"; - }, - ) => { - if (submitting.current) return false; - submitting.current = true; - setBusy(true); - const body = { items, ...options }, - payload = JSON.stringify(body); - if (request.current?.payload !== payload) - request.current = { payload, id: crypto.randomUUID() }; - try { - const response = await adminFetch("/api/admin/studio/import-jobs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...body, id: request.current.id }), - }); - const data = await response.json(); - if (!response.ok) - throw Error( - data.error || `Could not queue import (${response.status})`, - ); - request.current = null; - await refresh(); - toast.success( - "Import queued. You can leave this page and return to its history.", - ); - return true; - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Could not queue import", - ); - return false; - } finally { - submitting.current = false; - setBusy(false); - } - }; - return { jobs, busy, error, submit, refresh }; -} +import type { useFurnitureJobs } from "./use-furniture-jobs"; export function FurnitureJobHistory({ state, }: { diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx index 90d9b1c8..c24a2ffc 100644 --- a/src/components/admin/studio/import-review.tsx +++ b/src/components/admin/studio/import-review.tsx @@ -1,4 +1,5 @@ "use client"; + import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -13,11 +14,9 @@ import { import { adminFetch } from "@/lib/admin-fetch"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { compareFurniture } from "@/lib/furni/studio-inspection"; -import { - FurnitureComparison, - useFurnitureInspection, -} from "./furniture-inspector"; +import { FurnitureComparison } from "./furniture-inspector"; import type { FurniItem } from "./studio-types"; +import { useFurnitureInspection } from "./use-furniture-inspection"; import { useSourceAssetChecks } from "./use-source-asset-checks"; export function ImportReview({ diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index ed16c9f8..cf412b9f 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -25,10 +25,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { - deleteImportedFurni, - setFurnidataTranslateEnabled, -} from "@/actions/import-furni"; +import { deleteImportedFurni } from "@/actions/import-furni"; import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; @@ -68,8 +65,9 @@ import { BatchProgress } from "./batch-progress"; import { CatalogRail } from "./catalog-rail"; import { CheckboxDot } from "./checkbox-dot"; import { FurnitureInspector } from "./furniture-inspector"; -import { FurnitureJobHistory, useFurnitureJobs } from "./furniture-jobs"; +import { FurnitureJobHistory } from "./furniture-jobs"; import { ImportReview } from "./import-review"; +import { filterFurniture } from "./studio-filters"; import { getFurniImageUrl, handleImgError, @@ -82,25 +80,8 @@ import type { Meta, VerificationSummary, } from "./studio-types"; - -// Language codes that get a FurnitureData_.json file. Mirrors -// FURNIDATA_LANGUAGES in furni-data-i18n (kept client-safe here so we don't pull -// server-only fs modules into the browser bundle). -const TRANSLATE_LANGS = [ - "nl", - "en", - "de", - "fr", - "es", - "tr", - "it", - "pt", - "fi", - "pl", - "ru", - "ar", - "ja", -] as const; +import { useFurnitureJobs } from "./use-furniture-jobs"; +import { useStudioTranslations } from "./use-studio-translations"; export function StudioClient({ source, @@ -206,106 +187,36 @@ export function StudioClient({ new Set(), ); - // Translation controls (per-import toggle + language selection, plus a global - // default that persists to the site setting and seeds the per-import toggle). - const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; - const [translate, setTranslate] = useState(defaultTranslate); - const [translateLangs, setTranslateLangs] = useState([ - ...ALL_LANGS, - ]); - const [langPickerOpen, setLangPickerOpen] = useState(false); - const [buildingLanguages, setBuildingLanguages] = useState(false); - const [globalTranslate, setGlobalTranslate] = - useState(defaultTranslate); - - // Build every language for the whole catalog at once (used to translate - // everything after importing with translation disabled for speed). - const buildAllLanguages = async () => { - setBuildingLanguages(true); - try { - const res = await adminFetch( - "/api/admin/import/furni?action=build-languages", - ); - const data = (await res.json()) as { - languages?: Array<{ lang: string; ok: boolean }>; - error?: string; - }; - if (res.ok) { - const langs = data.languages ?? []; - const okCount = langs.filter((l) => l.ok).length; - toast.success( - `Languages translated (${okCount}/${langs.length} completed)`, - ); - } else { - toast.error(data.error ?? "Translation failed"); - } - } catch { - toast.error("Network error while translating languages"); - } finally { - setBuildingLanguages(false); - } - }; - - const toggleLang = (lang: string) => - setTranslateLangs((prev) => - prev.includes(lang) ? prev.filter((l) => l !== lang) : [...prev, lang], - ); - - const onGlobalTranslateChange = async (next: boolean) => { - setGlobalTranslate(next); - setTranslate(next); - try { - await setFurnidataTranslateEnabled({ enabled: next }); - } catch { - toast.error("Could not save the default translation setting"); - } - }; + const { + ALL_LANGS, + translate, + setTranslate, + translateLangs, + setTranslateLangs, + langPickerOpen, + setLangPickerOpen, + buildingLanguages, + globalTranslate, + buildAllLanguages, + toggleLang, + onGlobalTranslateChange, + } = useStudioTranslations(defaultTranslate); const categories = useMemo(() => { const cats = new Set(items.map((i) => i.category).filter(Boolean)); return ["all", ...Array.from(cats).sort()]; }, [items]); - const filteredItems = useMemo(() => { - let result = items; - if (typeFilter !== "all") { - result = result.filter((i) => i.type === typeFilter); - } - if (categoryFilter !== "all") { - result = result.filter((i) => i.category === categoryFilter); - } - if (statusFilter === "imported") - result = result.filter((i) => i.alreadyImported); - else if (statusFilter === "inCatalog") - result = result.filter((i) => i.alreadyImported && i.nitroExists); - else if (statusFilter === "missingNitro") - result = result.filter((i) => i.alreadyImported && !i.nitroExists); - else if (statusFilter === "missingFurnidata") - result = result.filter( - (i) => i.alreadyImported && i.hasFurnidata === false, - ); - else if (statusFilter === "missingCatalog") - result = result.filter( - (i) => i.alreadyImported && i.hasCatalogEntry === false, - ); - else if (statusFilter === "notImported") - result = result.filter((i) => !i.alreadyImported); - const sorted = [...result]; - if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name)); - else if (sortBy === "classname") - sorted.sort((a, b) => a.classname.localeCompare(b.classname)); - else if (sortBy === "category") - sorted.sort((a, b) => (a.category || "").localeCompare(b.category || "")); - else if (sortBy === "imported-first") - sorted.sort( - (a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0), - ); - else if (sortBy === "not-imported-first") - sorted.sort( - (a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0), - ); - return sorted; - }, [items, typeFilter, categoryFilter, statusFilter, sortBy]); + const filteredItems = useMemo( + () => + filterFurniture(items, { + typeFilter, + categoryFilter, + statusFilter, + sortBy, + }), + [items, typeFilter, categoryFilter, statusFilter, sortBy], + ); const allSelected = useMemo( () => diff --git a/src/components/admin/studio/studio-filters.ts b/src/components/admin/studio/studio-filters.ts new file mode 100644 index 00000000..1b8fecb1 --- /dev/null +++ b/src/components/admin/studio/studio-filters.ts @@ -0,0 +1,51 @@ +import type { FurniItem } from "./studio-types"; + +interface FurnitureFilters { + typeFilter: string; + categoryFilter: string; + statusFilter: string; + sortBy: string; +} +export function filterFurniture( + items: FurniItem[], + { typeFilter, categoryFilter, statusFilter, sortBy }: FurnitureFilters, +): FurniItem[] { + let result = items; + if (typeFilter !== "all") { + result = result.filter((i) => i.type === typeFilter); + } + if (categoryFilter !== "all") { + result = result.filter((i) => i.category === categoryFilter); + } + if (statusFilter === "imported") + result = result.filter((i) => i.alreadyImported); + else if (statusFilter === "inCatalog") + result = result.filter((i) => i.alreadyImported && i.nitroExists); + else if (statusFilter === "missingNitro") + result = result.filter((i) => i.alreadyImported && !i.nitroExists); + else if (statusFilter === "missingFurnidata") + result = result.filter( + (i) => i.alreadyImported && i.hasFurnidata === false, + ); + else if (statusFilter === "missingCatalog") + result = result.filter( + (i) => i.alreadyImported && i.hasCatalogEntry === false, + ); + else if (statusFilter === "notImported") + result = result.filter((i) => !i.alreadyImported); + const sorted = [...result]; + if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name)); + else if (sortBy === "classname") + sorted.sort((a, b) => a.classname.localeCompare(b.classname)); + else if (sortBy === "category") + sorted.sort((a, b) => (a.category || "").localeCompare(b.category || "")); + else if (sortBy === "imported-first") + sorted.sort( + (a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0), + ); + else if (sortBy === "not-imported-first") + sorted.sort( + (a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0), + ); + return sorted; +} diff --git a/src/components/admin/studio/use-furniture-inspection.ts b/src/components/admin/studio/use-furniture-inspection.ts new file mode 100644 index 00000000..0f399557 --- /dev/null +++ b/src/components/admin/studio/use-furniture-inspection.ts @@ -0,0 +1,54 @@ +"use client"; +import { useEffect, useState } from "react"; +import { adminFetch } from "@/lib/admin-fetch"; +import type { FurnitureInspection } from "@/lib/furni/studio-inspection"; +export function useFurnitureInspection(classnames: string[]) { + const [attempt, setAttempt] = useState(0); + const key = JSON.stringify({ classnames, attempt }); + const [state, setState] = useState<{ + key: string; + items: FurnitureInspection[]; + error: string; + loading: boolean; + }>({ key: "", items: [], error: "", loading: true }); + useEffect(() => { + const abort = new AbortController(); + setState({ key, items: [], error: "", loading: true }); + void (async () => { + try { + const names = JSON.parse(key).classnames as string[]; + const inspected: FurnitureInspection[] = []; + for (let offset = 0; offset < names.length; offset += 500) { + const res = await adminFetch("/api/admin/studio/inspect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + classnames: names.slice(offset, offset + 500), + }), + signal: abort.signal, + }); + const data = await res.json(); + if (!res.ok || !Array.isArray(data.items)) + throw new Error(data.error || "Could not inspect local furniture"); + inspected.push(...data.items); + } + if (!abort.signal.aborted) + setState({ key, items: inspected, error: "", loading: false }); + } catch (error) { + if (!abort.signal.aborted) + setState({ + key, + items: [], + error: (error as Error).message, + loading: false, + }); + } + })(); + return () => abort.abort(); + }, [key]); + return { + ...state, + loading: state.key !== key || state.loading, + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/src/components/admin/studio/use-furniture-jobs.ts b/src/components/admin/studio/use-furniture-jobs.ts new file mode 100644 index 00000000..315a984a --- /dev/null +++ b/src/components/admin/studio/use-furniture-jobs.ts @@ -0,0 +1,90 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { adminFetch } from "@/lib/admin-fetch"; +import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; +export function useFurnitureJobs(onComplete: () => void) { + const [jobs, setJobs] = useState([]), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const completed = useRef(new Set()), + callback = useRef(onComplete); + callback.current = onComplete; + const request = useRef<{ payload: string; id: string } | null>(null); + const submitting = useRef(false); + const refresh = useCallback(async () => { + try { + const response = await adminFetch("/api/admin/studio/import-jobs"); + const data = await response.json(); + if (!response.ok) + throw Error( + data.error || `Import history unavailable (${response.status})`, + ); + setJobs(data.jobs); + setError(""); + let changed = false; + for (const job of data.jobs as ImportJob[]) + if ( + (job.state === "completed" || job.state === "interrupted") && + !completed.current.has(job.id) + ) { + completed.current.add(job.id); + changed = true; + } + if (changed) callback.current(); + } catch (error) { + setError( + error instanceof Error ? error.message : "Cannot load import history", + ); + } + }, []); + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), 5000); + return () => clearInterval(timer); + }, [refresh]); + const submit = async ( + items: ImportJobItem[], + options: { + sourceId?: string; + translate: boolean; + langs?: string[]; + mode?: "repair"; + }, + ) => { + if (submitting.current) return false; + submitting.current = true; + setBusy(true); + const body = { items, ...options }, + payload = JSON.stringify(body); + if (request.current?.payload !== payload) + request.current = { payload, id: crypto.randomUUID() }; + try { + const response = await adminFetch("/api/admin/studio/import-jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, id: request.current.id }), + }); + const data = await response.json(); + if (!response.ok) + throw Error( + data.error || `Could not queue import (${response.status})`, + ); + request.current = null; + await refresh(); + toast.success( + "Import queued. You can leave this page and return to its history.", + ); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not queue import", + ); + return false; + } finally { + submitting.current = false; + setBusy(false); + } + }; + return { jobs, busy, error, submit, refresh }; +} diff --git a/src/components/admin/studio/use-studio-translations.ts b/src/components/admin/studio/use-studio-translations.ts new file mode 100644 index 00000000..ec05afa8 --- /dev/null +++ b/src/components/admin/studio/use-studio-translations.ts @@ -0,0 +1,96 @@ +"use client"; +import { useState } from "react"; +import { toast } from "sonner"; +import { setFurnidataTranslateEnabled } from "@/actions/import-furni"; +import { adminFetch } from "@/lib/admin-fetch"; + +// Language codes that get a FurnitureData_.json file. Mirrors +// FURNIDATA_LANGUAGES in furni-data-i18n (kept client-safe here so we don't pull +// server-only fs modules into the browser bundle). +const TRANSLATE_LANGS = [ + "nl", + "en", + "de", + "fr", + "es", + "tr", + "it", + "pt", + "fi", + "pl", + "ru", + "ar", + "ja", +] as const; + +export function useStudioTranslations(defaultTranslate: boolean) { + // Translation controls (per-import toggle + language selection, plus a global + // default that persists to the site setting and seeds the per-import toggle). + const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; + const [translate, setTranslate] = useState(defaultTranslate); + const [translateLangs, setTranslateLangs] = useState([ + ...ALL_LANGS, + ]); + const [langPickerOpen, setLangPickerOpen] = useState(false); + const [buildingLanguages, setBuildingLanguages] = useState(false); + const [globalTranslate, setGlobalTranslate] = + useState(defaultTranslate); + + // Build every language for the whole catalog at once (used to translate + // everything after importing with translation disabled for speed). + const buildAllLanguages = async () => { + setBuildingLanguages(true); + try { + const res = await adminFetch( + "/api/admin/import/furni?action=build-languages", + ); + const data = (await res.json()) as { + languages?: Array<{ lang: string; ok: boolean }>; + error?: string; + }; + if (res.ok) { + const langs = data.languages ?? []; + const okCount = langs.filter((l) => l.ok).length; + toast.success( + `Languages translated (${okCount}/${langs.length} completed)`, + ); + } else { + toast.error(data.error ?? "Translation failed"); + } + } catch { + toast.error("Network error while translating languages"); + } finally { + setBuildingLanguages(false); + } + }; + + const toggleLang = (lang: string) => + setTranslateLangs((prev) => + prev.includes(lang) ? prev.filter((l) => l !== lang) : [...prev, lang], + ); + + const onGlobalTranslateChange = async (next: boolean) => { + setGlobalTranslate(next); + setTranslate(next); + try { + await setFurnidataTranslateEnabled({ enabled: next }); + } catch { + toast.error("Could not save the default translation setting"); + } + }; + + return { + ALL_LANGS, + translate, + setTranslate, + translateLangs, + setTranslateLangs, + langPickerOpen, + setLangPickerOpen, + buildingLanguages, + globalTranslate, + buildAllLanguages, + toggleLang, + onGlobalTranslateChange, + }; +}