fix(catalog): check source assets and offer reachable import alternatives
This commit is contained in:
1 parent
8c1efae296
commit
159b1f7d1b
10 files changed
+646
-56
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user