feat(catalog): queue furniture imports with history and matching file attachments
CI / check (push) Successful in 1m30s
CI / deploy (push) Successful in 1m26s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 17:02:26 +02:00
1 parent 775d14f861
commit 2619bec165
16 files changed
+1016 -232

No files matched your search

@@ -0,0 +1,161 @@
"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<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[] },
) => {
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({
state,
}: {
state: ReturnType<typeof useFurnitureJobs>;
}) {
return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer">
Import history ·{" "}
{
state.jobs.filter(
(j) => j.state === "queued" || j.state === "running",
).length
}{" "}
active
</summary>
<div className="max-h-72 space-y-2 overflow-auto py-2">
{state.error && <p role="alert">{state.error}</p>}
{!state.jobs.length && <p>No background imports yet.</p>}
{state.jobs.map((job) => {
const done = job.items.filter((i) => i.state === "done"),
failed = job.items.filter((i) => i.state === "failed");
return (
<details
key={job.id}
className="rounded border border-[var(--admin-border)] p-2"
>
<summary className="cursor-pointer">
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "}
{done.length}/{job.items.length} imported · {failed.length}{" "}
failed
</summary>
<ul className="space-y-1 py-2">
{job.items.map((item) => (
<li key={item.classname}>
<strong>{item.classname}</strong> · {item.state}
{item.itemId ? ` · ID ${item.itemId}` : ""}
{item.error && (
<p className="text-[var(--admin-warning)]">
{item.error}
</p>
)}
{[...new Set(item.warnings ?? [])].map((warning) => (
<p
key={warning}
className="text-xs text-[var(--admin-text-muted)]"
>
{warning}
</p>
))}
</li>
))}
</ul>
{failed.length > 0 && job.state !== "running" && (
<Button
variant="outline"
disabled={state.busy}
onClick={() =>
void state.submit(failed, {
sourceId: job.sourceId,
translate: job.translate,
langs: job.langs,
})
}
>
Retry failed items only
</Button>
)}
</details>
);
})}
</div>
</details>
);
}
+64 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -9,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import {
@@ -24,6 +26,7 @@ export function ImportReview({
source,
translation,
onCancel,
busy = false,
onConfirm,
}: {
items: FurniItem[];
@@ -31,13 +34,41 @@ export function ImportReview({
source: string;
translation: string;
onCancel: () => void;
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null);
async function attach(item: FurniItem, file: File) {
setUploading(item.classname);
try {
const form = new FormData();
form.set("classname", item.classname);
form.set("file", file);
const response = await adminFetch("/api/admin/studio/import-attachment", {
method: "POST",
body: form,
});
const data = await response.json();
if (!response.ok) throw Error(data.error || "Upload failed");
setAttachments((current) => ({
...current,
[item.classname]: data.attachmentId,
}));
toast.success("Matching .nitro attached to this import");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
setUploading(null);
}
}
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
const assets = useSourceAssetChecks(items, sourceId, inspection);
const unavailable = assets.items.filter((item) => item.state === "missing");
const unavailable = assets.items.filter(
(item) => item.state === "missing" && !attachments[item.classname],
);
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
const [step, setStep] = useState<"review" | "confirm">("review");
const [page, setPage] = useState(0);
@@ -73,7 +104,7 @@ export function ImportReview({
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel();
if (!open && !busy && !uploading) onCancel();
}}
>
<DialogContent className="flex max-h-[90dvh] w-[calc(100vw-2rem)] max-w-3xl sm:max-w-3xl flex-col overflow-hidden border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text)]">
@@ -208,6 +239,27 @@ export function ImportReview({
</button>
{expanded === item.classname && (
<div className="border-t border-[var(--admin-border)] p-3">
{local.nitro.exists !== true && (
<label className="mt-3 block text-sm">
{attachments[item.classname]
? "Matching .nitro attached"
: "Attach the original .nitro"}
<input
className="mt-2 block max-w-full"
type="file"
accept=".nitro"
disabled={busy || uploading !== null}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void attach(item, file);
event.target.value = "";
}}
/>
{uploading === item.classname && (
<span role="status">Validating file…</span>
)}
</label>
)}
<FurnitureComparison item={item} local={local} />
</div>
)}
@@ -278,6 +330,7 @@ export function ImportReview({
<DialogFooter>
<Button
variant="outline"
disabled={busy || uploading !== null}
onClick={
step === "confirm"
? () => {
@@ -295,6 +348,8 @@ export function ImportReview({
color: "var(--admin-accent-foreground)",
}}
disabled={
busy ||
uploading !== null ||
inspection.loading ||
!!inspection.error ||
!complete ||
@@ -304,7 +359,13 @@ export function ImportReview({
if (step === "review") {
setStep("confirm");
setPage(0);
} else onConfirm(ready);
} else
onConfirm(
ready.map((item) => ({
...item,
attachmentId: attachments[item.classname],
})),
);
}}
>
{step === "review"
+19 -223
View File
@@ -60,7 +60,6 @@ import {
type AutoCatalogPreview,
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import { readImportResponse } from "@/lib/furni/import-response";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
@@ -69,6 +68,7 @@ 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 { ImportReview } from "./import-review";
import {
getFurniImageUrl,
@@ -116,6 +116,10 @@ export function StudioClient({
single: boolean;
cloneAll?: boolean;
} | null>(null);
const jobs = useFurnitureJobs(() => {
void fetchItems(activeSearch, 1, activeSource);
void fetchStats();
});
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
@@ -157,7 +161,7 @@ export function StudioClient({
const [detail, setDetail] = useState<FurniItem | null>(null);
// Import state
const [importingId, setImportingId] = useState<string | null>(null);
const importingId = jobs.busy ? (review?.items[0]?.classname ?? null) : null;
const [batchProgress, setBatchProgress] = useState<Map<
string,
BatchItemStatus
@@ -452,146 +456,6 @@ export function StudioClient({
setPreparingReview(false);
}
}
async function cloneAllMissing(classnames: string[]) {
if (!activeSource || !classnames.length) return;
// Initialize progress tracking as a Map for BatchProgress compatibility
const initial = new Map<string, BatchItemStatus>();
for (const cn of classnames) {
initial.set(cn, { classname: cn, status: "pending" });
}
setBatchProgress(initial);
batchDoneRef.current = false;
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setFailedClassnames([]);
setCloneAllCancelling(false);
batchStartTimeRef.current = Date.now();
const abort = new AbortController();
cloneAllAbortRef.current = abort;
const CHUNK = 400;
let okCount = 0;
let failCount = 0;
try {
for (let i = 0; i < classnames.length; i += CHUNK) {
if (abort.signal.aborted) break;
const chunk = classnames.slice(i, i + CHUNK);
const isLast = i + CHUNK >= classnames.length;
const res = await adminFetch("/api/admin/import/clone/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource,
classnames: chunk,
concurrency: 10,
final: isLast,
}),
signal: abort.signal,
});
if (!res.ok) {
const errMsg =
res.status === 502
? "Furnidata ophalen mislukt – controleer de hotel-URL en netwerk"
: res.status === 400
? "Ongeldige input voor batch-import"
: `Batch chunk mislukt (${res.status})`;
toast.error(errMsg);
// Toon foutmelding uit SSE-stream als die al onderweg is
if (res.body && "getReader" in res.body) {
void res.body.getReader();
toast.error(
"Foutmelding stream niet beschikbaar – de import loopt voort, fouten worden in de stream getoond",
);
} else {
toast.error(
"SSE-stream niet beschikbaar – foutmelding overgeslagen",
);
}
return;
}
await readSseStream(
res.body!,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(cn, {
classname: cn,
status: evt.status as BatchItemStatus["status"],
message: evt.message as string | undefined,
});
return next;
});
if (evt.status === "done" || evt.status === "failed") {
if (evt.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(cn) ? prev : [...prev, cn],
);
}
}
}
if (evt.type === "batch_complete") {
okCount += Number(evt.succeeded ?? 0);
failCount += Number(evt.failed ?? 0);
}
if (evt.type === "error") {
toast.error(
typeof evt.message === "string" ? evt.message : "Import fout",
);
}
},
abort.signal,
);
}
if (!abort.signal.aborted) {
setBatchSucceeded(okCount);
setBatchFailed(failCount);
markBatchDone();
if (okCount > 0) {
toast.success(
`${okCount} items geïmporteerd${
failCount > 0 ? ` (${failCount} mislukt)` : ""
}`,
);
}
if (failCount > 0 && okCount === 0) {
toast.error(`${failCount} items mislukt`);
}
fetchItems(activeSearch, 1, activeSource);
fetchStats();
fetchCloneStats(activeSource);
// Auto-translate after clone all if translation is enabled
if (translate && okCount > 0) {
toast.info("Translating all languages...");
buildAllLanguages();
}
} else {
toast.info("Clone all geannuleerd");
}
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Clone all geannuleerd");
} else {
toast.error("Verbindingsfout tijdens clone all");
}
} finally {
cloneAllAbortRef.current = null;
setCloneAllCancelling(false);
setSelected(new Set());
}
}
useEffect(() => {
fetchItems("", 1, activeSource);
@@ -686,76 +550,6 @@ export function StudioClient({
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll, review]);
async function importSingle(item: FurniItem) {
setImportingId(item.classname);
try {
const res = await adminFetch("/api/admin/import/furni", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
repairExisting: true,
id: item.id,
classname: item.classname,
name: item.name,
description: item.description,
type: item.type,
revision: item.revision,
category: item.category,
sourceId: activeSource || undefined,
translate,
langs:
translate && translateLangs.length > 0 ? translateLangs : undefined,
}),
});
const { status, data } = await readImportResponse(res);
if (status >= 200 && status < 300) {
setItems((prev) =>
prev.map((i) =>
i.classname === item.classname
? { ...i, alreadyImported: true, nitroExists: true }
: i,
),
);
if (detail?.classname === item.classname) {
setDetail({ ...item, alreadyImported: true, nitroExists: true });
}
fetchStats();
const parts = [`${item.classname} imported into the catalog`];
if (data.itemId && data.itemId !== item.id)
parts.push(`Local ID: ${data.itemId} (source: ${item.id})`);
if (typeof data.furniDataFixedIds === "number") {
parts.push(
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
);
}
if (
typeof data.furniDataMissing === "number" &&
data.furniDataMissing > 0
) {
parts.push(`${data.furniDataMissing} furnidata missing`);
}
toast.success(parts.join(" · "));
} else {
toast.error(
typeof data.error === "string"
? data.error
: `Import failed for ${item.classname}`,
);
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Import connection failed. Check the furniture status before retrying.",
);
} finally {
setImportingId(null);
}
}
async function importBatch(
requested?: string[],
reviewedItems?: FurniItem[],
@@ -1223,6 +1017,7 @@ export function StudioClient({
return (
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<FurnitureJobHistory state={jobs} />
{/* ── Header ─────────────────────────────────────────── */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-2.5">
@@ -2419,17 +2214,18 @@ export function StudioClient({
: "Off"
}
onCancel={() => setReview(null)}
onConfirm={(accepted) => {
const single = review.single;
setReview(null);
if (review.cloneAll)
void cloneAllMissing(accepted.map((item) => item.classname));
else if (single && accepted[0]) void importSingle(accepted[0]);
else
void importBatch(
accepted.map((item) => item.classname),
accepted,
);
busy={jobs.busy}
onConfirm={async (accepted) => {
if (
await jobs.submit(accepted, {
sourceId: activeSource || undefined,
translate,
langs: translateLangs.length ? translateLangs : undefined,
})
) {
setReview(null);
setSelected(new Set());
}
}}
/>
)}
@@ -1,4 +1,5 @@
export interface FurniItem {
attachmentId?: string;
id: number;
classname: string;
type: string;