perf(studio): defer optional panels and trim validator imports
CI / check (push) Successful in 2m2s
CI / deploy (push) Successful in 1m11s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-11 09:25:21 +02:00
1 parent a45d792563
commit 3b76d063a6
7 files changed
+401 -118

No files matched your search

+2
View File
@@ -15,6 +15,7 @@ const port = Number(process.env.UI_TEST_PORT || 3107);
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw Error("Invalid UI_TEST_PORT");
const actions = new Set([
"@/actions/import-furni",
"@/actions/events",
"@/actions/admin-settings",
"@/actions/admin-table-views",
@@ -197,6 +198,7 @@ const server = createServer(async (req, res) => {
if (
[
"/admin/attachment-harness",
"/admin/studio-harness",
"/admin/jobs-harness",
"/admin/settings",
"/admin/articles/new",
+9 -1
View File
@@ -7,7 +7,9 @@ import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form";
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
import { ArticleForm } from "@/components/admin/article-form";
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
import { StudioClient } from "@/components/admin/studio/studio-client";
import type { ArticleSaveResult } from "@/lib/article-input";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { themePaletteCss } from "@/lib/theme-css";
import { PRESETS } from "@/lib/theme-presets";
import messages from "@/messages/en.json";
@@ -103,7 +105,13 @@ createRoot(root).render(
? "News editor"
: "Furniture synchronization"}
</h1>
{route.includes("attachment-harness") ? (
{route.includes("studio-harness") ? (
<StudioClient
source={buildFurniImportSource("it")}
initialTree={[]}
defaultTranslate={false}
/>
) : route.includes("attachment-harness") ? (
<AttachmentHarness />
) : route.includes("events/recovery") ? (
<EventForm
+5
View File
@@ -27,3 +27,8 @@ export const createEvent = (input: unknown) =>
fixtureAction("create-event", input);
export const updateEvent = (input: unknown) =>
fixtureAction("update-event", input);
export const deleteImportedFurni = (input: unknown) =>
fixtureAction("delete-imported-furni", input);
export const setFurnidataTranslateEnabled = (input: unknown) =>
fixtureAction("furnidata-translation", input);
+164
View File
@@ -0,0 +1,164 @@
import { expect, test } from "@playwright/test";
const items = [
{
id: 1,
classname: "fixture_chair",
name: "Fixture chair",
description: "Synthetic chair",
type: "flooritem",
revision: 1,
category: "other",
alreadyImported: true,
nitroExists: true,
iconUrl: "/fixture/cover.svg",
},
{
id: 2,
classname: "fixture_table",
name: "Fixture table",
description: "Synthetic table",
type: "flooritem",
revision: 1,
category: "other",
alreadyImported: false,
nitroExists: false,
iconUrl: "/fixture/cover.svg",
},
];
test("Studio opens and dismisses detail, Nitro editor and import review", async ({
page,
}) => {
const errors: string[] = [];
const mutations: string[] = [];
let metadataRequests = 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/inspect") {
const { classnames } = request.postDataJSON() as { classnames: string[] };
return route.fulfill({
json: {
items: classnames.map((classname) => ({
classname,
sql: [],
catalog: [],
furnidata: [],
furnidataReadable: true,
nitro: { exists: false, bytes: 0 },
icon: { exists: false, bytes: 0 },
})),
},
});
}
if (url.pathname === "/api/admin/studio/source-assets") {
const body = request.postDataJSON() as {
items: Array<{ classname: string }>;
};
return route.fulfill({
json: {
items: body.items.map(({ classname }) => ({
classname,
state: "available",
revision: 1,
alternatives: [],
attempts: [],
})),
},
});
}
if (request.method() !== "GET") {
mutations.push(`${request.method()} ${url.pathname}`);
return route.fulfill({
status: 405,
json: { error: "Fixture forbids mutations" },
});
}
if (url.pathname.endsWith("/nitro-editor")) {
metadataRequests++;
return route.fulfill({
json: {
metadata: {
name: "fixture_chair",
logicType: "furniture_basic",
visualizationType: "furniture_static",
},
flags: { canstandon: false, cansiton: false, canlayon: false },
},
});
}
if (url.pathname === "/api/admin/import/furni") {
return route.fulfill({
json:
url.searchParams.get("action") === "stats"
? { totalInDb: 1, inCatalog: 1, notInCatalog: 0, missingNitro: 0 }
: {
items,
meta: { currentPage: 1, lastPage: 1, total: 2, perPage: 20 },
},
});
}
if (url.pathname === "/api/admin/import/clone")
return route.fulfill({ json: { sources: [] } });
if (url.pathname === "/api/admin/studio/import-jobs")
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
return route.fulfill({
status: 404,
json: { error: "Unknown fixture endpoint" },
});
});
await page.goto("/admin/studio-harness");
await expect(
page.getByRole("button", { name: "View Fixture chair", exact: true }),
).toBeVisible();
expect(metadataRequests).toBe(0);
await page
.getByRole("button", { name: "View Fixture chair", exact: true })
.click();
await expect(
page.getByRole("button", { name: "Close furniture details", exact: true }),
).toBeVisible();
await expect(
page.getByText("Synthetic chair", { exact: true }).first(),
).toBeVisible();
await page
.getByRole("button", { name: "Close furniture details", exact: true })
.click();
await expect(
page.getByRole("button", { name: "Close furniture details", exact: true }),
).toHaveCount(0);
await page
.getByRole("button", { name: "Edit nitro fixture_chair", exact: true })
.click();
const editor = page.getByRole("dialog", { name: "Edit Nitro", exact: true });
await expect(editor).toBeVisible();
await expect(
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
).toBeVisible();
expect(metadataRequests).toBe(1);
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(editor).toHaveCount(0);
await page
.getByRole("button", { name: "Import fixture_table", exact: true })
.click();
const review = page.getByRole("dialog", {
name: "Review furniture import",
exact: true,
});
await expect(review).toBeVisible();
await expect(
review.getByText("Fixture table", { exact: true }),
).toBeVisible();
await expect(
review.getByRole("button", {
name: "Continue to confirmation",
exact: true,
}),
).toBeEnabled();
await review.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(review).toHaveCount(0);
expect(mutations).toEqual([]);
expect(errors).toEqual([]);
});
@@ -1,34 +1,53 @@
import { QueryClient, queryOptions } from "@tanstack/react-query";
import { z } from "zod/mini";
import {
array as zArray,
boolean as zBoolean,
enum as zEnum,
int as zInt,
iso as zIso,
literal as zLiteral,
looseObject as zLooseObject,
maxLength as zMaxLength,
nonnegative as zNonnegative,
nullable as zNullable,
number as zNumber,
object as zObject,
optional as zOptional,
positive as zPositive,
refine as zRefine,
regex as zRegex,
string as zString,
uuid as zUuid,
} from "zod/mini";
import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob } from "@/lib/furni/import-job";
const endpoint = "/api/admin/studio/import-jobs";
const historyTimeoutMs = 15_000;
const jobSchema = z.looseObject({
id: z.uuid(),
userId: z.int().check(z.positive()),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]),
translate: z.boolean(),
mode: z.optional(z.literal("repair")),
syncKind: z.optional(z.enum(["official", "clone"])),
operationId: z.optional(z.string()),
retryOf: z.optional(z.string()),
sourceId: z.optional(z.string()),
cancelRequested: z.optional(z.boolean()),
langs: z.optional(z.array(z.string())),
items: z.array(
z.looseObject({
id: z.int().check(z.nonnegative()),
classname: z.string(),
name: z.string(),
description: z.string(),
type: z.string(),
revision: z.number(),
category: z.string(),
state: z.enum([
const jobSchema = zLooseObject({
id: zUuid(),
userId: zInt().check(zPositive()),
createdAt: zIso.datetime({ offset: true }),
updatedAt: zIso.datetime({ offset: true }),
state: zEnum(["queued", "running", "completed", "interrupted", "cancelled"]),
translate: zBoolean(),
mode: zOptional(zLiteral("repair")),
syncKind: zOptional(zEnum(["official", "clone"])),
operationId: zOptional(zString()),
retryOf: zOptional(zString()),
sourceId: zOptional(zString()),
cancelRequested: zOptional(zBoolean()),
langs: zOptional(zArray(zString())),
items: zArray(
zLooseObject({
id: zInt().check(zNonnegative()),
classname: zString(),
name: zString(),
description: zString(),
type: zString(),
revision: zNumber(),
category: zString(),
state: zEnum([
"pending",
"running",
"done",
@@ -36,26 +55,24 @@ const jobSchema = z.looseObject({
"cancelled",
"interrupted",
]),
error: z.optional(z.string()),
warnings: z.optional(z.array(z.string())),
itemId: z.optional(z.number()),
error: zOptional(zString()),
warnings: zOptional(zArray(zString())),
itemId: zOptional(zNumber()),
}),
),
});
const historySchema = z.object({
ok: z.literal(true),
jobs: z.array(jobSchema).check(z.maxLength(30)),
nextCursor: z.optional(
z.nullable(
z.string().check(
z.regex(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i,
),
z.refine((value) => {
const historySchema = zObject({
ok: zLiteral(true),
jobs: zArray(jobSchema).check(zMaxLength(30)),
nextCursor: zOptional(
zNullable(
zString().check(
zRegex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i),
zRefine((value) => {
const [timestamp, id] = value.split("|");
return (
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
z.uuid().safeParse(id).success
zIso.datetime({ precision: 3 }).safeParse(timestamp).success &&
zUuid().safeParse(id).success
);
}),
),
@@ -96,9 +113,9 @@ export async function readFurnitureJobResponse(
fallback: string,
): Promise<ImportJob> {
const data = await responseData(response, fallback);
const parsed = z
.object({ ok: z.literal(true), job: jobSchema })
.safeParse(data);
const parsed = zObject({ ok: zLiteral(true), job: jobSchema }).safeParse(
data,
);
if (!parsed.success) throw Error("Invalid import job response");
return parsed.data.job as ImportJob;
}
+114 -74
View File
@@ -22,10 +22,17 @@ import {
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { deleteImportedFurni } from "@/actions/import-furni";
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -59,15 +66,14 @@ import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
import { FurnitureDetailDrawer } from "./furniture-detail-drawer";
import { FurnitureJobHistory } from "./furniture-jobs";
import { ImportReview } from "./import-review";
import { filterFurniture } from "./studio-filters";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import { StudioPanelLoading } from "./studio-panel-loading";
import type {
BatchItemStatus,
FurniItem,
@@ -78,6 +84,22 @@ import type {
import { useFurnitureJobs } from "./use-furniture-jobs";
import { useStudioTranslations } from "./use-studio-translations";
const NitroEditorDialog = lazy(() =>
import("@/app/admin/import/furni/nitro-editor-dialog").then((module) => ({
default: module.NitroEditorDialog,
})),
);
const FurnitureDetailDrawer = lazy(() =>
import("./furniture-detail-drawer").then((module) => ({
default: module.FurnitureDetailDrawer,
})),
);
const ImportReview = lazy(() =>
import("./import-review").then((module) => ({
default: module.ImportReview,
})),
);
export function StudioClient({
source,
initialTree,
@@ -1917,83 +1939,101 @@ export function StudioClient({
{/* ── Detail drawer ─────────────────────────────── */}
{detail ? (
<FurnitureDetailDrawer
detail={detail}
importingId={importingId}
regenerating={regeneratingNitro.has(detail.classname)}
inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
.filter(
(j) =>
j.state === "completed" ||
j.state === "cancelled" ||
j.state === "interrupted",
)
.map((j) => j.id)
.join(",")}`}
busy={
jobs.busy ||
jobs.jobs.some(
(j) =>
(j.state === "queued" || j.state === "running") &&
j.items.some((i) => i.classname === detail.classname),
)
<Suspense
fallback={
<StudioPanelLoading drawer onClose={() => setDetail(null)} />
}
onClose={() => setDetail(null)}
onImport={() => setReview({ items: [detail], single: true })}
onEdit={() => setEditingClassname(detail.classname)}
onRegenerate={() => void regenNitro(detail)}
onDelete={() => {
setDeleting(detail.classname);
setConfirmDelete(true);
}}
onRepair={() =>
jobs.submit([detail], {
sourceId: activeSource || undefined,
translate: false,
mode: "repair",
})
}
/>
>
<FurnitureDetailDrawer
detail={detail}
importingId={importingId}
regenerating={regeneratingNitro.has(detail.classname)}
inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
.filter(
(j) =>
j.state === "completed" ||
j.state === "cancelled" ||
j.state === "interrupted",
)
.map((j) => j.id)
.join(",")}`}
busy={
jobs.busy ||
jobs.jobs.some(
(j) =>
(j.state === "queued" || j.state === "running") &&
j.items.some((i) => i.classname === detail.classname),
)
}
onClose={() => setDetail(null)}
onImport={() => setReview({ items: [detail], single: true })}
onEdit={() => setEditingClassname(detail.classname)}
onRegenerate={() => void regenNitro(detail)}
onDelete={() => {
setDeleting(detail.classname);
setConfirmDelete(true);
}}
onRepair={() =>
jobs.submit([detail], {
sourceId: activeSource || undefined,
translate: false,
mode: "repair",
})
}
/>
</Suspense>
) : null}
</div>
{review && (
<ImportReview
items={review.items}
sourceId={activeSource || undefined}
source={
sources.find((entry) => entry.id === activeSource)?.name ??
source.label
}
translation={
translate
? translateLangs.length
? translateLangs.join(", ")
: "Default languages"
: "Off"
}
onCancel={() => setReview(null)}
busy={jobs.busy}
onConfirm={async (accepted) => {
if (
await jobs.submit(accepted, {
sourceId: activeSource || undefined,
translate,
langs: translateLangs.length ? translateLangs : undefined,
})
) {
setReview(null);
setSelected(new Set());
<Suspense
fallback={<StudioPanelLoading onClose={() => setReview(null)} />}
>
<ImportReview
items={review.items}
sourceId={activeSource || undefined}
source={
sources.find((entry) => entry.id === activeSource)?.name ??
source.label
}
}}
/>
translation={
translate
? translateLangs.length
? translateLangs.join(", ")
: "Default languages"
: "Off"
}
onCancel={() => setReview(null)}
busy={jobs.busy}
onConfirm={async (accepted) => {
if (
await jobs.submit(accepted, {
sourceId: activeSource || undefined,
translate,
langs: translateLangs.length ? translateLangs : undefined,
})
) {
setReview(null);
setSelected(new Set());
}
}}
/>
</Suspense>
)}
{editingClassname !== null && (
<Suspense
fallback={
<StudioPanelLoading onClose={() => setEditingClassname(null)} />
}
>
<NitroEditorDialog
classname={editingClassname ?? ""}
name={detail?.name ?? ""}
open={editingClassname !== null}
onOpenChange={(open) => !open && setEditingClassname(null)}
/>
</Suspense>
)}
<NitroEditorDialog
classname={editingClassname ?? ""}
name={detail?.name ?? ""}
open={editingClassname !== null}
onOpenChange={(open) => !open && setEditingClassname(null)}
/>
{confirmDialog}
@@ -0,0 +1,47 @@
"use client";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function StudioPanelLoading({
onClose,
drawer = false,
}: {
onClose: () => void;
drawer?: boolean;
}) {
const t = useTranslations("pages.admin.housekeeping");
const status = (
<p role="status" className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
{t("loading")}
</p>
);
if (drawer)
return (
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col gap-4 border-l border-[var(--admin-border)] bg-[var(--admin-surface)] p-4 shadow-xl lg:static lg:shadow-none">
{status}
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
</aside>
);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("loading")}</DialogTitle>
</DialogHeader>
{status}
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
</DialogContent>
</Dialog>
);
}