perf(studio): defer optional panels and trim validator imports
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user