perf(studio): defer optional panels and trim validator imports

This commit is contained in:
2026-09-11 09:25:21 +02:00
parent a45d792563
commit 3b76d063a6
7 changed files with 401 additions and 118 deletions
@@ -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>
);
}