feat(cms): improve catalog, editorial recovery and operations
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8137 -703

No files matched your search

@@ -0,0 +1,51 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import { FurnitureCompleteness } from "./furniture-completeness";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
const empty: FurnitureInspection = {
classname: "chair",
sql: [],
catalog: [],
furnidata: [],
furnidataReadable: true,
nitro: { exists: false, bytes: 0 },
icon: { exists: false, bytes: 0 },
};
describe("completeness panel", () => {
it("renders five separately named parts with useful missing-data guidance", () => {
const html = renderToStaticMarkup(<FurnitureCompleteness local={empty} />);
for (const part of ["sql", "catalog", "furnidata", "icon", "nitro"]) {
expect(html).toContain(`parts.${part}`);
expect(html).toContain(`actions.${part}`);
}
expect(html).not.toContain('href="/admin/studio/audit"');
});
it("gives conflicts an accessible action to open the real audit page", () => {
const local = {
...empty,
sql: [
{ id: 1, spriteId: 1, type: "s", name: "Chair" },
{ id: 2, spriteId: 2, type: "s", name: "Chair" },
],
};
const html = renderToStaticMarkup(<FurnitureCompleteness local={local} />);
expect(html).toContain('role="alert"');
expect(html).toContain('href="/admin/studio/audit"');
expect(html).toContain("actions.audit");
});
it("renders unavailable checks as unverified with a recheck instruction", () => {
const html = renderToStaticMarkup(
<FurnitureCompleteness
local={{
...empty,
furnidataReadable: false,
icon: { exists: null, bytes: 0 },
}}
/>,
);
expect(html.match(/statuses.unknown/g)).toHaveLength(2);
expect(html.match(/actions.recheck/g)).toHaveLength(2);
});
});
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { furnitureCompleteness } from "@/lib/furni/studio-completeness";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
export function FurnitureCompleteness({
local,
}: {
local: FurnitureInspection;
}) {
const t = useTranslations("admin.studio.completeness");
const parts = furnitureCompleteness(local);
return (
<section aria-label={t("title")} className="space-y-3">
<h3 className="font-semibold">{t("title")}</h3>
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{parts.map((part) => (
<div
key={part.id}
className="rounded-md border border-[var(--admin-border)] p-3"
>
<dt className="text-xs text-[var(--admin-text-muted)]">
{t(`parts.${part.id}`)}
</dt>
<dd className="mt-1 space-y-1">
<p
className={
part.status === "present"
? "font-medium text-[var(--admin-success)]"
: part.status === "unknown"
? "font-medium text-[var(--admin-text-muted)]"
: "font-medium text-[var(--admin-warning)]"
}
>
{t(`statuses.${part.status}`)}
</p>
{part.action && (
<p className="text-xs text-[var(--admin-text-muted)]">
{t(`actions.${part.action}`)}
</p>
)}
</dd>
</div>
))}
</dl>
{parts.some((part) => part.status === "conflict") && (
<p role="alert" className="text-[var(--admin-warning)]">
<Link
href="/admin/studio/audit"
className="font-medium underline underline-offset-4"
>
{t("openAudit")}
</Link>
</p>
)}
</section>
);
}
@@ -0,0 +1,216 @@
"use client";
import {
Check,
CloudDownload,
Loader2,
Pencil,
RefreshCw,
Sparkles,
Trash2,
WandSparkles,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps {
detail: FurniItem;
importingId: string | null;
regenerating: boolean;
busy: boolean;
inspectionKey: string;
onClose: () => void;
onImport: () => void;
onEdit: () => void;
onDelete: () => void;
onRegenerate: () => void;
onRepair: () => Promise<unknown>;
}
export function FurnitureDetailDrawer({
detail,
importingId,
regenerating,
busy,
inspectionKey,
onClose,
onImport,
onEdit,
onDelete,
onRegenerate,
onRepair,
}: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness");
const detailPreview = previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
);
return (
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
</span>
<button
type="button"
aria-label="Close furniture details"
onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={14} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => handleImgError(e, detail)}
/>
</div>
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
{detail.name}
</h2>
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
{detail.classname}
</p>
{detail.description ? (
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
{detail.description}
</p>
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
<FurnitureInspector
key={inspectionKey}
item={detail}
busy={busy}
onRepair={onRepair}
/>
{/* Auto-catalog preview */}
{detailPreview && (
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles size={12} className="text-[var(--admin-accent)]" />
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Category</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Price</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
</div>
)}
</div>
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="space-y-2">
{!detail.alreadyImported ? (
<Button
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={onImport}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is missing.
</p>
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
disabled={regenerating}
onClick={onRegenerate}
>
{regenerating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
</Button>
</>
)}
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={onEdit}
disabled={!detail.nitroExists}
>
<Pencil size={14} />
Edit nitro
</Button>
</div>
)}
{detail.alreadyImported && (
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
onClick={onDelete}
>
<Trash2 size={14} />
Delete imported furni
</Button>
)}
</div>
</div>
</aside>
);
}
@@ -5,11 +5,10 @@ import {
type FurnitureInspection,
furnitureHealth,
} from "@/lib/furni/studio-inspection";
import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
const status = (value: boolean | null | undefined) =>
value == null ? "Not verified" : value ? "Present" : "Missing";
export function FurnitureComparison({
item,
local,
@@ -20,42 +19,7 @@ export function FurnitureComparison({
const comparison = compareFurniture(item, local);
return (
<div className="space-y-3 text-sm">
<dl className="grid grid-cols-2 gap-2">
{(
[
[".nitro", local.nitro.exists],
["Local icon", local.icon.exists],
[
"Furnidata",
local.furnidataReadable ? local.furnidata.length > 0 : null,
],
["SQL item", local.sql.length > 0],
["Catalog offer", local.catalog.length > 0],
] as const
).map(([label, present]) => (
<div
key={label}
className="rounded-md border border-[var(--admin-border)] p-2"
>
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
<dd
className={
present
? "text-[var(--admin-success)]"
: "text-[var(--admin-warning)]"
}
>
{status(present)}
</dd>
</div>
))}
</dl>
{comparison.state === "conflict" && (
<p role="alert" className="text-[var(--admin-warning)]">
Multiple local records use this classname. Review them in Catalog
audit before importing.
</p>
)}
<FurnitureCompleteness local={local} />
{!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified.
+51 -2
View File
@@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
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 type { useFurnitureJobs } from "./use-furniture-jobs";
@@ -10,6 +12,7 @@ export function FurnitureJobHistory({
}) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
const [limits, setLimits] = useState<Record<string, number>>({});
return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer">
@@ -21,6 +24,12 @@ export function FurnitureJobHistory({
</summary>
<div className="max-h-72 space-y-2 overflow-auto py-2">
{state.error && <p role="alert">{state.error}</p>}
<p className="text-xs text-[var(--admin-text-muted)]">
{t("historyLimit")}
</p>
<Button variant="outline" onClick={() => void state.refresh()}>
{t("refreshHistory")}
</Button>
{!state.jobs.length && <p>{t("empty")}</p>}
{state.jobs.map((job) => {
const retryItems = retryableJobItems(job);
@@ -50,7 +59,7 @@ export function FurnitureJobHistory({
aria-label={t("progress")}
/>
<ul className="space-y-1 py-2">
{job.items.map((item) => (
{job.items.slice(0, limits[job.id] ?? 50).map((item) => (
<li key={item.classname}>
<strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""}
@@ -70,6 +79,46 @@ export function FurnitureJobHistory({
</li>
))}
</ul>
{job.items.length > (limits[job.id] ?? 50) && (
<Button
variant="outline"
onClick={() =>
setLimits((current) => ({
...current,
[job.id]: (current[job.id] ?? 50) + 50,
}))
}
>
{t("showMoreItems")}
</Button>
)}
{(job.state === "queued" || job.state === "running") && (
<div className="space-y-2 py-2">
<p className="text-xs text-[var(--admin-text-muted)]">
{t(job.cancelRequested ? "cancelRequested" : "cancelHint")}
</p>
<Button
variant="outline"
disabled={state.cancelling !== null || job.cancelRequested}
onClick={() => void state.cancel(job.id)}
>
{t("cancelRemaining")}
</Button>
</div>
)}
{job.items.some(
(item) =>
item.state === "interrupted" ||
item.error?.startsWith("Server restarted during import.") ||
(item.state === "running" && job.state === "interrupted"),
) && (
<p role="alert" className="py-2 text-[var(--admin-warning)]">
{t("uncertainOutcome")}{" "}
<Link href="/admin/studio/audit" className="underline">
{t("reviewLocalData")}
</Link>
</p>
)}
{retryItems.length > 0 && (
<Button
variant="outline"
@@ -84,7 +133,7 @@ export function FurnitureJobHistory({
}
>
{t(
job.state === "interrupted"
job.state === "interrupted" || job.state === "cancelled"
? "retryUnfinished"
: "retryFailed",
)}
@@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -36,6 +38,7 @@ export function ImportReview({
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null);
async function attach(item: FurniItem, file: File) {
@@ -186,6 +189,22 @@ export function ImportReview({
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} source file warnings
</p>
{blocked > 0 && (
<div
role="alert"
className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-3 text-sm"
>
<p>
{completenessT("excludedConflicts", { count: blocked })}
</p>
<Link
href="/admin/studio/audit"
className="font-medium underline underline-offset-4"
>
{completenessT("openAudit")}
</Link>
</div>
)}
{inspection.items.some(
(item) =>
!item.furnidataReadable ||
+38 -198
View File
@@ -20,7 +20,6 @@ import {
Search,
Sparkles,
Trash2,
WandSparkles,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -53,10 +52,6 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { adminFetch } from "@/lib/admin-fetch";
import {
type AutoCatalogPreview,
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
@@ -64,7 +59,7 @@ import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
import { FurnitureInspector } from "./furniture-inspector";
import { FurnitureDetailDrawer } from "./furniture-detail-drawer";
import { FurnitureJobHistory } from "./furniture-jobs";
import { ImportReview } from "./import-review";
import { filterFurniture } from "./studio-filters";
@@ -883,14 +878,6 @@ export function StudioClient({
}
}
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
if (!detail) return null;
return previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
);
}, [detail]);
const selectedNonImported = useMemo(
() =>
filteredItems.filter(
@@ -1930,190 +1917,43 @@ export function StudioClient({
{/* ── Detail drawer ─────────────────────────────── */}
{detail ? (
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
</span>
<button
type="button"
aria-label="Close furniture details"
onClick={() => setDetail(null)}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={14} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => handleImgError(e, detail)}
/>
</div>
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
{detail.name}
</h2>
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
{detail.classname}
</p>
{detail.description ? (
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
{detail.description}
</p>
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> In catalog
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
<FurnitureInspector
key={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
.filter((j) => j.state === "completed")
.map((j) => j.id)
.join(",")}`}
item={detail}
busy={
jobs.busy ||
jobs.jobs.some(
(j) =>
(j.state === "queued" || j.state === "running") &&
j.items.some((i) => i.classname === detail.classname),
)
}
onRepair={() =>
jobs.submit([detail], {
sourceId: activeSource || undefined,
translate: false,
mode: "repair",
})
}
/>
{/* Auto-catalog preview */}
{detailPreview && (
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles
size={12}
className="text-[var(--admin-accent)]"
/>
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
Category
</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
Price
</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
</div>
)}
</div>
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="space-y-2">
{!detail.alreadyImported ? (
<Button
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() => setReview({ items: [detail], single: true })}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is
missing.
</p>
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
disabled={regeneratingNitro.has(detail.classname)}
onClick={() => regenNitro(detail)}
>
{regeneratingNitro.has(detail.classname) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
</Button>
</>
)}
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={() => setEditingClassname(detail.classname)}
>
<Pencil size={14} />
Edit nitro
</Button>
</div>
)}
{detail.alreadyImported && (
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
onClick={() => {
setDeleting(detail.classname);
setConfirmDelete(true);
}}
>
<Trash2 size={14} />
Delete imported furni
</Button>
)}
</div>
</div>
</aside>
<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",
})
}
/>
) : null}
</div>
@@ -1,10 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, 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";
export function useFurnitureJobs(onComplete: () => void) {
const t = useTranslations("pages.admin.importHistory");
const [cancelling, setCancelling] = useState<string | null>(null);
const cancelInFlight = useRef(false);
const [jobs, setJobs] = useState<ImportJob[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
@@ -29,7 +33,9 @@ export function useFurnitureJobs(onComplete: () => void) {
let changed = false;
for (const job of data.jobs as ImportJob[])
if (
(job.state === "completed" || job.state === "interrupted") &&
(job.state === "completed" ||
job.state === "interrupted" ||
job.state === "cancelled") &&
!completed.current.has(job.id)
) {
completed.current.add(job.id);
@@ -44,7 +50,33 @@ export function useFurnitureJobs(onComplete: () => void) {
refreshing.current = false;
}
}, []);
useVisiblePolling(refresh, 5000);
const active = jobs.some(
(job) => job.state === "queued" || job.state === "running",
);
useVisiblePolling(refresh, active ? 5000 : 30000);
const cancel = async (id: string) => {
if (cancelInFlight.current) return;
cancelInFlight.current = true;
setCancelling(id);
try {
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "PATCH",
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)),
);
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
} finally {
cancelInFlight.current = false;
setCancelling(null);
}
};
const submit = async (
items: ImportJobItem[],
options: {
@@ -88,5 +120,5 @@ export function useFurnitureJobs(onComplete: () => void) {
setBusy(false);
}
};
return { jobs, busy, error, submit, refresh };
return { jobs, busy, error, submit, refresh, cancel, cancelling };
}