feat(studio): preserve work position and guide import completion
This commit is contained in:
1 parent
c5a2b44807
commit
89af4eb1f6
6 files changed
+425
-10
No files matched your search
@@ -0,0 +1,185 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
classname: `chair_${index}`,
|
||||||
|
name: `Chair ${index}`,
|
||||||
|
description: "Fixture furniture",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 1,
|
||||||
|
category: "other",
|
||||||
|
alreadyImported: false,
|
||||||
|
nitroExists: false,
|
||||||
|
iconUrl: "/fixture/cover.svg",
|
||||||
|
}));
|
||||||
|
async function mockStudio(page: Page) {
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname.endsWith("/inspect"))
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: (route.request().postDataJSON().classnames as string[]).map(
|
||||||
|
(classname) => ({
|
||||||
|
classname,
|
||||||
|
sql: [{ id: 1, spriteId: 1, name: "Chair", type: "s" }],
|
||||||
|
catalog: [],
|
||||||
|
furnidata: [],
|
||||||
|
furnidataReadable: true,
|
||||||
|
nitro: { exists: true, bytes: 42 },
|
||||||
|
icon: { exists: false, bytes: 0 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/source-assets"))
|
||||||
|
return route.fulfill({ json: { items: [] } });
|
||||||
|
if (url.pathname.endsWith("/furni"))
|
||||||
|
return route.fulfill({
|
||||||
|
json: url.searchParams.has("action")
|
||||||
|
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
|
||||||
|
: {
|
||||||
|
items,
|
||||||
|
meta: {
|
||||||
|
currentPage: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
total: items.length,
|
||||||
|
perPage: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/clone"))
|
||||||
|
return route.fulfill({ json: { sources: [] } });
|
||||||
|
if (url.pathname.endsWith("/import-jobs"))
|
||||||
|
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Unknown fixture endpoint" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("closing furniture restores its scroll, focus, search, filter and selection", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await page.getByRole("textbox", { name: "Search furniture" }).fill("chair");
|
||||||
|
await page.waitForResponse(
|
||||||
|
(res) => new URL(res.url()).searchParams.get("search") === "chair",
|
||||||
|
);
|
||||||
|
await page.getByRole("combobox", { name: "Furniture type" }).click();
|
||||||
|
await page.getByRole("option", { name: "Floor items", exact: true }).click();
|
||||||
|
const trigger = page.getByRole("button", {
|
||||||
|
name: "View Chair 30",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await trigger.scrollIntoViewIfNeeded();
|
||||||
|
await page
|
||||||
|
.getByRole("checkbox", { name: "Select chair_30", exact: true })
|
||||||
|
.check();
|
||||||
|
const list = page.getByTestId("studio-furniture-list");
|
||||||
|
const top = await list.evaluate((element) => element.scrollTop);
|
||||||
|
await trigger.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Close furniture details", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
expect(await list.evaluate((element) => element.scrollTop)).toBe(top);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Search furniture" }),
|
||||||
|
).toHaveValue("chair");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("combobox", { name: "Furniture type" }),
|
||||||
|
).toContainText("Floor");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("checkbox", { name: "Select chair_30", exact: true }),
|
||||||
|
).toBeChecked();
|
||||||
|
await trigger.click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Close furniture details", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("checkbox", { name: "Select chair_30", exact: true }),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
test("review proposes completing existing furniture and filters actionable groups", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Import chair_0", exact: true })
|
||||||
|
.click();
|
||||||
|
const review = page.getByRole("dialog", {
|
||||||
|
name: "Review furniture import",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
review.getByText("Suggested: complete existing furniture", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
review.getByText("Missing: Catalog offer, Furnidata, Local icon"),
|
||||||
|
).toBeVisible();
|
||||||
|
await review
|
||||||
|
.getByRole("button", { name: "Conflicts (0)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
review.getByText("No furniture in this group.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await review
|
||||||
|
.getByRole("button", { name: "To complete (1)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
review.getByText("Suggested: complete existing furniture", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await review
|
||||||
|
.getByRole("button", { name: "Continue to confirmation", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("dialog", { name: "Confirm furniture import", exact: true })
|
||||||
|
.getByRole("button", { name: "Start import (1)", exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an older search response cannot replace newer furniture", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.route("**/api/admin/import/furni?*", async (route) => {
|
||||||
|
const search = new URL(route.request().url()).searchParams.get("search");
|
||||||
|
if (!search) return route.fallback();
|
||||||
|
if (search === "old")
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...items[0],
|
||||||
|
name: search === "old" ? "Old response" : "New response",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { currentPage: 1, lastPage: 1, total: 1, perPage: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
const search = page.getByRole("textbox", { name: "Search furniture" });
|
||||||
|
const oldRequest = page.waitForRequest(
|
||||||
|
(request) => new URL(request.url()).searchParams.get("search") === "old",
|
||||||
|
);
|
||||||
|
await search.fill("old");
|
||||||
|
await oldRequest;
|
||||||
|
await search.fill("new");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View New response", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View New response", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View Old response", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
- `furniture-jobs.tsx`: job history and retry controls.
|
- `furniture-jobs.tsx`: job history and retry controls.
|
||||||
- `use-furniture-inspection.ts`: cancellable inspection requests and explicit rechecks.
|
- `use-furniture-inspection.ts`: cancellable inspection requests and explicit rechecks.
|
||||||
- `furniture-inspector.tsx`: local component status, source comparison and repair controls.
|
- `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.
|
- `studio-types.ts`: shared client data types.
|
||||||
|
|
||||||
## Server responsibilities
|
## Server responsibilities
|
||||||
@@ -21,3 +22,5 @@
|
|||||||
- `src/lib/services/furni-job-worker.ts`: durable queued work and per-item results.
|
- `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.
|
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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
|
|||||||
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||||
import { compareFurniture } from "@/lib/furni/studio-inspection";
|
import { compareFurniture } from "@/lib/furni/studio-inspection";
|
||||||
import { FurnitureComparison } from "./furniture-inspector";
|
import { FurnitureComparison } from "./furniture-inspector";
|
||||||
|
import { importRecommendation } from "./import-recommendation";
|
||||||
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
|
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
|
||||||
import type { FurniItem } from "./studio-types";
|
import type { FurniItem } from "./studio-types";
|
||||||
import { useFurnitureInspection } from "./use-furniture-inspection";
|
import { useFurnitureInspection } from "./use-furniture-inspection";
|
||||||
@@ -39,6 +40,10 @@ export function ImportReview({
|
|||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onConfirm: (items: FurniItem[]) => void;
|
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 diagnosticsT = useTranslations("admin.studio.importDiagnostics");
|
||||||
const completenessT = useTranslations("admin.studio.completeness");
|
const completenessT = useTranslations("admin.studio.completeness");
|
||||||
const [attachments, setAttachments] = useState<Record<string, string>>({});
|
const [attachments, setAttachments] = useState<Record<string, string>>({});
|
||||||
@@ -97,13 +102,26 @@ export function ImportReview({
|
|||||||
compareFurniture(item, local).state !== "conflict"
|
compareFurniture(item, local).state !== "conflict"
|
||||||
);
|
);
|
||||||
}).length;
|
}).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 * 50,
|
||||||
(page + 1) * 50,
|
(page + 1) * 50,
|
||||||
);
|
);
|
||||||
const pageCount = Math.max(
|
const pageCount = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil((step === "review" ? items : ready).length / 50),
|
Math.ceil((step === "review" ? reviewItems : ready).length / 50),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -220,10 +238,49 @@ export function ImportReview({
|
|||||||
)}
|
)}
|
||||||
{step === "review" ? (
|
{step === "review" ? (
|
||||||
<div className="space-y-2">
|
<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) => {
|
{pageItems.map((item) => {
|
||||||
const local = localByName.get(item.classname);
|
const local = localByName.get(item.classname);
|
||||||
if (!local) return null;
|
if (!local) return null;
|
||||||
const comparison = compareFurniture(item, local);
|
const comparison = compareFurniture(item, local);
|
||||||
|
const recommendation = importRecommendation(local);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.classname}
|
key={item.classname}
|
||||||
@@ -244,6 +301,31 @@ export function ImportReview({
|
|||||||
<span className="block text-xs text-[var(--admin-text-muted)]">
|
<span className="block text-xs text-[var(--admin-text-muted)]">
|
||||||
{item.classname}
|
{item.classname}
|
||||||
</span>
|
</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>
|
||||||
<span>
|
<span>
|
||||||
{comparison.state === "new"
|
{comparison.state === "new"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -157,6 +158,32 @@ export function StudioClient({
|
|||||||
// Selection + detail
|
// Selection + detail
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [detail, setDetail] = useState<FurniItem | null>(null);
|
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
|
// Import state
|
||||||
const importingId = jobs.busy ? (review?.items[0]?.classname ?? null) : null;
|
const importingId = jobs.busy ? (review?.items[0]?.classname ?? null) : null;
|
||||||
@@ -256,6 +283,9 @@ export function StudioClient({
|
|||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async (search: string, page: number, source?: string) => {
|
async (search: string, page: number, source?: string) => {
|
||||||
|
listRequest.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
listRequest.current = controller;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -264,29 +294,56 @@ export function StudioClient({
|
|||||||
if (source) qs.set("source", source);
|
if (source) qs.set("source", source);
|
||||||
const res = await adminFetch(
|
const res = await adminFetch(
|
||||||
`/api/admin/import/furni?${qs.toString()}`,
|
`/api/admin/import/furni?${qs.toString()}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(`Request failed (${res.status})`);
|
setError(`Request failed (${res.status})`);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setItems(data.items ?? []);
|
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 {
|
} else {
|
||||||
setItems((prev) => [...prev, ...(data.items ?? [])]);
|
setItems((prev) => [...prev, ...(data.items ?? [])]);
|
||||||
}
|
}
|
||||||
setMeta(data.meta ?? null);
|
setMeta(data.meta ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error");
|
if (!controller.signal.aborted) setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeSource = (sourceId: string) => {
|
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);
|
setActiveSource(sourceId);
|
||||||
setActiveSourceName(
|
setActiveSourceName(
|
||||||
sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "",
|
sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "",
|
||||||
@@ -332,6 +389,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
async function prepareCloneReview() {
|
async function prepareCloneReview() {
|
||||||
if (!activeSource || preparingReview) return;
|
if (!activeSource || preparingReview) return;
|
||||||
|
const generation = sourceGeneration.current;
|
||||||
setPreparingReview(true);
|
setPreparingReview(true);
|
||||||
try {
|
try {
|
||||||
const response = await adminFetch(
|
const response = await adminFetch(
|
||||||
@@ -340,6 +398,7 @@ export function StudioClient({
|
|||||||
"&action=clonable",
|
"&action=clonable",
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
if (generation !== sourceGeneration.current) return;
|
||||||
if (!response.ok || !Array.isArray(result.classnames))
|
if (!response.ok || !Array.isArray(result.classnames))
|
||||||
throw new Error(result.error || "Could not load missing furniture");
|
throw new Error(result.error || "Could not load missing furniture");
|
||||||
const missing = new Set<string>(result.classnames);
|
const missing = new Set<string>(result.classnames);
|
||||||
@@ -358,6 +417,7 @@ export function StudioClient({
|
|||||||
page,
|
page,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (generation !== sourceGeneration.current) return;
|
||||||
if (
|
if (
|
||||||
!res.ok ||
|
!res.ok ||
|
||||||
!Array.isArray(data.items) ||
|
!Array.isArray(data.items) ||
|
||||||
@@ -369,6 +429,7 @@ export function StudioClient({
|
|||||||
lastPage = data.meta.lastPage;
|
lastPage = data.meta.lastPage;
|
||||||
page++;
|
page++;
|
||||||
} while (page <= lastPage && collected.size < missing.size);
|
} while (page <= lastPage && collected.size < missing.size);
|
||||||
|
if (generation !== sourceGeneration.current) return;
|
||||||
if (collected.size !== missing.size)
|
if (collected.size !== missing.size)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"The source changed while preparing the selection. Refresh and retry.",
|
"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.
|
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (review) return;
|
if (review || editingClassname || confirmDelete || e.defaultPrevented)
|
||||||
|
return;
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
||||||
@@ -470,13 +532,18 @@ export function StudioClient({
|
|||||||
toggleAll();
|
toggleAll();
|
||||||
}
|
}
|
||||||
if (e.key === "Escape" && !isInput) {
|
if (e.key === "Escape" && !isInput) {
|
||||||
|
if (detail) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
searchRef.current?.blur();
|
searchRef.current?.blur();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [toggleAll, review]);
|
}, [toggleAll, review, editingClassname, confirmDelete, detail]);
|
||||||
|
|
||||||
async function importBatch(
|
async function importBatch(
|
||||||
requested?: string[],
|
requested?: string[],
|
||||||
@@ -1511,7 +1578,11 @@ export function StudioClient({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<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 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
|
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
|
||||||
@@ -1567,7 +1638,7 @@ export function StudioClient({
|
|||||||
<div key={item.classname} className="group relative">
|
<div key={item.classname} className="group relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail(item)}
|
onClick={() => openDetail(item)}
|
||||||
className={cn(
|
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)]",
|
"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
|
isSel
|
||||||
@@ -1745,7 +1816,7 @@ export function StudioClient({
|
|||||||
<td className="px-1 py-1.5">
|
<td className="px-1 py-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail(item)}
|
onClick={() => openDetail(item)}
|
||||||
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||||
aria-label={`View ${item.name}`}
|
aria-label={`View ${item.name}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in new issue
Block a user