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

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