feat(cms): recover drafts and failed imports with verified UI workflows
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+29 -1
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useFormRecovery } from "@/hooks/use-form-recovery";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input";
@@ -17,11 +18,13 @@ import { RichText } from "./rich-text";
export function ArticleForm({
action,
userId,
defaultValues,
articleKey = "new",
baseToken = "",
}: {
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
userId?: string;
edit?: boolean;
articleKey?: string;
baseToken?: string;
@@ -45,6 +48,7 @@ export function ArticleForm({
const saving = useRef(false);
const editVersion = useRef(0);
function markDirty() {
localRecovery.changed();
editVersion.current++;
setDirty(true);
}
@@ -90,6 +94,24 @@ export function ArticleForm({
dirty,
restoreDraft,
);
const localRecovery = useFormRecovery({
serverDraft: recovery.draft,
userId,
kind: "article",
record: articleKey,
dirty,
snapshot: () => ({
title,
slug: slugTouched ? slug : slugify(title),
image,
shortStory: summary,
fullStory: body,
status,
publishAt,
}),
restore: (payload) =>
restoreDraft({ ...payload, baseToken } as ArticleDraft, false),
});
useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -126,6 +148,7 @@ export function ArticleForm({
if (!Number.isNaN(date.getTime()))
data.set("publishAt", date.toISOString());
}
localRecovery.flush();
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(null);
@@ -139,7 +162,10 @@ export function ArticleForm({
setFieldErrors(result.fieldErrors ?? {});
return;
}
if (editVersion.current === submittedVersion) setDirty(false);
if (result?.ok && editVersion.current === submittedVersion) {
setDirty(false);
localRecovery.clear();
}
if (result?.ok) await recovery.clear().catch(() => {});
if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo);
@@ -164,6 +190,7 @@ export function ArticleForm({
>
<fieldset disabled={pending} className="contents">
<input type="hidden" name="baseToken" value={token} />
{localRecovery.panel}
{recovery.panel}
{saveError && (
<p role="alert" className="text-sm">
@@ -282,6 +309,7 @@ export function ArticleForm({
{t("body")}
</span>
<RichText
ariaLabel={t("body")}
key={bodyVersion}
onChange={() => {
markDirty();
@@ -82,6 +82,7 @@ export function useArticleRecovery(
};
}, [key, form, saving, reload]);
return {
draft: recovery?.draft?.payload,
wait: () => request.current ?? Promise.resolve(),
clear: () => clearArticleRecovery(key, version.current),
panel: (
+8 -2
View File
@@ -395,7 +395,10 @@ export function DataTable<T extends { id: number | string }>({
value={currentValue}
onValueChange={(v) => handleFilterChange(col, v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger
aria-label={col.label}
className="h-8 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -479,7 +482,10 @@ export function DataTable<T extends { id: number | string }>({
value={String(data.perPage)}
onValueChange={handlePerPageChange}
>
<SelectTrigger className="w-[100px]">
<SelectTrigger
aria-label={t("perPage", { count: data.perPage })}
className="w-[100px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
+5 -1
View File
@@ -56,12 +56,14 @@ export function RichText({
name,
defaultValue = "",
placeholder,
ariaLabel,
rows = 10,
onChange,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
ariaLabel?: string;
rows?: number;
onChange?: () => void;
}) {
@@ -83,6 +85,7 @@ export function RichText({
.init({
selector,
menubar: false,
iframe_attrs: { title: ariaLabel || name },
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
@@ -127,13 +130,14 @@ export function RichText({
/* noop */
}
};
}, [editorId, rows]);
}, [editorId, rows, ariaLabel, name]);
return (
<textarea
id={editorId}
ref={textareaRef}
name={name}
aria-label={ariaLabel || name}
defaultValue={defaultValue}
placeholder={placeholder}
rows={rows}
@@ -0,0 +1,266 @@
import { onlineManager, QueryObserver } from "@tanstack/react-query";
import { delay, HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import {
furnitureCursorFixture,
furnitureJobFixture,
} from "@/test/furniture-jobs-fixtures";
import {
createFurnitureJobsClient,
furnitureJobsQueryOptions,
readFurnitureJobResponse,
} from "./furniture-jobs-query";
const endpoint = "http://localhost/api/admin/studio/import-jobs";
const server = setupServer();
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
function client() {
const value = createFurnitureJobsClient();
clients.push(value);
return value;
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(async () => {
await Promise.all(
clients.map(async (value) => {
await value.cancelQueries();
value.clear();
}),
);
clients.length = 0;
server.resetHandlers();
});
afterAll(() => server.close());
describe("furniture history HTTP query", () => {
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
let calls = 0;
let release: (() => void) | undefined;
server.use(
http.get(endpoint, async () => {
calls++;
await new Promise<void>((resolve) => {
release = resolve;
});
return HttpResponse.json({
ok: true,
jobs: [furnitureJobFixture],
nextCursor: furnitureCursorFixture,
});
}),
);
const cache = client();
const options = furnitureJobsQueryOptions(true, null);
const first = cache.fetchQuery(options);
const second = cache.fetchQuery(options);
await vi.waitFor(() => expect(calls).toBe(1));
release?.();
const [a, b] = await Promise.all([first, second]);
expect(a).toEqual(b);
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
expect(cache.getQueryData(options.queryKey)).toEqual(a);
});
it("keeps different pages and mounted history clients isolated", async () => {
const urls: string[] = [];
server.use(
http.get(endpoint, ({ request }) => {
urls.push(request.url);
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
}),
);
const first = client(),
second = client();
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
await first.fetchQuery(
furnitureJobsQueryOptions(true, furnitureCursorFixture),
);
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
expect(urls).toHaveLength(3);
expect(new URL(urls[1]).searchParams.get("before")).toBe(
furnitureCursorFixture,
);
first.clear();
expect(
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
).toEqual({ jobs: [], nextCursor: null });
});
it.each([403, 500])(
"surfaces HTTP %i without automatic retries or a false empty result",
async (status) => {
let calls = 0;
server.use(
http.get(endpoint, () => {
calls++;
return HttpResponse.json(
{ error: "History unavailable" },
{ status },
);
}),
);
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
status,
message: "History unavailable",
});
expect(calls).toBe(1);
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
},
);
it.each([
{ ok: true, jobs: null },
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
{
ok: true,
jobs: [
{
...furnitureJobFixture,
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
},
],
},
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
{ ok: true, jobs: [], nextCursor: "../other-account" },
{
ok: true,
jobs: [],
nextCursor:
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
},
])("rejects malformed successful payloads", async (payload) => {
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
await expect(cache.fetchQuery(options)).rejects.toThrow(
"Invalid import history response",
);
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
});
it("reports invalid JSON as a transport failure", async () => {
server.use(
http.get(
endpoint,
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
),
);
await expect(
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
).rejects.toThrow("Invalid import history response");
});
it("times out a stalled HTTP request without retries", async () => {
let calls = 0;
server.use(
http.get(endpoint, async () => {
calls++;
await delay(100);
return HttpResponse.json({ ok: true, jobs: [] });
}),
);
await expect(
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
).rejects.toThrow("Import history request timed out");
expect(calls).toBe(1);
});
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
let entered = false;
let transportAborted = false;
server.use(
http.get(endpoint, async ({ request }) => {
request.signal.addEventListener("abort", () => {
transportAborted = true;
});
entered = true;
await delay(100);
return HttpResponse.json({ ok: true, jobs: [] });
}),
);
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
const pending = cache.fetchQuery(options);
const rejection = expect(pending).rejects.toThrow();
await vi.waitFor(() => expect(entered).toBe(true));
await cache.cancelQueries({ queryKey: options.queryKey });
await rejection;
await vi.waitFor(() => expect(transportAborted).toBe(true));
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
});
});
describe("history refresh and mutation response integrity", () => {
it("retains the last valid page while a refresh fails", async () => {
server.use(
http.get(endpoint, () =>
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
),
);
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
const observer = new QueryObserver(cache, { ...options, enabled: false });
const unsubscribe = observer.subscribe(() => {});
try {
await cache.fetchQuery(options);
server.use(
http.get(endpoint, () =>
HttpResponse.json({ error: "offline" }, { status: 500 }),
),
);
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
furnitureJobFixture.id,
);
expect(observer.getCurrentResult().error?.message).toBe("offline");
} finally {
unsubscribe();
}
});
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
server.use(
http.patch(endpoint, () =>
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
),
);
const response = await fetch(endpoint, { method: "PATCH" });
await expect(
readFurnitureJobResponse(response, "Update failed"),
).rejects.toThrow("Invalid import job response");
});
it("reports mutation HTTP failure without issuing a second write", async () => {
let calls = 0;
server.use(
http.post(endpoint, () => {
calls++;
return HttpResponse.json(
{ error: "Queue unavailable" },
{ status: 500 },
);
}),
);
const response = await fetch(endpoint, { method: "POST" });
await expect(
readFurnitureJobResponse(response, "Queue failed"),
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
expect(calls).toBe(1);
});
});
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
server.use(
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
);
onlineManager.setOnline(false);
try {
await expect(
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
).resolves.toEqual({ jobs: [], nextCursor: null });
} finally {
onlineManager.setOnline(true);
}
});
@@ -0,0 +1,164 @@
import { QueryClient, queryOptions } from "@tanstack/react-query";
import { z } 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([
"pending",
"running",
"done",
"failed",
"cancelled",
"interrupted",
]),
error: z.optional(z.string()),
warnings: z.optional(z.array(z.string())),
itemId: z.optional(z.number()),
}),
),
});
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 [timestamp, id] = value.split("|");
return (
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
z.uuid().safeParse(id).success
);
}),
),
),
),
});
export interface FurnitureJobsPage {
jobs: ImportJob[];
nextCursor: string | null;
}
export class FurnitureJobsHttpError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function responseData(
response: Response,
fallback: string,
): Promise<unknown> {
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message =
data &&
typeof data === "object" &&
"error" in data &&
typeof data.error === "string"
? data.error
: `${fallback} (${response.status})`;
throw new FurnitureJobsHttpError(message, response.status);
}
return data;
}
export async function readFurnitureJobResponse(
response: Response,
fallback: string,
): Promise<ImportJob> {
const data = await responseData(response, fallback);
const parsed = z
.object({ ok: z.literal(true), job: jobSchema })
.safeParse(data);
if (!parsed.success) throw Error("Invalid import job response");
return parsed.data.job as ImportJob;
}
/** Each mounted history owns this client; no persisted or cross-page account cache. */
export function createFurnitureJobsClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
networkMode: "always",
staleTime: 0,
gcTime: 0,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
mutations: { retry: false },
},
});
}
export function furnitureJobsQueryOptions(
paginated: boolean,
cursor: string | null,
timeoutMs = historyTimeoutMs,
) {
return queryOptions({
queryKey: [
"furniture-import-history",
paginated,
paginated ? cursor : null,
] as const,
queryFn: async ({ signal }): Promise<FurnitureJobsPage> => {
const timeout = AbortSignal.timeout(timeoutMs);
const combined = AbortSignal.any([signal, timeout]);
const url = new URL(
endpoint,
typeof window === "undefined"
? "http://localhost"
: window.location.origin,
);
if (paginated) {
url.searchParams.set("page", "1");
if (cursor) url.searchParams.set("before", cursor);
}
try {
const response = await adminFetch(url, {
signal: combined,
cache: "no-store",
});
const data = await responseData(response, "Import history unavailable");
const parsed = historySchema.safeParse(data);
if (!parsed.success) throw Error("Invalid import history response");
return {
jobs: parsed.data.jobs as ImportJob[],
nextCursor: parsed.data.nextCursor ?? null,
};
} catch (error) {
if (timeout.aborted && !signal.aborted)
throw Error("Import history request timed out");
throw error;
}
},
});
}
@@ -4,6 +4,7 @@ import { useFormatter, useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
import { ImportAttachmentRecovery } from "./import-attachment-recovery";
import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({
state,
@@ -98,6 +99,18 @@ export function FurnitureJobHistory({
{item.error}
</p>
)}
{!job.syncKind &&
item.state === "failed" &&
retryItems.some(
(retryItem) => retryItem.classname === item.classname,
) && (
<ImportAttachmentRecovery
jobId={job.id}
classname={item.classname}
disabled={state.busy}
onResumed={state.refresh}
/>
)}
{[...new Set(item.warnings ?? [])].map((warning) => (
<p
key={warning}
@@ -0,0 +1,128 @@
"use client";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
export function ImportAttachmentRecovery({
jobId,
classname,
disabled,
onResumed,
}: {
jobId: string;
classname: string;
disabled: boolean;
onResumed: () => Promise<unknown>;
}) {
const t = useTranslations("admin.studio.importDiagnostics");
const lock = useRef(false);
const [busy, setBusy] = useState(false);
const [attachmentId, setAttachmentId] = useState("");
const [error, setError] = useState("");
async function attach(file: File) {
if (disabled || lock.current) return;
lock.current = true;
setBusy(true);
setError("");
setAttachmentId("");
try {
const form = new FormData();
form.set("classname", classname);
form.set("file", file);
const response = await adminFetch("/api/admin/studio/import-attachment", {
method: "POST",
body: form,
});
const data = await response.json();
if (!response.ok || typeof data.attachmentId !== "string") {
setError(
t(data.code === "mismatchedFile" ? "mismatchedFile" : "invalidFile"),
);
return;
}
setAttachmentId(data.attachmentId);
} catch {
setError(t("network"));
} finally {
lock.current = false;
setBusy(false);
}
}
async function resume() {
if (disabled || lock.current || !attachmentId) return;
lock.current = true;
setBusy(true);
setError("");
try {
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: jobId,
action: "retry",
attachments: { [classname]: attachmentId },
}),
});
if (!response.ok) {
const data = await response.json();
const code =
data.code === "alreadyStarted"
? "alreadyStarted"
: data.code === "notEligible"
? "notEligible"
: data.code === "invalidFile"
? "invalidFile"
: "resumeError";
setError(t(code));
return;
}
setAttachmentId("");
await onResumed();
} catch {
setError(t("network"));
} finally {
lock.current = false;
setBusy(false);
}
}
return (
<details className="my-2 rounded border border-[var(--admin-border)] p-2">
<summary className="cursor-pointer">{t("recover")}</summary>
<p className="my-2 text-xs text-[var(--admin-text-muted)]">
{t("recoveryHint", { file: `${classname.split("*")[0]}.nitro` })}
</p>
<label className="block text-xs">
{t("attach")}
<input
type="file"
accept=".nitro"
className="my-2 block max-w-full"
disabled={disabled || busy}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void attach(file);
event.target.value = "";
}}
/>
</label>
{busy && <p role="status">{t("working")}</p>}
{attachmentId && (
<>
<p role="status">{t("attached")}</p>
<Button
variant="outline"
disabled={disabled || busy}
onClick={() => void resume()}
>
{t("resume")}
</Button>
</>
)}
{error && (
<p role="alert" className="text-[var(--admin-warning)]">
{error}
</p>
)}
</details>
);
}
+14 -3
View File
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import { FurnitureComparison } from "./furniture-inspector";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
import { useSourceAssetChecks } from "./use-source-asset-checks";
@@ -38,6 +39,7 @@ export function ImportReview({
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null);
@@ -57,7 +59,7 @@ export function ImportReview({
...current,
[item.classname]: data.attachmentId,
}));
toast.success("Matching .nitro attached to this import");
toast.success(diagnosticsT("attached"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
@@ -260,8 +262,8 @@ export function ImportReview({
{local.nitro.exists !== true && (
<label className="mt-3 block text-sm">
{attachments[item.classname]
? "Matching .nitro attached"
: "Attach the original .nitro"}
? diagnosticsT("attached")
: diagnosticsT("attach")}
<input
className="mt-2 block max-w-full"
type="file"
@@ -278,6 +280,15 @@ export function ImportReview({
)}
</label>
)}
{assets.items.find(
(check) => check.classname === item.classname,
) && (
<ImportSourceDiagnostics
check={assets.items.find(
(check) => check.classname === item.classname,
)}
/>
)}
<FurnitureComparison item={item} local={local} />
</div>
)}
@@ -0,0 +1,54 @@
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, args?: Record<string, unknown>) =>
`${key}${args?.status ?? ""}`,
}));
it("shows filenames, attempted addresses and distinct reasons inline", () => {
const html = renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
attempts: [
{
file: "chair.swf",
url: "https://source.test/1/chair.swf",
state: "missing",
reason: "http",
status: 404,
},
{
file: "chair.nitro",
url: "https://source.test/chair.nitro",
state: "unknown",
reason: "timeout",
},
],
}}
/>,
);
expect(html).toContain("chair.swf");
expect(html).toContain("http404");
expect(html).toContain("timeout");
expect(html).toContain("https://source.test/1/chair.swf");
expect(html).not.toContain("href=");
});
it("does not invent diagnostics for earlier responses", () => {
expect(
renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
}}
/>,
),
).toBe("");
});
@@ -0,0 +1,32 @@
"use client";
import { useTranslations } from "next-intl";
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
export function ImportSourceDiagnostics({
check,
}: {
check?: SourceAssetCheck;
}) {
const t = useTranslations("admin.studio.importDiagnostics");
if (!check?.attempts?.length) return null;
return (
<details className="mt-2 text-xs">
<summary className="cursor-pointer">{t("attempts")}</summary>
<p className="mt-2 text-[var(--admin-text-muted)]">{t("preliminary")}</p>
<ul className="space-y-2 py-2">
{[
...new Map(
check.attempts.map((attempt) => [attempt.url, attempt]),
).values(),
].map((attempt) => (
<li key={attempt.url}>
<strong>{attempt.file}</strong> ·{" "}
{attempt.reason === "http"
? t("http", { status: attempt.status ?? 0 })
: t(attempt.reason)}
<code className="block break-all">{attempt.url}</code>
</li>
))}
</ul>
</details>
);
}
@@ -1,76 +1,87 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
import type { ImportJobItem } from "@/lib/furni/import-job";
import {
createFurnitureJobsClient,
FurnitureJobsHttpError,
type FurnitureJobsPage,
furnitureJobsQueryOptions,
readFurnitureJobResponse,
} from "./furniture-jobs-query";
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const t = useTranslations("pages.admin.importHistory");
const [client] = useState(createFurnitureJobsClient);
const cursor = useRef<string | null>(null);
const previous = useRef<Array<string | null>>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageCursor, setPageCursor] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const query = useQuery(
{ ...furnitureJobsQueryOptions(paginated, pageCursor), enabled: false },
client,
);
const unauthorized =
query.error instanceof FurnitureJobsHttpError &&
[401, 403].includes(query.error.status);
const jobs = unauthorized ? [] : (query.data?.jobs ?? []);
const nextCursor = unauthorized ? null : (query.data?.nextCursor ?? null);
const loading = query.isFetching;
const error = query.error?.message ?? "";
const [cancelling, setCancelling] = useState<string | null>(null);
const cancelInFlight = useRef(false);
const [jobs, setJobs] = useState<ImportJob[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const completed = useRef(new Set<string>()),
callback = useRef(onComplete);
const [busy, setBusy] = useState(false);
const completed = useRef(new Set<string>());
const callback = useRef(onComplete);
callback.current = onComplete;
const request = useRef<{ payload: string; id: string } | null>(null);
const submitting = useRef(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
setLoading(true);
try {
const response = await adminFetch(
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
);
const data = await response.json();
if (!response.ok)
throw Error(
data.error || `Import history unavailable (${response.status})`,
);
setJobs(data.jobs);
setNextCursor(data.nextCursor ?? null);
setError("");
let changed = false;
for (const job of data.jobs as ImportJob[])
if (
(job.state === "completed" ||
job.state === "interrupted" ||
job.state === "cancelled") &&
!completed.current.has(job.id)
) {
completed.current.add(job.id);
changed = true;
}
if (changed) callback.current();
} catch (error) {
setError(
error instanceof Error ? error.message : "Cannot load import history",
);
} finally {
refreshing.current = false;
setLoading(false);
useEffect(
() => () => {
client.clear();
},
[client],
);
useEffect(() => {
if (!query.data || unauthorized) return;
let changed = false;
for (const job of query.data.jobs) {
if (
["completed", "interrupted", "cancelled"].includes(job.state) &&
!completed.current.has(job.id)
) {
completed.current.add(job.id);
changed = true;
}
}
}, [paginated]);
if (changed) callback.current();
}, [query.data, unauthorized]);
const refresh = useCallback(async () => {
// fetchQuery shares an in-flight GET. Query owns data, errors and loading.
await client
.fetchQuery(furnitureJobsQueryOptions(paginated, cursor.current))
.catch(() => undefined);
}, [client, paginated]);
const resetPage = () => {
cursor.current = null;
previous.current = [];
setPageCursor(null);
setPage(1);
};
const navigate = async (direction: "next" | "previous") => {
if (refreshing.current) return;
if (client.isFetching()) return;
if (direction === "next" && nextCursor) {
previous.current.push(cursor.current);
cursor.current = nextCursor;
} else if (direction === "previous" && previous.current.length)
cursor.current = previous.current.pop() ?? null;
else return;
setPageCursor(cursor.current);
setPage(previous.current.length + 1);
setJobs([]);
setNextCursor(null);
await refresh();
};
const retry = async (id: string) => {
@@ -83,11 +94,9 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, action: "retry" }),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("cancelFailed"));
cursor.current = null;
previous.current = [];
setPage(1);
await readFurnitureJobResponse(response, t("cancelFailed"));
await client.cancelQueries();
resetPage();
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
@@ -99,6 +108,7 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const active = jobs.some(
(job) => job.state === "queued" || job.state === "running",
);
// Retain settle-then-poll timing and visibility behavior without global focus state.
useVisiblePolling(refresh, active ? 5000 : 30000);
const cancel = async (id: string) => {
if (cancelInFlight.current) return;
@@ -110,10 +120,17 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("cancelFailed"));
setJobs((current) =>
current.map((job) => (job.id === id ? data.job : job)),
const job = await readFurnitureJobResponse(response, t("cancelFailed"));
await client.cancelQueries();
client.setQueriesData<FurnitureJobsPage>(
{ queryKey: ["furniture-import-history"] },
(current) =>
current
? {
...current,
jobs: current.jobs.map((item) => (item.id === id ? job : item)),
}
: current,
);
await refresh();
} catch (error) {
@@ -145,12 +162,10 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, id: request.current.id }),
});
const data = await response.json();
if (!response.ok)
throw Error(
data.error || `Could not queue import (${response.status})`,
);
await readFurnitureJobResponse(response, "Could not queue import");
request.current = null;
await client.cancelQueries();
resetPage();
await refresh();
toast.success(
"Import queued. You can leave this page and return to its history.",