diff --git a/e2e/ui/studio.spec.ts b/e2e/ui/studio.spec.ts index dbf939af..69f592a1 100644 --- a/e2e/ui/studio.spec.ts +++ b/e2e/ui/studio.spec.ts @@ -33,10 +33,28 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async const errors: string[] = []; const mutations: string[] = []; let metadataRequests = 0; + let qualityRequests = 0; page.on("pageerror", (error) => errors.push(error.message)); await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); + if (url.pathname === "/api/admin/studio/nitro-quality") { + qualityRequests++; + return route.fulfill({ + json: { + report: { + scales: [32, 64].map((size) => ({ + size, + state: "missing", + assets: [], + animations: [], + directions: [], + issues: [], + })), + }, + }, + }); + } if (url.pathname === "/api/admin/studio/inspect") { const { classnames } = request.postDataJSON() as { classnames: string[] }; return route.fulfill({ @@ -113,6 +131,17 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async await expect( page.getByRole("button", { name: "View Fixture chair", exact: true }), ).toBeVisible(); + const history = page.getByRole("button", { + name: "Refresh history", + includeHidden: true, + }); + await expect(history).toHaveCount(0); + await page.locator("summary").filter({ hasText: "Import history" }).click(); + await expect(history).toBeVisible(); + await page.locator("summary").filter({ hasText: "Import history" }).click(); + await expect(history).toBeHidden(); + await page.locator("summary").filter({ hasText: "Import history" }).click(); + await expect(history).toBeVisible(); expect(metadataRequests).toBe(0); await page .getByRole("button", { name: "View Fixture chair", exact: true }) @@ -138,6 +167,20 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async editor.getByRole("switch", { name: "Can Sit On", exact: true }), ).toBeVisible(); expect(metadataRequests).toBe(1); + expect(qualityRequests).toBe(0); + await editor.getByRole("tab", { name: "32 / 64", exact: true }).click(); + await expect( + editor.getByRole("button", { name: "Preview generation", exact: true }), + ).toBeVisible(); + await expect( + editor.getByText("Scale 32 · Not included", { exact: true }), + ).toBeVisible(); + expect(qualityRequests).toBe(1); + await editor.getByRole("tab", { name: "Visual", exact: true }).click(); + await expect( + editor.getByRole("switch", { name: "Can Sit On", exact: true }), + ).toBeVisible(); + await editor.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(editor).toHaveCount(0); await page diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index 4ae1dfce..610a3088 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -16,10 +16,10 @@ import { Save, Sparkles, } from "lucide-react"; -import { useEffect, useEffectEvent, useState } from "react"; +import { useTranslations } from "next-intl"; +import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; import { toast } from "sonner"; -import { NitroQualityPanel } from "@/components/admin/studio/nitro-quality-panel"; -import { NitroScale32Generator } from "@/components/admin/studio/nitro-scale32-generator"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -41,6 +41,17 @@ import { nitroAnimationStatesCount, } from "@/lib/furni/auto-interaction"; +const NitroQualityPanel = lazy(() => + import("@/components/admin/studio/nitro-quality-panel").then((module) => ({ + default: module.NitroQualityPanel, + })), +); +const NitroScale32Generator = lazy(() => + import("@/components/admin/studio/nitro-scale32-generator").then( + (module) => ({ default: module.NitroScale32Generator }), + ), +); + interface NitroEditorDialogProps { classname: string; name?: string; @@ -135,6 +146,7 @@ export function NitroEditorDialog({ open, onOpenChange, }: NitroEditorDialogProps) { + const loadingT = useTranslations("pages.admin.housekeeping"); const [metadata, setMetadata] = useState(null); const [originalMetadata, setOriginalMetadata] = useState(null); @@ -448,19 +460,21 @@ export function NitroEditorDialog({ value="quality" className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4" > - { - setBundleRevision((value) => value + 1); - setMetadata(updated as NitroMetadata); - setOriginalMetadata( - structuredClone(updated) as NitroMetadata, - ); - setJsonText(JSON.stringify(updated, null, 2)); - }} - /> - + {loadingT("loading")}

}> + { + setBundleRevision((value) => value + 1); + setMetadata(updated as NitroMetadata); + setOriginalMetadata( + structuredClone(updated) as NitroMetadata, + ); + setJsonText(JSON.stringify(updated, null, 2)); + }} + /> + +
{/* ── Visual Tab ── */} ; + expanded?: boolean; +}) { + const t = useTranslations("pages.admin.importHistory"); + const format = useFormatter(); + const progressT = useTranslations("pages.admin.importProgress"); + const [limits, setLimits] = useState>({}); + return ( +
+ {state.error &&

{state.error}

} +

+ {state.paginated ? t("ownerHistory") : t("historyLimit")} +

+ + {state.paginated && ( +
+ + {t("page", { page: state.page })} + +
+ )} + {!state.jobs.length && !state.loading && !state.error && ( +

{t("empty")}

+ )} + {state.jobs.map((job) => { + const retryItems = retryableJobItems(job); + const done = job.items.filter((i) => i.state === "done"), + failed = job.items.filter((i) => i.state === "failed"); + return ( +
+ + {format.dateTime(new Date(job.createdAt), { + dateStyle: "short", + timeStyle: "short", + })}{" "} + · {t(job.state)} ·{" "} + {t("result", { + done: done.length, + total: job.items.length, + failed: failed.length, + })} + + +
    + {job.items.slice(0, limits[job.id] ?? 50).map((item) => ( +
  • + {item.classname} · {t(item.state)} + {item.itemId ? ` · ID ${item.itemId}` : ""} + {item.phase && + (item.state === "running" || + item.state === "failed" || + item.state === "interrupted") && ( +

    + {progressT( + item.state === "running" ? "current" : "last", + { phase: progressT(item.phase) }, + )} + {item.state === "running" && + item.phaseStartedAt && + ` · ${progressT("elapsed", { seconds: Math.max(0, Math.floor((Date.now() - Date.parse(item.phaseStartedAt)) / 1000)) })}`} + {item.sourceAttempt && ` · ${item.sourceAttempt}`} +

    + )} + {item.recoveredSource && ( +

    + {progressT("recovered", { + source: item.recoveredSource.sourceName, + revision: item.recoveredSource.revision, + })} +

    + )} + {item.error && ( +

    {item.error}

    + )} + {!job.syncKind && + item.state === "failed" && + retryItems.some( + (retryItem) => retryItem.classname === item.classname, + ) && ( + + )} + {[...new Set(item.warnings ?? [])].map((warning) => ( +

    + {warning} +

    + ))} +
  • + ))} +
+ {job.items.length > (limits[job.id] ?? 50) && ( + + )} + {(job.state === "queued" || job.state === "running") && ( +
+

+ {t(job.cancelRequested ? "cancelRequested" : "cancelHint")} +

+ +
+ )} + {job.items.some( + (item) => + item.state === "interrupted" || + item.error?.startsWith("Server restarted during import.") || + (item.state === "running" && job.state === "interrupted"), + ) && ( +

+ {t("uncertainOutcome")}{" "} + + {t("reviewLocalData")} + +

+ )} + {retryItems.length > 0 && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/src/components/admin/studio/furniture-jobs.tsx b/src/components/admin/studio/furniture-jobs.tsx index d5cc4880..e53eca15 100644 --- a/src/components/admin/studio/furniture-jobs.tsx +++ b/src/components/admin/studio/furniture-jobs.tsx @@ -1,11 +1,13 @@ "use client"; -import Link from "next/link"; -import { useFormatter, useTranslations } from "next-intl"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { retryableJobItems } from "@/lib/furni/import-job-retry"; -import { ImportAttachmentRecovery } from "./import-attachment-recovery"; +import { useTranslations } from "next-intl"; +import { lazy, Suspense, useState } from "react"; import type { useFurnitureJobs } from "./use-furniture-jobs"; + +const HistoryBody = lazy(() => + import("./furniture-job-history-body").then((module) => ({ + default: module.FurnitureJobHistoryBody, + })), +); export function FurnitureJobHistory({ state, expanded = false, @@ -14,12 +16,14 @@ export function FurnitureJobHistory({ expanded?: boolean; }) { const t = useTranslations("pages.admin.importHistory"); - const format = useFormatter(); - const progressT = useTranslations("pages.admin.importProgress"); - const [limits, setLimits] = useState>({}); + const common = useTranslations("pages.admin.housekeeping"); + const [opened, setOpened] = useState(expanded); return (
{ + if (event.currentTarget.open) setOpened(true); + }} className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm" > @@ -29,180 +33,17 @@ export function FurnitureJobHistory({ ).length, })} -
- {state.error &&

{state.error}

} -

- {state.paginated ? t("ownerHistory") : t("historyLimit")} -

- - {state.paginated && ( -
- - {t("page", { page: state.page })} - -
- )} - {!state.jobs.length && !state.loading && !state.error && ( -

{t("empty")}

- )} - {state.jobs.map((job) => { - const retryItems = retryableJobItems(job); - const done = job.items.filter((i) => i.state === "done"), - failed = job.items.filter((i) => i.state === "failed"); - return ( -
- - {format.dateTime(new Date(job.createdAt), { - dateStyle: "short", - timeStyle: "short", - })}{" "} - · {t(job.state)} ·{" "} - {t("result", { - done: done.length, - total: job.items.length, - failed: failed.length, - })} - - -
    - {job.items.slice(0, limits[job.id] ?? 50).map((item) => ( -
  • - {item.classname} · {t(item.state)} - {item.itemId ? ` · ID ${item.itemId}` : ""} - {item.phase && - (item.state === "running" || - item.state === "failed" || - item.state === "interrupted") && ( -

    - {progressT( - item.state === "running" ? "current" : "last", - { phase: progressT(item.phase) }, - )} - {item.state === "running" && - item.phaseStartedAt && - ` · ${progressT("elapsed", { seconds: Math.max(0, Math.floor((Date.now() - Date.parse(item.phaseStartedAt)) / 1000)) })}`} - {item.sourceAttempt && ` · ${item.sourceAttempt}`} -

    - )} - {item.recoveredSource && ( -

    - {progressT("recovered", { - source: item.recoveredSource.sourceName, - revision: item.recoveredSource.revision, - })} -

    - )} - {item.error && ( -

    - {item.error} -

    - )} - {!job.syncKind && - item.state === "failed" && - retryItems.some( - (retryItem) => retryItem.classname === item.classname, - ) && ( - - )} - {[...new Set(item.warnings ?? [])].map((warning) => ( -

    - {warning} -

    - ))} -
  • - ))} -
- {job.items.length > (limits[job.id] ?? 50) && ( - - )} - {(job.state === "queued" || job.state === "running") && ( -
-

- {t(job.cancelRequested ? "cancelRequested" : "cancelHint")} -

- -
- )} - {job.items.some( - (item) => - item.state === "interrupted" || - item.error?.startsWith("Server restarted during import.") || - (item.state === "running" && job.state === "interrupted"), - ) && ( -

- {t("uncertainOutcome")}{" "} - - {t("reviewLocalData")} - -

- )} - {retryItems.length > 0 && ( - - )} -
- ); - })} -
+ {(opened || expanded) && ( + + {common("loading")} +

+ } + > + +
+ )}
); } diff --git a/src/lib/studio-bundle.test.ts b/src/lib/studio-bundle.test.ts new file mode 100644 index 00000000..b82c7dbc --- /dev/null +++ b/src/lib/studio-bundle.test.ts @@ -0,0 +1,48 @@ +import path from "node:path"; +import { build } from "esbuild"; +import { expect, it } from "vitest"; + +it("keeps scale preview and generation outside the initial Nitro editor bundle", async () => { + const entry = "src/app/admin/import/furni/nitro-editor-dialog.tsx"; + const result = await build({ + entryPoints: [entry], + bundle: true, + splitting: true, + format: "esm", + write: false, + outdir: "storage/bundle-check", + metafile: true, + packages: "external", + alias: { "@": path.resolve("src") }, + }); + const outputs = result.metafile.outputs; + const start = Object.keys(outputs).find( + (key) => outputs[key].entryPoint === entry, + ); + expect(start).toBeDefined(); + const seen = new Set(); + function visit(key: string) { + if (seen.has(key)) return; + seen.add(key); + for (const dependency of outputs[key].imports) { + if (dependency.kind !== "dynamic-import" && !dependency.external) + visit(dependency.path); + } + } + if (!start) throw new Error("Missing editor entry bundle"); + visit(start); + const initialInputs = new Set( + [...seen].flatMap((key) => Object.keys(outputs[key].inputs)), + ); + for (const name of [ + "nitro-quality-panel.tsx", + "nitro-scale32-generator.tsx", + ]) { + expect( + Object.keys(result.metafile.inputs).some((input) => input.endsWith(name)), + ).toBe(true); + expect([...initialInputs].some((input) => input.endsWith(name))).toBe( + false, + ); + } +}, 30_000);