From bf126fd8255730342a0861346767bd55ad800c82 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 12:40:50 +0200 Subject: [PATCH] Improve Catalog Studio navigation and import workflow --- src/app/admin/studio/layout.tsx | 129 ++++- .../admin/studio/batch-progress.tsx | 369 ++++++------- src/components/admin/studio/catalog-rail.tsx | 174 +++--- src/components/admin/studio/studio-client.tsx | 507 ++++++++++++------ 4 files changed, 687 insertions(+), 492 deletions(-) diff --git a/src/app/admin/studio/layout.tsx b/src/app/admin/studio/layout.tsx index 3fb89c0d..09b6bd74 100644 --- a/src/app/admin/studio/layout.tsx +++ b/src/app/admin/studio/layout.tsx @@ -3,6 +3,7 @@ import { BadgeCheck, Cat, + ChevronDown, ClipboardCheck, Copy, Dumbbell, @@ -15,30 +16,93 @@ import { Wrench, } from "lucide-react"; import { usePathname } from "next/navigation"; +import { useState } from "react"; import Link from "@/components/link"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { cn } from "@/lib/utils"; -const TABS: { href: string; label: string; icon: LucideIcon }[] = [ - { href: "/admin/studio/furni", label: "Furni", icon: Puzzle }, - { href: "/admin/studio/upload", label: "Upload", icon: Upload }, +type StudioLink = { href: string; label: string; icon: LucideIcon }; +const assets: StudioLink[] = [ + { href: "/admin/studio/furni", label: "Furniture", icon: Puzzle }, { href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck }, { href: "/admin/studio/clothing", label: "Clothing", icon: Shirt }, { href: "/admin/studio/effects", label: "Effects", icon: Dumbbell }, { href: "/admin/studio/pets", label: "Pets", icon: Cat }, - { href: "/admin/studio/clone", label: "Clone", icon: Copy }, - { href: "/admin/studio/sync", label: "Sync", icon: RefreshCw }, +]; +const imports: StudioLink[] = [ + { href: "/admin/studio/upload", label: "Upload files", icon: Upload }, + { href: "/admin/studio/clone", label: "Import from a source", icon: Copy }, + { href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw }, +]; +const maintenance: StudioLink[] = [ + { href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck }, { href: "/admin/studio/repair-icons", - label: "Repair Icons", + label: "Repair icons", icon: ImageIcon, }, - { href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck }, { href: "/admin/studio/maintenance", - label: "Catalogus onderhoud", + label: "Catalog maintenance", icon: Wrench, }, ]; +const activePath = (path: string, href: string) => + path === href || path.startsWith(`${href}/`); + +function StudioMenu({ + label, + links, + pathname, +}: { + label: string; + links: StudioLink[]; + pathname: string; +}) { + const [open, setOpen] = useState(false); + const active = links.find((link) => activePath(pathname, link.href)); + return ( + + + {label} + + + + {links.map(({ href, label: title, icon: Icon }) => ( + setOpen(false)} + aria-current={activePath(pathname, href) ? "page" : undefined} + className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15" + > + + {title} + + ))} + + + ); +} export default function StudioLayout({ children, @@ -47,31 +111,48 @@ export default function StudioLayout({ }) { const pathname = usePathname() ?? ""; return ( -
-
- {TABS.map(({ href, label, icon: Icon }) => { - const active = pathname === href || pathname.startsWith(`${href}/`); - return ( +
+
+
+ + +
+ +
{children}
); } diff --git a/src/components/admin/studio/batch-progress.tsx b/src/components/admin/studio/batch-progress.tsx index 4a0677c0..38f2284b 100644 --- a/src/components/admin/studio/batch-progress.tsx +++ b/src/components/admin/studio/batch-progress.tsx @@ -1,44 +1,19 @@ "use client"; -import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; - +import { Ban, Check, Circle, Loader2, RefreshCw, X } from "lucide-react"; +import { memo } from "react"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import type { BatchItemStatus, VerificationSummary } from "./studio-types"; -function SummaryRow({ - label, - value, - tone, -}: { - label: string; - value: string | number; - tone?: "ok" | "warn"; -}) { - return ( -
- {label} - - {value} - -
- ); -} - -function formatEta(ms: number): string { - if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`; - const min = Math.floor(ms / 60_000); - const sec = Math.ceil((ms % 60_000) / 1000); - return `${min}m ${sec}s`; -} +const statusLabels: Record = { + pending: "Waiting", + started: "Starting", + downloading: "Downloading assets", + converting: "Preparing assets", + writing_db: "Saving", + done: "Imported", + failed: "Failed", +}; export const BatchProgress = memo(function BatchProgress({ progress, @@ -47,7 +22,6 @@ export const BatchProgress = memo(function BatchProgress({ failed, cancelling, verification, - startTime, failedClassnames, onCancel, onDismiss, @@ -65,192 +39,151 @@ export const BatchProgress = memo(function BatchProgress({ onDismiss?: () => void; onRetry?: (classnames: string[]) => void; }) { - const { entries, total, completed, percent } = useMemo(() => { - const e = [...progress.entries()]; - const t = e.length; - const c = e.filter( - ([, s]) => s.status === "done" || s.status === "failed", - ).length; - return { - entries: e, - total: t, - completed: c, - percent: t > 0 ? Math.round((c / t) * 100) : 0, - }; - }, [progress]); - - // Live ETA calculation - const [eta, setEta] = useState(null); - const completedRef = useRef(completed); - const totalRef = useRef(total); - completedRef.current = completed; - totalRef.current = total; - - useEffect(() => { - if (done || !startTime || completed === 0) { - setEta(null); - return; - } - const calc = () => { - const elapsed = Date.now() - startTime; - const avgPerItem = elapsed / completedRef.current; - const remaining = (totalRef.current - completedRef.current) * avgPerItem; - setEta(formatEta(remaining)); - }; - calc(); - const interval = setInterval(calc, 2000); - return () => clearInterval(interval); - }, [done, startTime, completed]); - + const entries = [...progress.values()]; + const completed = entries.filter( + (item) => item.status === "done" || item.status === "failed", + ).length; + const total = entries.length; + const percent = total ? Math.round((completed / total) * 100) : 0; + const imported = done + ? succeeded + : entries.filter((item) => item.status === "done").length; + const errors = done + ? failed + : entries.filter((item) => item.status === "failed").length; + const unfinished = Math.max(0, total - completed); + const heading = !done + ? cancelling + ? "Stopping import…" + : "Importing furniture…" + : unfinished + ? "Import stopped" + : errors + ? "Import completed with errors" + : "Import completed"; + const issues = verification + ? verification.furniDataMissing + verification.furniDataConflicts + : 0; return ( -
-
- - {done ? "Import finished" : "Importing…"} - - {done ? ( - 0 - ? "text-[var(--admin-warning)]" - : "text-[var(--admin-success)]", - )} +
+
+
+

- {completed}/{total} · {succeeded} ok - {failed > 0 ? ` · ${failed} failed` : ""} - - ) : ( - - - {completed}/{total} - - {eta && ( - - ~{eta} - - )} - - - )} -

- - {!done && ( -
-
-
-
- - {percent}% - + {heading} + +

+ {imported} imported · {errors} failed + {unfinished > 0 && + ` · ${unfinished} ${done ? "not completed" : "remaining"}`} +

- )} - -
- {entries.map(([classname, s]) => ( -
- {s.status === "done" ? ( - - ) : s.status === "failed" ? ( - - ) : ( - - )} - - {classname} - - - {s.status.replace(/_/g, " ")} - -
- ))} -
- - {done && verification && ( -
-
- - Post-import verification -
- - - 0 ? "warn" : "ok"} - /> - 0 ? "warn" : "ok"} - /> - - - -
- )} - -
- {done ? ( - <> - {failedClassnames && failedClassnames.length > 0 && onRetry && ( - + )} + - )} - - - ) : ( - - )} + )} +
-
+
+
+
+ {done && verification && ( +

+ {issues + ? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.` + : "Catalog data verification completed."} +

+ )} +
+ + View item details ({total}) + +
+ {entries.map((item) => ( +
+
+ {item.status === "done" ? ( + + ) : item.status === "failed" ? ( + + ) : done || item.status === "pending" ? ( + + ) : ( + + )} + + {item.classname} + + + {done && item.status !== "done" && item.status !== "failed" + ? "Not completed" + : statusLabels[item.status]} + +
+ {item.message && ( +

+ {item.message} +

+ )} + {[...new Set(item.warnings ?? [])].map((warning) => ( +

+ {warning} +

+ ))} +
+ ))} +
+
+
); }); diff --git a/src/components/admin/studio/catalog-rail.tsx b/src/components/admin/studio/catalog-rail.tsx index d630aa6e..0b56f022 100644 --- a/src/components/admin/studio/catalog-rail.tsx +++ b/src/components/admin/studio/catalog-rail.tsx @@ -1,8 +1,7 @@ "use client"; -import { ChevronDown, ChevronRight, FolderTree } from "lucide-react"; -import { memo, useCallback, useMemo, useState } from "react"; - +import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react"; +import { memo, useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import type { TreeNode } from "@/types/catalog"; @@ -10,93 +9,122 @@ export const CatalogRail = memo(function CatalogRail({ tree, selectedId, onSelect, + onClose, }: { tree: TreeNode[]; selectedId: number | null; onSelect: (id: number | null) => void; + onClose?: () => void; }) { const [collapsed, setCollapsed] = useState>(new Set()); - + const [search, setSearch] = useState(""); const visible = useMemo(() => { + if (search.trim()) + return tree.filter((node) => + node.caption.toLowerCase().includes(search.trim().toLowerCase()), + ); const hidden = new Set(); - for (const node of tree) { - if (hidden.has(node.parentId)) { + for (const node of tree) + if (hidden.has(node.parentId) || collapsed.has(node.parentId)) hidden.add(node.id); - continue; - } - if (collapsed.has(node.parentId)) { - hidden.add(node.id); - } - } - return tree.filter((n) => !hidden.has(n.id)); - }, [tree, collapsed]); - - const toggle = useCallback((id: number) => { - setCollapsed((prev) => { - const next = new Set(prev); + return tree.filter((node) => !hidden.has(node.id)); + }, [tree, collapsed, search]); + function toggle(id: number) { + setCollapsed((previous) => { + const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); - }, []); - + } return ( -