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 (
-