Add Catalog Studio inspection and guided import review
CI / check (push) Successful in 1m39s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 13:10:43 +02:00
1 parent 2578bf6a09
commit 26f071117b
7 files changed
+961 -44

No files matched your search

@@ -0,0 +1,198 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
import {
compareFurniture,
type FurnitureInspection,
} from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types";
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({
item,
local,
}: {
item: FurniItem;
local: FurnitureInspection;
}) {
const comparison = compareFurniture(item, local);
return (
<div className="space-y-3 text-sm">
<dl className="grid grid-cols-2 gap-2">
{(
[
[".nitro", local.nitro.exists],
["Local icon", local.icon.exists],
[
"Furnidata",
local.furnidataReadable ? local.furnidata.length > 0 : null,
],
["SQL item", local.sql.length > 0],
["Catalog offer", local.catalog.length > 0],
] as const
).map(([label, present]) => (
<div
key={label}
className="rounded-md border border-[var(--admin-border)] p-2"
>
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
<dd
className={
present
? "text-[var(--admin-success)]"
: "text-[var(--admin-warning)]"
}
>
{status(present)}
</dd>
</div>
))}
</dl>
{comparison.state === "conflict" && (
<p role="alert" className="text-[var(--admin-warning)]">
Multiple local records use this classname. Review them in Catalog
audit before importing.
</p>
)}
{!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="p-2 text-left">Field</th>
<th className="p-2 text-left">Local now</th>
<th className="p-2 text-left">Source</th>
</tr>
</thead>
<tbody>
{comparison.fields.map((field) => (
<tr
key={field.label}
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""}
>
<th className="p-2 text-left">
{field.label}
{field.changed ? " · Changed" : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? "Not supplied"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
Source values are compared with local data. Import may remap IDs and
translate names; existing SQL items are skipped by this import flow.
</p>
<details>
<summary className="cursor-pointer font-medium">
SQL records and catalog offers
</summary>
<div className="mt-2 space-y-2 text-xs">
{local.sql.length ? (
local.sql.map((row) => (
<p key={row.id}>
Item #{row.id} · Sprite #{row.spriteId} · {row.name}
</p>
))
) : (
<p>No local SQL item.</p>
)}
{local.catalog.map((row) => (
<p key={row.id}>
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "}
{row.points} points
</p>
))}
</div>
</details>
</div>
);
}
export function FurnitureInspector({ item }: { item: FurniItem }) {
const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading)
return (
<p role="status" className="py-3 text-sm">
Checking local data and assets…
</p>
);
if (inspection.error)
return (
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
Retry inspection
</Button>
</div>
);
const local = inspection.items.find(
(entry) => entry.classname === item.classname,
);
return local ? (
<FurnitureComparison item={item} local={local} />
) : (
<p>Local inspection unavailable.</p>
);
}