Add Catalog Studio inspection and guided import review
This commit is contained in:
1 parent
2578bf6a09
commit
26f071117b
7 files changed
+961
-44
No files matched your search
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||
import { compareFurniture } from "@/lib/furni/studio-inspection";
|
||||
import {
|
||||
FurnitureComparison,
|
||||
useFurnitureInspection,
|
||||
} from "./furniture-inspector";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
|
||||
export function ImportReview({
|
||||
items,
|
||||
source,
|
||||
translation,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
items: FurniItem[];
|
||||
source: string;
|
||||
translation: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: (items: FurniItem[]) => void;
|
||||
}) {
|
||||
const inspection = useFurnitureInspection(
|
||||
items.map((item) => item.classname),
|
||||
);
|
||||
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
|
||||
const [step, setStep] = useState<"review" | "confirm">("review");
|
||||
const [page, setPage] = useState(0);
|
||||
const localByName = new Map(
|
||||
inspection.items.map((item) => [item.classname, item]),
|
||||
);
|
||||
const complete = items.every((item) => localByName.has(item.classname));
|
||||
const ready = items.filter((item) => {
|
||||
const local = localByName.get(item.classname);
|
||||
return (
|
||||
local &&
|
||||
local.sql.length === 0 &&
|
||||
compareFurniture(item, local).state !== "conflict"
|
||||
);
|
||||
});
|
||||
const blocked = items.filter((item) => {
|
||||
const local = localByName.get(item.classname);
|
||||
return local && compareFurniture(item, local).state === "conflict";
|
||||
}).length;
|
||||
const existing = items.filter((item) => {
|
||||
const local = localByName.get(item.classname);
|
||||
return (
|
||||
local &&
|
||||
local.sql.length > 0 &&
|
||||
compareFurniture(item, local).state !== "conflict"
|
||||
);
|
||||
}).length;
|
||||
const pageItems = (step === "review" ? items : ready).slice(
|
||||
page * 50,
|
||||
(page + 1) * 50,
|
||||
);
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil((step === "review" ? items : ready).length / 50),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) 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)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === "review"
|
||||
? "Review furniture import"
|
||||
: "Confirm furniture import"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
1. Select → 2. Review → 3. Import
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
|
||||
<p>
|
||||
<strong>Source:</strong> {source}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Destination:</strong> local catalog, furnidata, .nitro and
|
||||
icons
|
||||
</p>
|
||||
<p>
|
||||
<strong>Translation:</strong> {translation}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Selection:</strong> {items.length} furniture items
|
||||
</p>
|
||||
</div>
|
||||
{inspection.loading ? (
|
||||
<p role="status">Checking local data and assets…</p>
|
||||
) : inspection.error ? (
|
||||
<div role="alert">
|
||||
<p>{inspection.error}</p>
|
||||
<Button variant="outline" onClick={inspection.retry}>
|
||||
Retry inspection
|
||||
</Button>
|
||||
</div>
|
||||
) : !complete ? (
|
||||
<p role="alert">
|
||||
Inspection is incomplete. Close and retry before importing.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p role="status" className="text-sm">
|
||||
{ready.length} ready to import · {existing} already in SQL
|
||||
(skipped) · {blocked} conflicts (skipped)
|
||||
</p>
|
||||
{inspection.items.some(
|
||||
(item) =>
|
||||
!item.furnidataReadable ||
|
||||
item.icon.exists === null ||
|
||||
item.nitro.exists === null,
|
||||
) && (
|
||||
<p className="text-sm text-[var(--admin-warning)]">
|
||||
Some local files could not be verified. Review the item
|
||||
details before continuing.
|
||||
</p>
|
||||
)}
|
||||
{step === "review" ? (
|
||||
<div className="space-y-2">
|
||||
{pageItems.map((item) => {
|
||||
const local = localByName.get(item.classname);
|
||||
if (!local) return null;
|
||||
const comparison = compareFurniture(item, local);
|
||||
return (
|
||||
<div
|
||||
key={item.classname}
|
||||
className="rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded === item.classname}
|
||||
onClick={() =>
|
||||
setExpanded(
|
||||
expanded === item.classname ? "" : item.classname,
|
||||
)
|
||||
}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 p-3 text-left text-sm"
|
||||
>
|
||||
<span className="min-w-0 break-words">
|
||||
{item.name}
|
||||
<span className="block text-xs text-[var(--admin-text-muted)]">
|
||||
{item.classname}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{comparison.state === "new"
|
||||
? "New"
|
||||
: comparison.state === "changed"
|
||||
? "Differences"
|
||||
: comparison.state === "conflict"
|
||||
? "Conflict"
|
||||
: comparison.state === "unknown"
|
||||
? "Not fully verified"
|
||||
: "Present"}
|
||||
</span>
|
||||
</button>
|
||||
{expanded === item.classname && (
|
||||
<div className="border-t border-[var(--admin-border)] p-3">
|
||||
<FurnitureComparison item={item} local={local} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Only the {ready.length} eligible items below will be
|
||||
submitted. Existing SQL items and conflicts are excluded.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Automatic categories and prices below are estimates. Source
|
||||
rules may override them; final results appear in the import
|
||||
summary.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{pageItems.map((item) => {
|
||||
const placement = previewAutoCatalog(
|
||||
item.classname,
|
||||
item.type,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={item.classname}
|
||||
className="rounded-md border border-[var(--admin-border)] p-2 text-sm"
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<p>
|
||||
{placement.categoryLabel} · {placement.credits}{" "}
|
||||
credits + {placement.points} points
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!inspection.loading && !inspection.error && pageCount > 1 && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
Previous items
|
||||
</Button>
|
||||
<span>
|
||||
Page {page + 1} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page + 1 === pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
Next items
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={
|
||||
step === "confirm"
|
||||
? () => {
|
||||
setStep("review");
|
||||
setPage(0);
|
||||
}
|
||||
: onCancel
|
||||
}
|
||||
>
|
||||
{step === "confirm" ? "Back to review" : "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: "var(--admin-accent)",
|
||||
color: "var(--admin-accent-foreground)",
|
||||
}}
|
||||
disabled={
|
||||
inspection.loading ||
|
||||
!!inspection.error ||
|
||||
!complete ||
|
||||
ready.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
if (step === "review") {
|
||||
setStep("confirm");
|
||||
setPage(0);
|
||||
} else onConfirm(ready);
|
||||
}}
|
||||
>
|
||||
{step === "review"
|
||||
? "Continue to confirmation"
|
||||
: `Start import (${ready.length})`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user