feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -4,18 +4,24 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
export function ArticleForm({
|
||||
action,
|
||||
defaultValues,
|
||||
articleKey = "new",
|
||||
baseToken = "",
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
|
||||
edit?: boolean;
|
||||
articleKey?: string;
|
||||
baseToken?: string;
|
||||
defaultValues?: {
|
||||
title?: string;
|
||||
slug?: string;
|
||||
@@ -55,6 +61,31 @@ export function ArticleForm({
|
||||
}, [defaultValues?.publishAt]);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [token, setToken] = useState(baseToken);
|
||||
const [summary, setSummary] = useState(defaultValues?.shortStory ?? "");
|
||||
const [body, setBody] = useState(defaultValues?.fullStory ?? "");
|
||||
const [bodyVersion, setBodyVersion] = useState(0);
|
||||
function restoreDraft(draft: ArticleDraft, revision: boolean) {
|
||||
setTitle(draft.title);
|
||||
setSlug(draft.slug);
|
||||
setSlugTouched(true);
|
||||
setImage(draft.image);
|
||||
setImageError(false);
|
||||
setSummary(draft.shortStory);
|
||||
setBody(draft.fullStory);
|
||||
setBodyVersion((v) => v + 1);
|
||||
setStatus("draft");
|
||||
setPublishAt("");
|
||||
setToken(revision ? baseToken : draft.baseToken);
|
||||
markDirty();
|
||||
}
|
||||
const recovery = useArticleRecovery(
|
||||
articleKey,
|
||||
formRef,
|
||||
saving,
|
||||
dirty,
|
||||
restoreDraft,
|
||||
);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -76,12 +107,14 @@ export function ArticleForm({
|
||||
setSaveError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
return;
|
||||
}
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
if (result?.ok) await recovery.clear().catch(() => {});
|
||||
if (result?.ok && result.data?.redirectTo)
|
||||
router.push(result.data.redirectTo);
|
||||
} catch (error) {
|
||||
@@ -104,6 +137,8 @@ export function ArticleForm({
|
||||
style={{ display: "grid", gap: "1rem" }}
|
||||
>
|
||||
<fieldset disabled={pending} className="contents">
|
||||
<input type="hidden" name="baseToken" value={token} />
|
||||
{recovery.panel}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm">
|
||||
{saveError}
|
||||
@@ -193,7 +228,8 @@ export function ArticleForm({
|
||||
<textarea
|
||||
name="shortStory"
|
||||
maxLength={255}
|
||||
defaultValue={defaultValues?.shortStory ?? ""}
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
@@ -205,9 +241,10 @@ export function ArticleForm({
|
||||
{t("body")}
|
||||
</span>
|
||||
<RichText
|
||||
key={bodyVersion}
|
||||
onChange={markDirty}
|
||||
name="fullStory"
|
||||
defaultValue={defaultValues?.fullStory ?? ""}
|
||||
defaultValue={body}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={12}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
autosaveArticle,
|
||||
clearArticleRecovery,
|
||||
loadArticleRecovery,
|
||||
} from "@/actions/article-recovery";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
|
||||
export function useArticleRecovery(
|
||||
key: string,
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
saving: RefObject<boolean>,
|
||||
dirty: boolean,
|
||||
restore: (draft: ArticleDraft, revision: boolean) => void,
|
||||
) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const [recovery, setRecovery] = useState<Awaited<
|
||||
ReturnType<typeof loadArticleRecovery>
|
||||
> | null>(null);
|
||||
const [message, setMessage] = useState("autosaveLoading");
|
||||
const [reload, setReload] = useState(0);
|
||||
const version = useRef(0);
|
||||
const request = useRef<Promise<void> | null>(null);
|
||||
const last = useRef("");
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
const blocked = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
blocked.current = true;
|
||||
last.current = "";
|
||||
setMessage("autosaveLoading");
|
||||
loadArticleRecovery(key)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
version.current = data.version;
|
||||
setRecovery(data);
|
||||
blocked.current = Boolean(data.draft);
|
||||
setMessage(data.draft ? "recoveryFound" : "autosaveReady");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMessage("autosaveFailed");
|
||||
});
|
||||
const timer = setInterval(() => {
|
||||
if (
|
||||
blocked.current ||
|
||||
!dirtyRef.current ||
|
||||
saving.current ||
|
||||
request.current ||
|
||||
!form.current
|
||||
)
|
||||
return;
|
||||
const data = new FormData(form.current);
|
||||
const snapshot = JSON.stringify(Array.from(data.entries()));
|
||||
if (snapshot === last.current) return;
|
||||
setMessage("autosaveSaving");
|
||||
request.current = autosaveArticle(key, version.current, data)
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
if (!result.ok) {
|
||||
blocked.current = true;
|
||||
setMessage("autosaveConflict");
|
||||
return;
|
||||
}
|
||||
version.current = result.version;
|
||||
last.current = snapshot;
|
||||
setMessage("autosaveSaved");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMessage("autosaveFailed");
|
||||
})
|
||||
.finally(() => {
|
||||
request.current = null;
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
wait: () => request.current ?? Promise.resolve(),
|
||||
clear: () => clearArticleRecovery(key, version.current),
|
||||
panel: (
|
||||
<div className="grid gap-2 text-xs" aria-live="polite">
|
||||
<p>{t(message)}</p>
|
||||
{(message === "autosaveFailed" || message === "autosaveConflict") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={async () => {
|
||||
if (saving.current) return;
|
||||
// Reconcile server versions without replacing any current form contents.
|
||||
blocked.current = true;
|
||||
await request.current;
|
||||
setReload((current) => current + 1);
|
||||
}}
|
||||
>
|
||||
{t("retryRecovery")}
|
||||
</button>
|
||||
)}
|
||||
{recovery?.draft && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (!recovery.draft || !window.confirm(t("confirmRecovery")))
|
||||
return;
|
||||
restore(recovery.draft.payload, false);
|
||||
setRecovery({ ...recovery, draft: null });
|
||||
blocked.current = false;
|
||||
setMessage("autosaveReady");
|
||||
}}
|
||||
>
|
||||
{t("recoverDraft")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t("confirmDiscardDraft"))) return;
|
||||
try {
|
||||
await clearArticleRecovery(key, version.current);
|
||||
// Reload the version in case another tab changed it before discarding.
|
||||
const data = await loadArticleRecovery(key);
|
||||
version.current = data.version;
|
||||
setRecovery(data);
|
||||
blocked.current = Boolean(data.draft);
|
||||
setMessage(data.draft ? "autosaveConflict" : "autosaveReady");
|
||||
} catch {
|
||||
setMessage("autosaveFailed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("discardDraft")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!recovery?.revisions.length && (
|
||||
<details>
|
||||
<summary>{t("revisionHistory")}</summary>
|
||||
<ul className="grid gap-2 mt-2">
|
||||
{recovery.revisions.map((revision) => (
|
||||
<li key={revision.id} className="flex gap-2 items-center">
|
||||
<time dateTime={revision.createdAt}>
|
||||
{new Date(revision.createdAt).toLocaleString()}
|
||||
</time>
|
||||
<span>{revision.payload.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (
|
||||
blocked.current ||
|
||||
!window.confirm(t("confirmRevision"))
|
||||
)
|
||||
return;
|
||||
restore(revision.payload, true);
|
||||
}}
|
||||
disabled={Boolean(recovery.draft)}
|
||||
>
|
||||
{t("restoreRevision")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -106,7 +106,42 @@ export function DataTable<T extends { id: number | string }>({
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const bulkLock = useRef(false);
|
||||
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [columnPreference, setColumnPreference] = useState<{
|
||||
path: string;
|
||||
hidden: Set<string>;
|
||||
}>(() => ({ path: pathname, hidden: new Set() }));
|
||||
const hiddenColumns =
|
||||
columnPreference.path === pathname &&
|
||||
!columns.every((column) => columnPreference.hidden.has(column.key))
|
||||
? columnPreference.hidden
|
||||
: new Set<string>();
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(
|
||||
sessionStorage.getItem(`cms-table-columns:${pathname}`) ?? "[]",
|
||||
);
|
||||
const hidden = Array.isArray(raw)
|
||||
? raw.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
setColumnPreference({ path: pathname, hidden: new Set(hidden) });
|
||||
} catch {
|
||||
setColumnPreference({ path: pathname, hidden: new Set() });
|
||||
}
|
||||
}, [pathname]);
|
||||
function setHiddenColumns(update: (previous: Set<string>) => Set<string>) {
|
||||
const next = update(hiddenColumns);
|
||||
// Leave at least one data column available, even after a stale saved preference.
|
||||
if (columns.every((column) => next.has(column.key))) return;
|
||||
setColumnPreference({ path: pathname, hidden: next });
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`cms-table-columns:${pathname}`,
|
||||
JSON.stringify([...next]),
|
||||
);
|
||||
} catch {
|
||||
/* Storage can be disabled by the browser. */
|
||||
}
|
||||
}
|
||||
|
||||
const currentSort = searchParams.get("sort") || "";
|
||||
const currentOrder = searchParams.get("order") || "desc";
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { count, eq, ne } from "drizzle-orm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { collectOperations } from "@/lib/admin/operations-inbox";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
|
||||
export async function OperationsInbox() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const allowed = (permission: string) =>
|
||||
canAccess(permissions, permission, session.user.rank);
|
||||
const t = await getTranslations("pages.admin.operations");
|
||||
const result = await collectOperations([
|
||||
{
|
||||
source: "errors",
|
||||
allowed: allowed(PERMS.DEVOPS_VIEW),
|
||||
load: async () => {
|
||||
const { records } = await new ErrorStore().read();
|
||||
const total = new Set(
|
||||
records
|
||||
.filter((record) => !record.resolved)
|
||||
.map((record) => record.fingerprint),
|
||||
).size;
|
||||
return [
|
||||
{
|
||||
id: "unresolved",
|
||||
source: "errors",
|
||||
count: total,
|
||||
severity: 3,
|
||||
href: "/admin/devops/cms-errors?status=open",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "imports",
|
||||
allowed: allowed(PERMS.ASSETS_IMPORT),
|
||||
load: async () => {
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: Number(session.user.id),
|
||||
limit: 30,
|
||||
});
|
||||
const total = jobs.filter(
|
||||
(job) =>
|
||||
job.state === "interrupted" ||
|
||||
job.items.some((item) => item.state === "failed"),
|
||||
).length;
|
||||
return [
|
||||
{
|
||||
id: "attention",
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "tickets",
|
||||
allowed: allowed(PERMS.TICKETS_VIEW),
|
||||
load: async () => {
|
||||
const [rows, helpRows] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed")),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true)),
|
||||
]);
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
source: "tickets",
|
||||
count:
|
||||
Number(rows[0]?.total ?? 0) + Number(helpRows[0]?.total ?? 0),
|
||||
severity: 1,
|
||||
href: "/admin/tickets",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "publications",
|
||||
allowed: allowed(PERMS.NEWS_VIEW),
|
||||
load: async () => {
|
||||
const rows = await db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.status, "draft"));
|
||||
return [
|
||||
{
|
||||
id: "drafts",
|
||||
source: "publications",
|
||||
count: Number(rows[0]?.total ?? 0),
|
||||
severity: 0,
|
||||
href: "/admin/articles?status=draft",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
]);
|
||||
return (
|
||||
<section
|
||||
className="admin-card mb-6 space-y-3"
|
||||
aria-labelledby="operations-title"
|
||||
>
|
||||
<h2 id="operations-title" className="text-lg font-semibold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
{result.items.length === 0 && result.unavailable.length === 0 ? (
|
||||
<p>{t("empty")}</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{result.items.map((item) => (
|
||||
<Link
|
||||
key={`${item.source}:${item.id}`}
|
||||
href={item.href}
|
||||
className="rounded-lg border border-[var(--admin-border)] p-3 focus-visible:outline-2"
|
||||
>
|
||||
<span>{t(item.source)}</span>
|
||||
<strong className="float-right">{item.count}</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{result.unavailable.map((source) => (
|
||||
<p
|
||||
key={source}
|
||||
role="status"
|
||||
className="text-sm text-[var(--admin-warning)]"
|
||||
>
|
||||
{t("unavailable", { source: t(source) })}
|
||||
</p>
|
||||
))}
|
||||
{allowed(PERMS.DEVOPS_VIEW) ? (
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{t("installation")}
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user