refactor(catalog): separate Studio data hooks and presentation
CI / check (push) Successful in 1m40s
CI / deploy (push) Successful in 1m26s
CI / e2e (push) Successful in 24s

This commit is contained in:
Simo committed 2026-09-05 18:15:29 +02:00
1 parent 570f3dde44
commit 3ae4f21217
9 files changed
+348 -263

No files matched your search

+23
View File
@@ -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.
@@ -1,64 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
import { import {
compareFurniture, compareFurniture,
type FurnitureInspection, type FurnitureInspection,
furnitureHealth, furnitureHealth,
} from "@/lib/furni/studio-inspection"; } from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types"; 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) => const status = (value: boolean | null | undefined) =>
value == null ? "Not verified" : value ? "Present" : "Missing"; value == null ? "Not verified" : value ? "Present" : "Missing";
export function FurnitureComparison({ export function FurnitureComparison({
+1 -89
View File
@@ -1,94 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import type { useFurnitureJobs } from "./use-furniture-jobs";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
export function useFurnitureJobs(onComplete: () => void) {
const [jobs, setJobs] = useState<ImportJob[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const completed = useRef(new Set<string>()),
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 };
}
export function FurnitureJobHistory({ export function FurnitureJobHistory({
state, state,
}: { }: {
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,11 +14,9 @@ import {
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection"; import { compareFurniture } from "@/lib/furni/studio-inspection";
import { import { FurnitureComparison } from "./furniture-inspector";
FurnitureComparison,
useFurnitureInspection,
} from "./furniture-inspector";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
import { useSourceAssetChecks } from "./use-source-asset-checks"; import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({ export function ImportReview({
+29 -118
View File
@@ -25,10 +25,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { deleteImportedFurni } from "@/actions/import-furni";
deleteImportedFurni,
setFurnidataTranslateEnabled,
} from "@/actions/import-furni";
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog"; import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -68,8 +65,9 @@ import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail"; import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot"; import { CheckboxDot } from "./checkbox-dot";
import { FurnitureInspector } from "./furniture-inspector"; import { FurnitureInspector } from "./furniture-inspector";
import { FurnitureJobHistory, useFurnitureJobs } from "./furniture-jobs"; import { FurnitureJobHistory } from "./furniture-jobs";
import { ImportReview } from "./import-review"; import { ImportReview } from "./import-review";
import { filterFurniture } from "./studio-filters";
import { import {
getFurniImageUrl, getFurniImageUrl,
handleImgError, handleImgError,
@@ -82,25 +80,8 @@ import type {
Meta, Meta,
VerificationSummary, VerificationSummary,
} from "./studio-types"; } from "./studio-types";
import { useFurnitureJobs } from "./use-furniture-jobs";
// Language codes that get a FurnitureData_<lang>.json file. Mirrors import { useStudioTranslations } from "./use-studio-translations";
// 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 StudioClient({ export function StudioClient({
source, source,
@@ -206,106 +187,36 @@ export function StudioClient({
new Set(), new Set(),
); );
// Translation controls (per-import toggle + language selection, plus a global const {
// default that persists to the site setting and seeds the per-import toggle). ALL_LANGS,
const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; translate,
const [translate, setTranslate] = useState<boolean>(defaultTranslate); setTranslate,
const [translateLangs, setTranslateLangs] = useState<string[]>([ translateLangs,
...ALL_LANGS, setTranslateLangs,
]); langPickerOpen,
const [langPickerOpen, setLangPickerOpen] = useState(false); setLangPickerOpen,
const [buildingLanguages, setBuildingLanguages] = useState(false); buildingLanguages,
const [globalTranslate, setGlobalTranslate] = globalTranslate,
useState<boolean>(defaultTranslate); buildAllLanguages,
toggleLang,
// Build every language for the whole catalog at once (used to translate onGlobalTranslateChange,
// everything after importing with translation disabled for speed). } = useStudioTranslations(defaultTranslate);
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 categories = useMemo(() => { const categories = useMemo(() => {
const cats = new Set(items.map((i) => i.category).filter(Boolean)); const cats = new Set(items.map((i) => i.category).filter(Boolean));
return ["all", ...Array.from(cats).sort()]; return ["all", ...Array.from(cats).sort()];
}, [items]); }, [items]);
const filteredItems = useMemo(() => { const filteredItems = useMemo(
let result = items; () =>
if (typeFilter !== "all") { filterFurniture(items, {
result = result.filter((i) => i.type === typeFilter); typeFilter,
} categoryFilter,
if (categoryFilter !== "all") { statusFilter,
result = result.filter((i) => i.category === categoryFilter); sortBy,
} }),
if (statusFilter === "imported") [items, typeFilter, categoryFilter, statusFilter, sortBy],
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 allSelected = useMemo( const allSelected = useMemo(
() => () =>
@@ -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;
}
@@ -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),
};
}
@@ -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<ImportJob[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const completed = useRef(new Set<string>()),
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 };
}
@@ -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_<lang>.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<boolean>(defaultTranslate);
const [translateLangs, setTranslateLangs] = useState<string[]>([
...ALL_LANGS,
]);
const [langPickerOpen, setLangPickerOpen] = useState(false);
const [buildingLanguages, setBuildingLanguages] = useState(false);
const [globalTranslate, setGlobalTranslate] =
useState<boolean>(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,
};
}