diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs
index 714f61a8..52f44062 100644
--- a/e2e/ui/fixture-server.mjs
+++ b/e2e/ui/fixture-server.mjs
@@ -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",
diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx
index d4ea3c6f..1dcac4a1 100644
--- a/e2e/ui/fixtures/entry.tsx
+++ b/e2e/ui/fixtures/entry.tsx
@@ -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"}
- {route.includes("attachment-harness") ? (
+ {route.includes("studio-harness") ? (
+
+ ) : route.includes("attachment-harness") ? (
) : route.includes("events/recovery") ? (
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);
diff --git a/e2e/ui/studio.spec.ts b/e2e/ui/studio.spec.ts
new file mode 100644
index 00000000..dbf939af
--- /dev/null
+++ b/e2e/ui/studio.spec.ts
@@ -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([]);
+});
diff --git a/src/components/admin/studio/furniture-jobs-query.ts b/src/components/admin/studio/furniture-jobs-query.ts
index bb80b144..be23276b 100644
--- a/src/components/admin/studio/furniture-jobs-query.ts
+++ b/src/components/admin/studio/furniture-jobs-query.ts
@@ -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 {
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;
}
diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx
index 558aa11f..5626f8c5 100644
--- a/src/components/admin/studio/studio-client.tsx
+++ b/src/components/admin/studio/studio-client.tsx
@@ -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 ? (
-
- 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),
- )
+ 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",
- })
- }
- />
+ >
+
+ 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",
+ })
+ }
+ />
+
) : null}
{review && (
- 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());
+ setReview(null)} />}
+ >
+ 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());
+ }
+ }}
+ />
+
+ )}
+ {editingClassname !== null && (
+ setEditingClassname(null)} />
+ }
+ >
+ !open && setEditingClassname(null)}
+ />
+
)}
- !open && setEditingClassname(null)}
- />
{confirmDialog}
diff --git a/src/components/admin/studio/studio-panel-loading.tsx b/src/components/admin/studio/studio-panel-loading.tsx
new file mode 100644
index 00000000..06c662a8
--- /dev/null
+++ b/src/components/admin/studio/studio-panel-loading.tsx
@@ -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 = (
+
+
+ {t("loading")}
+
+ );
+ if (drawer)
+ return (
+
+ );
+ return (
+
+ );
+}