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);