feat(studio): preserve work position and guide import completion
CI / check (push) Successful in 2m20s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m22s

This commit is contained in:
Simo committed 2026-09-11 10:48:09 +02:00
1 parent c5a2b44807
commit 89af4eb1f6
6 files changed
+425 -10

No files matched your search

+4 -1
View File
@@ -10,7 +10,8 @@
- `furniture-jobs.tsx`: job history and retry controls.
- `use-furniture-inspection.ts`: cancellable inspection requests and explicit rechecks.
- `furniture-inspector.tsx`: local component status, source comparison and repair controls.
- `import-review.tsx`: import review and attachment confirmation.
- `import-review.tsx`: import review, actionable review groups and attachment confirmation.
- `import-recommendation.ts`: completion, conflict and verification recommendations from inspected component evidence.
- `studio-types.ts`: shared client data types.
## Server responsibilities
@@ -21,3 +22,5 @@
- `src/lib/services/furni-job-worker.ts`: durable queued work and per-item results.
Keep client modules free of filesystem/database imports. Background refreshes must not reset open editors or unsaved input. Completion status must come from verification, not optimistic UI flags.
Detail navigation keeps the mounted workspace state and restores the list scroll position and originating control on close. Escape closes an open detail before clearing selection. Furniture responses must belong to the latest list request; source changes invalidate pending detail, review and delete state. Selection is never persisted outside the mounted workspace.
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import { importRecommendation } from "./import-recommendation";
const local: FurnitureInspection = {
classname: "chair",
sql: [{ id: 1, spriteId: 1, name: "Chair", type: "s" }],
catalog: [],
furnidata: [],
furnidataReadable: true,
icon: { exists: false, bytes: 0 },
nitro: { exists: true, bytes: 42 },
};
describe("import recommendations", () => {
it("proposes completion with the precise missing components for existing SQL", () => {
expect(importRecommendation(local)).toEqual({
action: "complete",
missing: ["catalog", "furnidata", "icon"],
unknown: [],
});
});
it("keeps unavailable checks distinct from missing parts", () => {
expect(
importRecommendation({
...local,
furnidataReadable: false,
icon: { exists: null, bytes: 0 },
}),
).toEqual({
action: "verify",
missing: ["catalog"],
unknown: ["furnidata", "icon"],
});
});
it("prioritizes conflicts over completing missing parts", () => {
expect(
importRecommendation({ ...local, sql: [...local.sql, ...local.sql] })
.action,
).toBe("conflict");
});
it("proposes import when no local components exist", () => {
expect(
importRecommendation({
...local,
sql: [],
nitro: { exists: false, bytes: 0 },
}).action,
).toBe("import");
});
it("recognizes partial furniture even without a SQL row", () => {
expect(importRecommendation({ ...local, sql: [] }).action).toBe("complete");
});
});
@@ -0,0 +1,21 @@
import { furnitureCompleteness } from "@/lib/furni/studio-completeness";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
export function importRecommendation(local: FurnitureInspection) {
const parts = furnitureCompleteness(local);
const missing = parts
.filter((part) => part.status === "missing")
.map((part) => part.id);
const unknown = parts
.filter((part) => part.status === "unknown")
.map((part) => part.id);
const action = parts.some((part) => part.status === "conflict")
? "conflict"
: unknown.length
? "verify"
: missing.length === parts.length
? "import"
: missing.length
? "complete"
: "refresh";
return { action, missing, unknown } as const;
}
+84 -2
View File
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import { FurnitureComparison } from "./furniture-inspector";
import { importRecommendation } from "./import-recommendation";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
@@ -39,6 +40,10 @@ export function ImportReview({
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const preflightT = useTranslations("admin.studio.preflight");
const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify"
>("all");
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({});
@@ -97,13 +102,26 @@ export function ImportReview({
compareFurniture(item, local).state !== "conflict"
);
}).length;
const pageItems = (step === "review" ? items : ready).slice(
const recommendations = new Map(
inspection.items.map((local) => [
local.classname,
importRecommendation(local),
]),
);
const reviewItems =
reviewFilter === "all"
? items
: items.filter(
(item) =>
recommendations.get(item.classname)?.action === reviewFilter,
);
const pageItems = (step === "review" ? reviewItems : ready).slice(
page * 50,
(page + 1) * 50,
);
const pageCount = Math.max(
1,
Math.ceil((step === "review" ? items : ready).length / 50),
Math.ceil((step === "review" ? reviewItems : ready).length / 50),
);
return (
<Dialog
@@ -220,10 +238,49 @@ export function ImportReview({
)}
{step === "review" ? (
<div className="space-y-2">
<fieldset
className="flex flex-wrap gap-2"
aria-label={preflightT("filterLabel")}
>
{(["all", "complete", "conflict", "verify"] as const).map(
(filter) => (
<Button
key={filter}
variant="outline"
aria-pressed={reviewFilter === filter}
onClick={() => {
setReviewFilter(filter);
setPage(0);
setExpanded("");
}}
>
{preflightT(`filters.${filter}`, {
count:
filter === "all"
? items.length
: items.filter(
(item) =>
recommendations.get(item.classname)
?.action === filter,
).length,
})}
</Button>
),
)}
</fieldset>
{reviewItems.length === 0 && (
<p
role="status"
className="p-3 text-sm text-[var(--admin-text-muted)]"
>
{preflightT("empty")}
</p>
)}
{pageItems.map((item) => {
const local = localByName.get(item.classname);
if (!local) return null;
const comparison = compareFurniture(item, local);
const recommendation = importRecommendation(local);
return (
<div
key={item.classname}
@@ -244,6 +301,31 @@ export function ImportReview({
<span className="block text-xs text-[var(--admin-text-muted)]">
{item.classname}
</span>
<span className="mt-1 block text-xs font-medium">
{preflightT(`actions.${recommendation.action}`)}
</span>
{recommendation.missing.length > 0 && (
<span className="mt-1 block text-xs text-[var(--admin-warning)]">
{preflightT("missing", {
parts: recommendation.missing
.map((part) =>
completenessT(`parts.${part}`),
)
.join(", "),
})}
</span>
)}
{recommendation.unknown.length > 0 && (
<span className="mt-1 block text-xs text-[var(--admin-text-muted)]">
{preflightT("unknown", {
parts: recommendation.unknown
.map((part) =>
completenessT(`parts.${part}`),
)
.join(", "),
})}
</span>
)}
</span>
<span>
{comparison.state === "new"
+78 -7
View File
@@ -27,6 +27,7 @@ import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -157,6 +158,32 @@ export function StudioClient({
// Selection + detail
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<FurniItem | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const detailOrigin = useRef<{
top: number;
trigger: HTMLElement | null;
} | null>(null);
const openDetail = (item: FurniItem) => {
detailOrigin.current = {
top: listRef.current?.scrollTop ?? 0,
trigger:
document.activeElement instanceof HTMLElement
? document.activeElement
: null,
};
setDetail(item);
};
useLayoutEffect(() => {
if (detail || !detailOrigin.current) return;
const origin = detailOrigin.current;
detailOrigin.current = null;
if (listRef.current) listRef.current.scrollTop = origin.top;
if (origin.trigger?.isConnected)
origin.trigger.focus({ preventScroll: true });
}, [detail]);
const sourceGeneration = useRef(0);
const listRequest = useRef<AbortController | null>(null);
useEffect(() => () => listRequest.current?.abort(), []);
// Import state
const importingId = jobs.busy ? (review?.items[0]?.classname ?? null) : null;
@@ -256,6 +283,9 @@ export function StudioClient({
const fetchItems = useCallback(
async (search: string, page: number, source?: string) => {
listRequest.current?.abort();
const controller = new AbortController();
listRequest.current = controller;
setLoading(true);
setError("");
try {
@@ -264,29 +294,56 @@ export function StudioClient({
if (source) qs.set("source", source);
const res = await adminFetch(
`/api/admin/import/furni?${qs.toString()}`,
{ signal: controller.signal },
);
if (controller.signal.aborted) return;
if (!res.ok) {
setError(`Request failed (${res.status})`);
setLoading(false);
return;
}
const data = await res.json();
if (controller.signal.aborted) return;
if (page === 1) {
setItems(data.items ?? []);
const available = new Set(
(data.items ?? []).map((item: FurniItem) => item.classname),
);
setSelected(
(previous) =>
new Set(
[...previous].filter((classname) => available.has(classname)),
),
);
setDetail((previous) =>
previous
? ((data.items ?? []).find(
(item: FurniItem) => item.classname === previous.classname,
) ?? null)
: null,
);
} else {
setItems((prev) => [...prev, ...(data.items ?? [])]);
}
setMeta(data.meta ?? null);
} catch {
setError("Network error");
if (!controller.signal.aborted) setError("Network error");
} finally {
setLoading(false);
if (!controller.signal.aborted) setLoading(false);
}
},
[],
);
const changeSource = (sourceId: string) => {
sourceGeneration.current += 1;
setConfirmDelete(false);
setDeleting(null);
setEditingClassname(null);
listRequest.current?.abort();
detailOrigin.current = null;
setDetail(null);
setReview(null);
setActiveSource(sourceId);
setActiveSourceName(
sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "",
@@ -332,6 +389,7 @@ export function StudioClient({
async function prepareCloneReview() {
if (!activeSource || preparingReview) return;
const generation = sourceGeneration.current;
setPreparingReview(true);
try {
const response = await adminFetch(
@@ -340,6 +398,7 @@ export function StudioClient({
"&action=clonable",
);
const result = await response.json();
if (generation !== sourceGeneration.current) return;
if (!response.ok || !Array.isArray(result.classnames))
throw new Error(result.error || "Could not load missing furniture");
const missing = new Set<string>(result.classnames);
@@ -358,6 +417,7 @@ export function StudioClient({
page,
);
const data = await res.json();
if (generation !== sourceGeneration.current) return;
if (
!res.ok ||
!Array.isArray(data.items) ||
@@ -369,6 +429,7 @@ export function StudioClient({
lastPage = data.meta.lastPage;
page++;
} while (page <= lastPage && collected.size < missing.size);
if (generation !== sourceGeneration.current) return;
if (collected.size !== missing.size)
throw new Error(
"The source changed while preparing the selection. Refresh and retry.",
@@ -458,7 +519,8 @@ export function StudioClient({
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (review) return;
if (review || editingClassname || confirmDelete || e.defaultPrevented)
return;
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
@@ -470,13 +532,18 @@ export function StudioClient({
toggleAll();
}
if (e.key === "Escape" && !isInput) {
if (detail) {
e.preventDefault();
setDetail(null);
return;
}
setSelected(new Set());
searchRef.current?.blur();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll, review]);
}, [toggleAll, review, editingClassname, confirmDelete, detail]);
async function importBatch(
requested?: string[],
@@ -1511,7 +1578,11 @@ export function StudioClient({
) : null}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div
ref={listRef}
data-testid="studio-furniture-list"
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{loading && items.length === 0 ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
@@ -1567,7 +1638,7 @@ export function StudioClient({
<div key={item.classname} className="group relative">
<button
type="button"
onClick={() => setDetail(item)}
onClick={() => openDetail(item)}
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
isSel
@@ -1745,7 +1816,7 @@ export function StudioClient({
<td className="px-1 py-1.5">
<button
type="button"
onClick={() => setDetail(item)}
onClick={() => openDetail(item)}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
aria-label={`View ${item.name}`}
>