refactor(catalog): separate Studio data hooks and presentation
This commit is contained in:
1 parent
570f3dde44
commit
3ae4f21217
9 files changed
+348
-263
No files matched your search
@@ -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,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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user