fix(catalog): check source assets and offer reachable import alternatives
CI / check (push) Failing after 19s
CI / deploy (push) Skipped
CI / e2e (push) Skipped

This commit is contained in:
Simo committed 2026-09-05 14:56:04 +02:00
1 parent 8c1efae296
commit 159b1f7d1b
10 files changed
+646 -56

No files matched your search

+120 -5
View File
@@ -16,23 +16,33 @@ import {
useFurnitureInspection,
} from "./furniture-inspector";
import type { FurniItem } from "./studio-types";
import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({
items,
items: originalItems,
sourceId,
source,
translation,
onCancel,
onConfirm,
}: {
items: FurniItem[];
sourceId?: string;
source: string;
translation: string;
onCancel: () => void;
onConfirm: (items: FurniItem[]) => void;
onConfirm: (items: FurniItem[], replaced?: boolean) => void;
}) {
const [items, setItems] = useState(originalItems);
const [replacements, setReplacements] = useState<string[]>([]);
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
const assets = useSourceAssetChecks(items, sourceId, inspection);
const assetsByName = new Map(
assets.items.map((item) => [item.classname, item]),
);
const unavailable = assets.items.filter((item) => item.state === "missing");
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
const [step, setStep] = useState<"review" | "confirm">("review");
const [page, setPage] = useState(0);
@@ -42,7 +52,11 @@ export function ImportReview({
const complete = items.every((item) => localByName.has(item.classname));
const ready = items.filter((item) => {
const local = localByName.get(item.classname);
return local && compareFurniture(item, local).state !== "conflict";
return (
local &&
assetsByName.get(item.classname)?.state !== "missing" &&
compareFurniture(item, local).state !== "conflict"
);
});
const blocked = items.filter((item) => {
const local = localByName.get(item.classname);
@@ -98,6 +112,104 @@ export function ImportReview({
<strong>Selection:</strong> {items.length} furniture items
</p>
</div>
{replacements.length > 0 && (
<p className="text-sm">
Replacements selected: {replacements.join(" · ")}{" "}
<Button
variant="ghost"
onClick={() => {
setItems(originalItems);
setReplacements([]);
setStep("review");
setPage(0);
}}
>
Undo replacements
</Button>
</p>
)}
{assets.loading && !inspection.loading && (
<p role="status">
Checking source files… {assets.items.length} checked
</p>
)}
{assets.error && (
<div role="alert">
<p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}>
Retry source check
</Button>
</div>
)}
{assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to
download their files.
</p>
)}
{unavailable.map((check) => (
<section
key={check.classname}
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
>
<p className="text-sm font-medium">
Source files unavailable: {check.classname}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
This item will be skipped. Choose a reachable alternative below
to replace it in this import.
</p>
{check.alternatives.length === 0 && (
<p className="text-sm">
No verified alternative found. Upload the original .nitro from
the import page if you have it.
</p>
)}
{check.alternatives.map((alternative) => (
<div
key={alternative.classname}
className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-[var(--admin-canvas)] p-2"
>
<div className="text-sm">
<strong>{alternative.name}</strong>
<p className="font-mono text-xs">{alternative.classname}</p>
<p className="text-xs">
{alternative.type} · Revision {alternative.revision} · ID{" "}
{alternative.id}
</p>
</div>
<Button
variant="outline"
onClick={() => {
setItems((current) => [
...new Map(
current.map((item) => {
const next =
item.classname === check.classname
? {
...alternative,
alreadyImported: false,
nitroExists: false,
}
: item;
return [next.classname, next] as const;
}),
).values(),
]);
setReplacements((current) => [
...current,
`${check.classname} → ${alternative.classname}`,
]);
setStep("review");
setPage(0);
}}
>
Use this alternative
</Button>
</div>
))}
</section>
))}
{inspection.loading ? (
<p role="status">Checking local data and assets…</p>
) : inspection.error ? (
@@ -115,7 +227,8 @@ export function ImportReview({
<>
<p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL
(complete missing data) · {blocked} conflicts (skipped)
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} unavailable (skipped)
</p>
{inspection.items.some(
(item) =>
@@ -257,6 +370,8 @@ export function ImportReview({
}}
disabled={
inspection.loading ||
assets.loading ||
!!assets.error ||
!!inspection.error ||
!complete ||
ready.length === 0
@@ -265,7 +380,7 @@ export function ImportReview({
if (step === "review") {
setStep("confirm");
setPage(0);
} else onConfirm(ready);
} else onConfirm(ready, replacements.length > 0);
}}
>
{step === "review"
@@ -2391,6 +2391,7 @@ export function StudioClient({
{review && (
<ImportReview
items={review.items}
sourceId={activeSource || undefined}
source={
sources.find((entry) => entry.id === activeSource)?.name ??
source.label
@@ -2403,10 +2404,10 @@ export function StudioClient({
: "Off"
}
onCancel={() => setReview(null)}
onConfirm={(accepted) => {
onConfirm={(accepted, replaced) => {
const single = review.single;
setReview(null);
if (review.cloneAll)
if (review.cloneAll && !replaced)
void cloneAllMissing(accepted.map((item) => item.classname));
else if (single && accepted[0]) void importSingle(accepted[0]);
else
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { adminFetch } from "@/lib/admin-fetch";
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types";
export function useSourceAssetChecks(
items: FurniItem[],
sourceId: string | undefined,
local: { items: FurnitureInspection[]; loading: boolean; error: string },
) {
const [attempt, setAttempt] = useState(0);
const needed = items.filter(
(item) =>
!local.items.some(
(entry) =>
entry.classname === item.classname && entry.nitro.exists === true,
),
);
const key =
local.loading || local.error
? ""
: JSON.stringify({ items: needed, sourceId, attempt });
const [state, setState] = useState<{
key: string;
loading: boolean;
error: string;
items: SourceAssetCheck[];
}>({ key: "", loading: true, error: "", items: [] });
useEffect(() => {
if (!key) return;
const abort = new AbortController();
setState({ key, loading: true, error: "", items: [] });
void (async () => {
const { items, sourceId } = JSON.parse(key) as {
items: FurniItem[];
sourceId?: string;
};
const results: SourceAssetCheck[] = [];
try {
for (let offset = 0; offset < items.length; offset += 20) {
const res = await adminFetch("/api/admin/studio/source-assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: items.slice(offset, offset + 20),
sourceId,
}),
signal: abort.signal,
});
const data = await res.json();
if (!res.ok || !Array.isArray(data.items))
throw Error(data.error || "Could not check source assets");
results.push(...data.items);
if (!abort.signal.aborted)
setState({ key, items: [...results], loading: true, error: "" });
}
if (results.length !== items.length)
throw Error("Source check is incomplete. Retry before importing.");
if (!abort.signal.aborted)
setState({ key, items: results, loading: false, error: "" });
} catch (error) {
if (!abort.signal.aborted)
setState({
key,
items: [],
loading: false,
error: (error as Error).message,
});
}
})();
return () => abort.abort();
}, [key]);
return {
...state,
loading: !key || state.key !== key || state.loading,
items: state.key === key ? state.items : [],
retry: () => setAttempt((value) => value + 1),
};
}