feat(catalog): queue furniture imports with history and matching file attachments
This commit is contained in:
1 parent
775d14f861
commit
2619bec165
16 files changed
+1016
-232
No files matched your search
@@ -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());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user