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
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user