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
@@ -15,6 +15,7 @@ const port = Number(process.env.UI_TEST_PORT || 3107);
|
|||||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||||
throw Error("Invalid UI_TEST_PORT");
|
throw Error("Invalid UI_TEST_PORT");
|
||||||
const actions = new Set([
|
const actions = new Set([
|
||||||
|
"@/actions/import-furni",
|
||||||
"@/actions/events",
|
"@/actions/events",
|
||||||
"@/actions/admin-settings",
|
"@/actions/admin-settings",
|
||||||
"@/actions/admin-table-views",
|
"@/actions/admin-table-views",
|
||||||
@@ -197,6 +198,7 @@ const server = createServer(async (req, res) => {
|
|||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
"/admin/attachment-harness",
|
"/admin/attachment-harness",
|
||||||
|
"/admin/studio-harness",
|
||||||
"/admin/jobs-harness",
|
"/admin/jobs-harness",
|
||||||
"/admin/settings",
|
"/admin/settings",
|
||||||
"/admin/articles/new",
|
"/admin/articles/new",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form";
|
|||||||
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
||||||
import { ArticleForm } from "@/components/admin/article-form";
|
import { ArticleForm } from "@/components/admin/article-form";
|
||||||
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
|
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 type { ArticleSaveResult } from "@/lib/article-input";
|
||||||
|
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { themePaletteCss } from "@/lib/theme-css";
|
import { themePaletteCss } from "@/lib/theme-css";
|
||||||
import { PRESETS } from "@/lib/theme-presets";
|
import { PRESETS } from "@/lib/theme-presets";
|
||||||
import messages from "@/messages/en.json";
|
import messages from "@/messages/en.json";
|
||||||
@@ -103,7 +105,13 @@ createRoot(root).render(
|
|||||||
? "News editor"
|
? "News editor"
|
||||||
: "Furniture synchronization"}
|
: "Furniture synchronization"}
|
||||||
</h1>
|
</h1>
|
||||||
{route.includes("attachment-harness") ? (
|
{route.includes("studio-harness") ? (
|
||||||
|
<StudioClient
|
||||||
|
source={buildFurniImportSource("it")}
|
||||||
|
initialTree={[]}
|
||||||
|
defaultTranslate={false}
|
||||||
|
/>
|
||||||
|
) : route.includes("attachment-harness") ? (
|
||||||
<AttachmentHarness />
|
<AttachmentHarness />
|
||||||
) : route.includes("events/recovery") ? (
|
) : route.includes("events/recovery") ? (
|
||||||
<EventForm
|
<EventForm
|
||||||
|
|||||||
@@ -27,3 +27,8 @@ export const createEvent = (input: unknown) =>
|
|||||||
fixtureAction("create-event", input);
|
fixtureAction("create-event", input);
|
||||||
export const updateEvent = (input: unknown) =>
|
export const updateEvent = (input: unknown) =>
|
||||||
fixtureAction("update-event", input);
|
fixtureAction("update-event", input);
|
||||||
|
|
||||||
|
export const deleteImportedFurni = (input: unknown) =>
|
||||||
|
fixtureAction("delete-imported-furni", input);
|
||||||
|
export const setFurnidataTranslateEnabled = (input: unknown) =>
|
||||||
|
fixtureAction("furnidata-translation", input);
|
||||||
@@ -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 { 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 { adminFetch } from "@/lib/admin-fetch";
|
||||||
import type { ImportJob } from "@/lib/furni/import-job";
|
import type { ImportJob } from "@/lib/furni/import-job";
|
||||||
|
|
||||||
const endpoint = "/api/admin/studio/import-jobs";
|
const endpoint = "/api/admin/studio/import-jobs";
|
||||||
const historyTimeoutMs = 15_000;
|
const historyTimeoutMs = 15_000;
|
||||||
const jobSchema = z.looseObject({
|
const jobSchema = zLooseObject({
|
||||||
id: z.uuid(),
|
id: zUuid(),
|
||||||
userId: z.int().check(z.positive()),
|
userId: zInt().check(zPositive()),
|
||||||
createdAt: z.iso.datetime({ offset: true }),
|
createdAt: zIso.datetime({ offset: true }),
|
||||||
updatedAt: z.iso.datetime({ offset: true }),
|
updatedAt: zIso.datetime({ offset: true }),
|
||||||
state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]),
|
state: zEnum(["queued", "running", "completed", "interrupted", "cancelled"]),
|
||||||
translate: z.boolean(),
|
translate: zBoolean(),
|
||||||
mode: z.optional(z.literal("repair")),
|
mode: zOptional(zLiteral("repair")),
|
||||||
syncKind: z.optional(z.enum(["official", "clone"])),
|
syncKind: zOptional(zEnum(["official", "clone"])),
|
||||||
operationId: z.optional(z.string()),
|
operationId: zOptional(zString()),
|
||||||
retryOf: z.optional(z.string()),
|
retryOf: zOptional(zString()),
|
||||||
sourceId: z.optional(z.string()),
|
sourceId: zOptional(zString()),
|
||||||
cancelRequested: z.optional(z.boolean()),
|
cancelRequested: zOptional(zBoolean()),
|
||||||
langs: z.optional(z.array(z.string())),
|
langs: zOptional(zArray(zString())),
|
||||||
items: z.array(
|
items: zArray(
|
||||||
z.looseObject({
|
zLooseObject({
|
||||||
id: z.int().check(z.nonnegative()),
|
id: zInt().check(zNonnegative()),
|
||||||
classname: z.string(),
|
classname: zString(),
|
||||||
name: z.string(),
|
name: zString(),
|
||||||
description: z.string(),
|
description: zString(),
|
||||||
type: z.string(),
|
type: zString(),
|
||||||
revision: z.number(),
|
revision: zNumber(),
|
||||||
category: z.string(),
|
category: zString(),
|
||||||
state: z.enum([
|
state: zEnum([
|
||||||
"pending",
|
"pending",
|
||||||
"running",
|
"running",
|
||||||
"done",
|
"done",
|
||||||
@@ -36,26 +55,24 @@ const jobSchema = z.looseObject({
|
|||||||
"cancelled",
|
"cancelled",
|
||||||
"interrupted",
|
"interrupted",
|
||||||
]),
|
]),
|
||||||
error: z.optional(z.string()),
|
error: zOptional(zString()),
|
||||||
warnings: z.optional(z.array(z.string())),
|
warnings: zOptional(zArray(zString())),
|
||||||
itemId: z.optional(z.number()),
|
itemId: zOptional(zNumber()),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const historySchema = z.object({
|
const historySchema = zObject({
|
||||||
ok: z.literal(true),
|
ok: zLiteral(true),
|
||||||
jobs: z.array(jobSchema).check(z.maxLength(30)),
|
jobs: zArray(jobSchema).check(zMaxLength(30)),
|
||||||
nextCursor: z.optional(
|
nextCursor: zOptional(
|
||||||
z.nullable(
|
zNullable(
|
||||||
z.string().check(
|
zString().check(
|
||||||
z.regex(
|
zRegex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i),
|
||||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i,
|
zRefine((value) => {
|
||||||
),
|
|
||||||
z.refine((value) => {
|
|
||||||
const [timestamp, id] = value.split("|");
|
const [timestamp, id] = value.split("|");
|
||||||
return (
|
return (
|
||||||
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
|
zIso.datetime({ precision: 3 }).safeParse(timestamp).success &&
|
||||||
z.uuid().safeParse(id).success
|
zUuid().safeParse(id).success
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -96,9 +113,9 @@ export async function readFurnitureJobResponse(
|
|||||||
fallback: string,
|
fallback: string,
|
||||||
): Promise<ImportJob> {
|
): Promise<ImportJob> {
|
||||||
const data = await responseData(response, fallback);
|
const data = await responseData(response, fallback);
|
||||||
const parsed = z
|
const parsed = zObject({ ok: zLiteral(true), job: jobSchema }).safeParse(
|
||||||
.object({ ok: z.literal(true), job: jobSchema })
|
data,
|
||||||
.safeParse(data);
|
);
|
||||||
if (!parsed.success) throw Error("Invalid import job response");
|
if (!parsed.success) throw Error("Invalid import job response");
|
||||||
return parsed.data.job as ImportJob;
|
return parsed.data.job as ImportJob;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,17 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { toast } from "sonner";
|
||||||
import { deleteImportedFurni } from "@/actions/import-furni";
|
import { deleteImportedFurni } from "@/actions/import-furni";
|
||||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -59,15 +66,14 @@ import type { TreeNode } from "@/types/catalog";
|
|||||||
import { BatchProgress } from "./batch-progress";
|
import { BatchProgress } from "./batch-progress";
|
||||||
import { CatalogRail } from "./catalog-rail";
|
import { CatalogRail } from "./catalog-rail";
|
||||||
import { CheckboxDot } from "./checkbox-dot";
|
import { CheckboxDot } from "./checkbox-dot";
|
||||||
import { FurnitureDetailDrawer } from "./furniture-detail-drawer";
|
|
||||||
import { FurnitureJobHistory } from "./furniture-jobs";
|
import { FurnitureJobHistory } from "./furniture-jobs";
|
||||||
import { ImportReview } from "./import-review";
|
|
||||||
import { filterFurniture } from "./studio-filters";
|
import { filterFurniture } from "./studio-filters";
|
||||||
import {
|
import {
|
||||||
getFurniImageUrl,
|
getFurniImageUrl,
|
||||||
handleImgError,
|
handleImgError,
|
||||||
TYPE_LABELS,
|
TYPE_LABELS,
|
||||||
} from "./studio-furni-icons";
|
} from "./studio-furni-icons";
|
||||||
|
import { StudioPanelLoading } from "./studio-panel-loading";
|
||||||
import type {
|
import type {
|
||||||
BatchItemStatus,
|
BatchItemStatus,
|
||||||
FurniItem,
|
FurniItem,
|
||||||
@@ -78,6 +84,22 @@ import type {
|
|||||||
import { useFurnitureJobs } from "./use-furniture-jobs";
|
import { useFurnitureJobs } from "./use-furniture-jobs";
|
||||||
import { useStudioTranslations } from "./use-studio-translations";
|
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({
|
export function StudioClient({
|
||||||
source,
|
source,
|
||||||
initialTree,
|
initialTree,
|
||||||
@@ -1917,83 +1939,101 @@ export function StudioClient({
|
|||||||
|
|
||||||
{/* ── Detail drawer ─────────────────────────────── */}
|
{/* ── Detail drawer ─────────────────────────────── */}
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<FurnitureDetailDrawer
|
<Suspense
|
||||||
detail={detail}
|
fallback={
|
||||||
importingId={importingId}
|
<StudioPanelLoading drawer onClose={() => setDetail(null)} />
|
||||||
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 })}
|
<FurnitureDetailDrawer
|
||||||
onEdit={() => setEditingClassname(detail.classname)}
|
detail={detail}
|
||||||
onRegenerate={() => void regenNitro(detail)}
|
importingId={importingId}
|
||||||
onDelete={() => {
|
regenerating={regeneratingNitro.has(detail.classname)}
|
||||||
setDeleting(detail.classname);
|
inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
|
||||||
setConfirmDelete(true);
|
.filter(
|
||||||
}}
|
(j) =>
|
||||||
onRepair={() =>
|
j.state === "completed" ||
|
||||||
jobs.submit([detail], {
|
j.state === "cancelled" ||
|
||||||
sourceId: activeSource || undefined,
|
j.state === "interrupted",
|
||||||
translate: false,
|
)
|
||||||
mode: "repair",
|
.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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{review && (
|
{review && (
|
||||||
<ImportReview
|
<Suspense
|
||||||
items={review.items}
|
fallback={<StudioPanelLoading onClose={() => setReview(null)} />}
|
||||||
sourceId={activeSource || undefined}
|
>
|
||||||
source={
|
<ImportReview
|
||||||
sources.find((entry) => entry.id === activeSource)?.name ??
|
items={review.items}
|
||||||
source.label
|
sourceId={activeSource || undefined}
|
||||||
}
|
source={
|
||||||
translation={
|
sources.find((entry) => entry.id === activeSource)?.name ??
|
||||||
translate
|
source.label
|
||||||
? 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());
|
|
||||||
}
|
}
|
||||||
}}
|
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}
|
{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