Improve Catalog Studio navigation and import workflow
CI / check (push) Successful in 1m37s
CI / deploy (push) Successful in 1m25s
CI / e2e (push) Failing after 30s

This commit is contained in:
Simo committed 2026-09-05 12:40:50 +02:00
1 parent bfdf4def0f
commit bf126fd825
4 files changed
+687 -492

No files matched your search

+105 -24
View File
@@ -3,6 +3,7 @@
import { import {
BadgeCheck, BadgeCheck,
Cat, Cat,
ChevronDown,
ClipboardCheck, ClipboardCheck,
Copy, Copy,
Dumbbell, Dumbbell,
@@ -15,30 +16,93 @@ import {
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react";
import Link from "@/components/link"; import Link from "@/components/link";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const TABS: { href: string; label: string; icon: LucideIcon }[] = [ type StudioLink = { href: string; label: string; icon: LucideIcon };
{ href: "/admin/studio/furni", label: "Furni", icon: Puzzle }, const assets: StudioLink[] = [
{ href: "/admin/studio/upload", label: "Upload", icon: Upload }, { href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck }, { href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt }, { href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell }, { href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat }, { 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", href: "/admin/studio/repair-icons",
label: "Repair Icons", label: "Repair icons",
icon: ImageIcon, icon: ImageIcon,
}, },
{ href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck },
{ {
href: "/admin/studio/maintenance", href: "/admin/studio/maintenance",
label: "Catalogus onderhoud", label: "Catalog maintenance",
icon: Wrench, 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium",
active
? "bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
{label}
<ChevronDown size={14} />
</PopoverTrigger>
<PopoverContent
align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
>
{links.map(({ href, label: title, icon: Icon }) => (
<Link
key={href}
href={href}
style={{
color: activePath(pathname, href)
? "var(--admin-text)"
: "var(--admin-text-muted)",
}}
onClick={() => 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"
>
<Icon size={16} />
{title}
</Link>
))}
</PopoverContent>
</Popover>
);
}
export default function StudioLayout({ export default function StudioLayout({
children, children,
@@ -47,31 +111,48 @@ export default function StudioLayout({
}) { }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
return ( return (
<div <div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]" <nav
style={{ backgroundColor: "var(--admin-canvas)" }} aria-label="Catalog Studio"
> className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2 py-1.5"> >
{TABS.map(({ href, label, icon: Icon }) => { <div className="flex flex-wrap gap-1">
const active = pathname === href || pathname.startsWith(`${href}/`); {assets.map(({ href, label, icon: Icon }) => (
return (
<Link <Link
key={href} key={href}
href={href} href={href}
style={{
color: activePath(pathname, href)
? "var(--admin-accent-foreground)"
: "var(--admin-text-muted)",
}}
aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn( className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors", "flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
active activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]" ? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]", : "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)} )}
> >
<Icon size={13} /> <Icon size={16} />
{label} {label}
</Link> </Link>
); ))}
})} </div>
</div> <div className="flex flex-wrap gap-1">
<div className="min-h-0 flex-1 overflow-auto">{children}</div> <StudioMenu
label="Import & sync"
links={imports}
pathname={pathname}
/>
<StudioMenu
label="Maintenance"
links={maintenance}
pathname={pathname}
/>
</div>
</nav>
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
</div> </div>
); );
} }
+151 -218
View File
@@ -1,44 +1,19 @@
"use client"; "use client";
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react"; import { Ban, Check, Circle, Loader2, RefreshCw, X } from "lucide-react";
import { memo, useEffect, useMemo, useRef, useState } from "react"; import { memo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types"; import type { BatchItemStatus, VerificationSummary } from "./studio-types";
function SummaryRow({ const statusLabels: Record<BatchItemStatus["status"], string> = {
label, pending: "Waiting",
value, started: "Starting",
tone, downloading: "Downloading assets",
}: { converting: "Preparing assets",
label: string; writing_db: "Saving",
value: string | number; done: "Imported",
tone?: "ok" | "warn"; failed: "Failed",
}) { };
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<span
className={cn(
"font-mono font-semibold",
tone === "warn"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
{value}
</span>
</div>
);
}
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`;
}
export const BatchProgress = memo(function BatchProgress({ export const BatchProgress = memo(function BatchProgress({
progress, progress,
@@ -47,7 +22,6 @@ export const BatchProgress = memo(function BatchProgress({
failed, failed,
cancelling, cancelling,
verification, verification,
startTime,
failedClassnames, failedClassnames,
onCancel, onCancel,
onDismiss, onDismiss,
@@ -65,192 +39,151 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void; onDismiss?: () => void;
onRetry?: (classnames: string[]) => void; onRetry?: (classnames: string[]) => void;
}) { }) {
const { entries, total, completed, percent } = useMemo(() => { const entries = [...progress.values()];
const e = [...progress.entries()]; const completed = entries.filter(
const t = e.length; (item) => item.status === "done" || item.status === "failed",
const c = e.filter( ).length;
([, s]) => s.status === "done" || s.status === "failed", const total = entries.length;
).length; const percent = total ? Math.round((completed / total) * 100) : 0;
return { const imported = done
entries: e, ? succeeded
total: t, : entries.filter((item) => item.status === "done").length;
completed: c, const errors = done
percent: t > 0 ? Math.round((c / t) * 100) : 0, ? failed
}; : entries.filter((item) => item.status === "failed").length;
}, [progress]); const unfinished = Math.max(0, total - completed);
const heading = !done
// Live ETA calculation ? cancelling
const [eta, setEta] = useState<string | null>(null); ? "Stopping import…"
const completedRef = useRef(completed); : "Importing furniture…"
const totalRef = useRef(total); : unfinished
completedRef.current = completed; ? "Import stopped"
totalRef.current = total; : errors
? "Import completed with errors"
useEffect(() => { : "Import completed";
if (done || !startTime || completed === 0) { const issues = verification
setEta(null); ? verification.furniDataMissing + verification.furniDataConflicts
return; : 0;
}
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]);
return ( return (
<div className="border-t border-[var(--admin-border)] p-3"> <section
<div className="mb-2 flex items-center justify-between gap-3"> aria-label="Import progress"
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]"> className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
{done ? "Import finished" : "Importing…"} >
</span> <div className="flex flex-wrap items-center justify-between gap-3">
{done ? ( <div role="status" aria-live="polite">
<span <h2
className={cn( style={{ fontSize: 14, margin: 0 }}
"text-xs font-bold", className="text-sm font-semibold"
failed > 0
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
> >
{completed}/{total} · {succeeded} ok {heading}
{failed > 0 ? ` · ${failed} failed` : ""} </h2>
</span> <p className="mt-1 text-xs text-[var(--admin-text-muted)]">
) : ( {imported} imported · {errors} failed
<span className="flex items-center gap-2"> {unfinished > 0 &&
<span className="text-xs font-mono text-[var(--admin-text-muted)]"> ` · ${unfinished} ${done ? "not completed" : "remaining"}`}
{completed}/{total} </p>
</span>
{eta && (
<span className="text-xs font-mono text-[var(--admin-accent)]">
~{eta}
</span>
)}
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
</span>
)}
</div>
{!done && (
<div className="mb-2 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs font-mono font-bold text-[var(--admin-text)]">
{percent}%
</span>
</div> </div>
)} <div className="flex flex-wrap gap-2">
{done ? (
<div className="max-h-44 space-y-1 overflow-y-auto pr-1"> <>
{entries.map(([classname, s]) => ( {!!failedClassnames?.length && onRetry && (
<div key={classname} className="flex items-center gap-2 text-xs"> <Button
{s.status === "done" ? ( size="sm"
<Check variant="outline"
size={12} onClick={() => onRetry(failedClassnames)}
className="shrink-0 text-[var(--admin-success)]" >
/> <RefreshCw size={14} />
) : s.status === "failed" ? ( Retry failed ({failedClassnames.length})
<X size={12} className="shrink-0 text-[var(--admin-warning)]" /> </Button>
) : ( )}
<Loader2 <Button size="sm" variant="secondary" onClick={onDismiss}>
size={12} <Check size={14} />
className="shrink-0 animate-spin text-[var(--admin-accent)]" Close summary
/>
)}
<span className="min-w-0 flex-1 truncate font-mono">
{classname}
</span>
<span className="shrink-0 capitalize text-[var(--admin-text-muted)]">
{s.status.replace(/_/g, " ")}
</span>
</div>
))}
</div>
{done && verification && (
<div className="mt-2 space-y-1.5 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<ShieldCheck size={13} className="text-[var(--admin-success)]" />
Post-import verification
</div>
<SummaryRow
label="Catalog offer_id fixed"
value={verification.offerIdsFixed}
/>
<SummaryRow
label="Furnidata ids fixed"
value={verification.furniDataFixedIds}
/>
<SummaryRow
label="Furnidata entries missing"
value={verification.furniDataMissing}
tone={verification.furniDataMissing > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Furnidata id conflicts"
value={verification.furniDataConflicts}
tone={verification.furniDataConflicts > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Nitros synced"
value={verification.nitrosSynced.length}
/>
<SummaryRow
label="Icons synced"
value={verification.iconsSynced.length}
/>
<SummaryRow
label="Folders chowned"
value={verification.ownershipFixed.length}
/>
</div>
)}
<div className="mt-2 flex justify-end gap-2">
{done ? (
<>
{failedClassnames && failedClassnames.length > 0 && onRetry && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(failedClassnames)}
>
<Loader2 size={14} />
Retry {failedClassnames.length} failed
</Button> </Button>
)} </>
<Button size="sm" variant="secondary" onClick={onDismiss}> ) : (
<Check size={14} /> <Button
Dismiss size="sm"
variant="outline"
onClick={onCancel}
disabled={cancelling}
>
{cancelling ? (
<Loader2 className="animate-spin" size={14} />
) : (
<Ban size={14} />
)}
Stop import
</Button> </Button>
</> )}
) : ( </div>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={cancelling}
>
{cancelling ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Ban size={14} />
)}
Cancel
</Button>
)}
</div> </div>
</div> <div
role="progressbar"
aria-label="Furniture processed"
aria-valuemin={0}
aria-valuemax={total || 1}
aria-valuenow={completed}
className="my-3 h-2 overflow-hidden rounded-full bg-[var(--admin-accent)]/10"
>
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all"
style={{ width: `${percent}%` }}
/>
</div>
{done && verification && (
<p className="mb-2 text-sm text-[var(--admin-text-muted)]">
{issues
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.`
: "Catalog data verification completed."}
</p>
)}
<details>
<summary className="cursor-pointer text-xs font-medium">
View item details ({total})
</summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.map((item) => (
<div
key={item.classname}
className="rounded-md bg-[var(--admin-canvas)] p-2 text-xs"
>
<div className="flex items-center gap-2">
{item.status === "done" ? (
<Check size={14} className="text-[var(--admin-success)]" />
) : item.status === "failed" ? (
<X size={14} className="text-[var(--admin-warning)]" />
) : done || item.status === "pending" ? (
<Circle size={12} />
) : (
<Loader2 size={14} className="animate-spin" />
)}
<span className="min-w-0 flex-1 break-all font-medium">
{item.classname}
</span>
<span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed"
? "Not completed"
: statusLabels[item.status]}
</span>
</div>
{item.message && (
<p className="mt-1 break-words text-[var(--admin-text-muted)]">
{item.message}
</p>
)}
{[...new Set(item.warnings ?? [])].map((warning) => (
<p
key={warning}
className="mt-1 break-words text-[var(--admin-warning)]"
>
{warning}
</p>
))}
</div>
))}
</div>
</details>
</section>
); );
}); });
+101 -73
View File
@@ -1,8 +1,7 @@
"use client"; "use client";
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react"; import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react";
import { memo, useCallback, useMemo, useState } from "react"; import { memo, useMemo, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog"; import type { TreeNode } from "@/types/catalog";
@@ -10,93 +9,122 @@ export const CatalogRail = memo(function CatalogRail({
tree, tree,
selectedId, selectedId,
onSelect, onSelect,
onClose,
}: { }: {
tree: TreeNode[]; tree: TreeNode[];
selectedId: number | null; selectedId: number | null;
onSelect: (id: number | null) => void; onSelect: (id: number | null) => void;
onClose?: () => void;
}) { }) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set()); const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const visible = useMemo(() => { const visible = useMemo(() => {
if (search.trim())
return tree.filter((node) =>
node.caption.toLowerCase().includes(search.trim().toLowerCase()),
);
const hidden = new Set<number>(); const hidden = new Set<number>();
for (const node of tree) { for (const node of tree)
if (hidden.has(node.parentId)) { if (hidden.has(node.parentId) || collapsed.has(node.parentId))
hidden.add(node.id); hidden.add(node.id);
continue; return tree.filter((node) => !hidden.has(node.id));
} }, [tree, collapsed, search]);
if (collapsed.has(node.parentId)) { function toggle(id: number) {
hidden.add(node.id); setCollapsed((previous) => {
} const next = new Set(previous);
}
return tree.filter((n) => !hidden.has(n.id));
}, [tree, collapsed]);
const toggle = useCallback((id: number) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
return next; return next;
}); });
}, []); }
return ( return (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]"> <aside
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5"> aria-label="Catalog categories"
<FolderTree size={14} className="text-[var(--admin-accent)]" /> className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none"
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]"> >
Catalog <div className="flex items-center gap-2 px-3 pt-3">
</span> <FolderTree size={16} />
<h2
style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold"
>
Categories
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close categories"
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
>
<X size={16} />
</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5"> <label className="mx-3 my-3 flex items-center gap-2 rounded-md border border-[var(--admin-border)] px-2">
{visible.length === 0 ? ( <Search size={14} />
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]"> <input
No catalog pages found. value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Find a category…"
aria-label="Find a category"
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/>
</label>
<button
type="button"
onClick={() => onSelect(null)}
aria-pressed={selectedId === null}
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
>
All categories
</button>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
No matching categories.
</p> </p>
) : (
visible.map((node) => {
const isRoot = node.parentId <= 0;
const hasChildren = node.childCount > 0;
const isCollapsed = collapsed.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => {
if (hasChildren) toggle(node.id);
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
style={{ paddingLeft: `${6 + node.depth * 12}px` }}
>
{hasChildren ? (
isCollapsed ? (
<ChevronRight size={12} className="shrink-0" />
) : (
<ChevronDown size={12} className="shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">
{isRoot ? (
<span className="font-semibold">{node.caption}</span>
) : (
node.caption
)}
</span>
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
{node.itemCount}
</span>
</button>
);
})
)} )}
{visible.map((node) => (
<div
key={node.id}
className={cn(
"flex items-center rounded-md",
selectedId === node.id && "bg-[var(--admin-accent)]/15",
)}
style={{
paddingLeft: search.trim() ? 0 : Math.min(node.depth * 12, 60),
}}
>
{node.childCount > 0 && !search.trim() ? (
<button
type="button"
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`}
aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(node.id)}
className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
>
{collapsed.has(node.id) ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-8 shrink-0" />
)}
<button
type="button"
onClick={() => onSelect(node.id)}
aria-pressed={selectedId === node.id}
title={node.caption}
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-md py-2 pr-2 text-left text-sm hover:bg-[var(--admin-accent)]/10"
>
<span className="min-w-0 flex-1 truncate">{node.caption}</span>
<span className="text-xs tabular-nums text-[var(--admin-text-muted)]">
{node.itemCount}
</span>
</button>
</div>
))}
</div> </div>
</aside> </aside>
); );
+330 -177
View File
@@ -111,7 +111,10 @@ export function StudioClient({
// Catalog tree // Catalog tree
const [tree] = useState<TreeNode[]>(initialTree); const [tree] = useState<TreeNode[]>(initialTree);
const [selectedPage, setSelectedPage] = useState<number | null>(null); const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(true); const [railOpen, setRailOpen] = useState(false);
useEffect(() => {
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
}, []);
// Search & list // Search & list
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -133,7 +136,7 @@ export function StudioClient({
const [categoryFilter, setCategoryFilter] = useState<string>("all"); const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<string>("default"); const [sortBy, setSortBy] = useState<string>("default");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("list");
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -154,7 +157,7 @@ export function StudioClient({
const batchDoneRef = useRef(false); const batchDoneRef = useRef(false);
const markBatchDone = useCallback(() => { const markBatchDone = useCallback(() => {
batchDoneRef.current = true; batchDoneRef.current = true;
markBatchDone(); setBatchDone(true);
}, []); }, []);
const [batchSucceeded, setBatchSucceeded] = useState(0); const [batchSucceeded, setBatchSucceeded] = useState(0);
const [batchFailed, setBatchFailed] = useState(0); const [batchFailed, setBatchFailed] = useState(0);
@@ -218,13 +221,13 @@ export function StudioClient({
const langs = data.languages ?? []; const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length; const okCount = langs.filter((l) => l.ok).length;
toast.success( toast.success(
`Alle talen vertaald (${okCount}/${langs.length} gelukt)`, `Languages translated (${okCount}/${langs.length} completed)`,
); );
} else { } else {
toast.error(data.error ?? "Vertaling mislukt"); toast.error(data.error ?? "Translation failed");
} }
} catch { } catch {
toast.error("Netwerkfout bij het vertalen van alle talen"); toast.error("Network error while translating languages");
} finally { } finally {
setBuildingLanguages(false); setBuildingLanguages(false);
} }
@@ -241,7 +244,7 @@ export function StudioClient({
try { try {
await setFurnidataTranslateEnabled({ enabled: next }); await setFurnidataTranslateEnabled({ enabled: next });
} catch { } catch {
toast.error("Kon de globale vertaal-instelling niet opslaan"); toast.error("Could not save the default translation setting");
} }
}; };
@@ -410,7 +413,7 @@ export function StudioClient({
} }
if (classnames.length === 0) { if (classnames.length === 0) {
toast.info("Alles is al geïmporteerd voor deze bron"); toast.info("All items from this source are already imported");
return; return;
} }
@@ -418,9 +421,9 @@ export function StudioClient({
sources.find((s) => s.id === activeSource)?.name || activeSource; sources.find((s) => s.id === activeSource)?.name || activeSource;
const confirmed = await confirm({ const confirmed = await confirm({
title: "Alles importeren", title: "Import all missing",
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`, description: `${classnames.length} items to import from "${sourceName}"? This may take a while.`,
confirmLabel: "Alles importeren", confirmLabel: "Import all missing",
}); });
if (!confirmed) return; if (!confirmed) return;
@@ -430,6 +433,7 @@ export function StudioClient({
initial.set(cn, { classname: cn, status: "pending" }); initial.set(cn, { classname: cn, status: "pending" });
} }
setBatchProgress(initial); setBatchProgress(initial);
batchDoneRef.current = false;
setBatchDone(false); setBatchDone(false);
setBatchSucceeded(0); setBatchSucceeded(0);
setBatchFailed(0); setBatchFailed(0);
@@ -543,7 +547,7 @@ export function StudioClient({
// Auto-translate after clone all if translation is enabled // Auto-translate after clone all if translation is enabled
if (translate && okCount > 0) { if (translate && okCount > 0) {
toast.info("Automatisch vertalen van alle talen..."); toast.info("Translating all languages...");
buildAllLanguages(); buildAllLanguages();
} }
} else { } else {
@@ -589,6 +593,12 @@ export function StudioClient({
if (updated) setDetail(updated); if (updated) setDetail(updated);
}, [items, detail]); }, [items, detail]);
const clearFilters = () => {
setTypeFilter("all");
setCategoryFilter("all");
setStatusFilter("all");
setSelectedPage(null);
};
const clearSearch = () => { const clearSearch = () => {
setSearchTerm(""); setSearchTerm("");
setActiveSearch(""); setActiveSearch("");
@@ -701,15 +711,19 @@ export function StudioClient({
} }
} }
async function importBatch() { async function importBatch(requested?: string[]) {
const toImport = filteredItems.filter( const toImport = (requested ? items : filteredItems).filter(
(i) => selected.has(i.classname) && !i.alreadyImported, (i) =>
(requested
? requested.includes(i.classname)
: selected.has(i.classname)) && !i.alreadyImported,
); );
if (toImport.length === 0) { if (toImport.length === 0) {
toast.info("Select furni that isn't imported yet"); toast.info("Select furni that isn't imported yet");
return; return;
} }
batchDoneRef.current = false;
setBatchDone(false); setBatchDone(false);
setBatchSucceeded(0); setBatchSucceeded(0);
setBatchFailed(0); setBatchFailed(0);
@@ -750,7 +764,7 @@ export function StudioClient({
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
toast.error(`Batch import mislukt (${res.status})`); toast.error(`Batch import failed (${res.status})`);
setBatchProgress(null); setBatchProgress(null);
return; return;
} }
@@ -818,18 +832,16 @@ export function StudioClient({
} }
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") { if ((err as Error).name === "AbortError") {
toast.info("Batch import geannuleerd door gebruiker"); toast.info("Import cancelled");
} else if ((err as Error).name === "TimeoutError") { } else if ((err as Error).name === "TimeoutError") {
toast.error( toast.error("Import timed out. Retry or reduce concurrency.");
"Import tijdelijkstop gedurende te lange tijd – probeer het opnieuw of verlaag de concurrentie",
);
} else if (err instanceof TypeError && err.message?.includes("fetch")) { } else if (err instanceof TypeError && err.message?.includes("fetch")) {
toast.error( toast.error(
"Netwerkfout tijdens upload – controleer je internetverbinding en probeer opnieuw", "Network error during upload. Check your connection and retry.",
); );
} else { } else {
toast.error( toast.error(
"Onverwende fout tijdens import: " + "Unexpected import error: " +
(err instanceof Error ? err.message : String(err)), (err instanceof Error ? err.message : String(err)),
); );
} }
@@ -838,7 +850,7 @@ export function StudioClient({
setBatchCancelling(false); setBatchCancelling(false);
setSelected(new Set()); setSelected(new Set());
fetchStats(); fetchStats();
if (!batchDoneRef.current) setBatchProgress(null); if (!batchDoneRef.current) markBatchDone();
} }
} }
@@ -857,8 +869,13 @@ export function StudioClient({
}; };
async function retryFailed(failed: string[]) { async function retryFailed(failed: string[]) {
if (!activeSource || failed.length === 0) return; if (failed.length === 0) return;
if (!activeSource) {
await importBatch(failed);
return;
}
// For clone sources, re-run cloneAllMissing with only the failed classnames // For clone sources, re-run cloneAllMissing with only the failed classnames
batchDoneRef.current = false;
setBatchDone(false); setBatchDone(false);
setBatchSucceeded(0); setBatchSucceeded(0);
setBatchFailed(0); setBatchFailed(0);
@@ -890,7 +907,7 @@ export function StudioClient({
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
toast.error(`Retry mislukt (${res.status})`); toast.error(`Retry failed (${res.status})`);
setBatchProgress(null); setBatchProgress(null);
return; return;
} }
@@ -931,14 +948,14 @@ export function StudioClient({
markBatchDone(); markBatchDone();
if (okCount > 0) { if (okCount > 0) {
toast.success(`${okCount} items opnieuw geïmporteerd`); toast.success(`${okCount} items imported again`);
} }
fetchItems(activeSearch, 1, activeSource); fetchItems(activeSearch, 1, activeSource);
fetchStats(); fetchStats();
fetchCloneStats(activeSource); fetchCloneStats(activeSource);
} catch (err) { } catch (err) {
if ((err as Error)?.name !== "AbortError") { if ((err as Error)?.name !== "AbortError") {
toast.error("Verbindingsfout tijdens retry"); toast.error("Connection failed while retrying");
setBatchProgress(null); setBatchProgress(null);
} }
} finally { } finally {
@@ -1153,7 +1170,7 @@ export function StudioClient({
); );
return ( return (
<div className="flex h-full min-h-[520px] flex-col overflow-hidden"> <div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
{/* ── Header ─────────────────────────────────────────── */} {/* ── Header ─────────────────────────────────────────── */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3"> <header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -1161,7 +1178,10 @@ export function StudioClient({
<Sparkles size={16} className="text-[var(--admin-accent)]" /> <Sparkles size={16} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="text-base font-extrabold leading-tight text-[var(--admin-text)]"> <h1
style={{ fontSize: 20, margin: 0 }}
className="text-base font-extrabold leading-tight text-[var(--admin-text)]"
>
Studio Studio
</h1> </h1>
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]"> <p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
@@ -1170,13 +1190,14 @@ export function StudioClient({
</div> </div>
</div> </div>
<div className="flex flex-1 items-center gap-2"> <div className="flex min-w-[180px] flex-1 items-center gap-2">
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
<Search <Search
size={14} size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]" className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
/> />
<Input <Input
aria-label="Search furniture"
ref={searchRef} ref={searchRef}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
@@ -1185,6 +1206,7 @@ export function StudioClient({
if (e.key === "Escape") clearSearch(); if (e.key === "Escape") clearSearch();
}} }}
placeholder="Search furni, classnames, keywords… (/)" placeholder="Search furni, classnames, keywords… (/)"
style={{ paddingLeft: 32, paddingRight: 32 }}
className="pl-8 pr-8" className="pl-8 pr-8"
/> />
{searchTerm ? ( {searchTerm ? (
@@ -1232,7 +1254,7 @@ export function StudioClient({
</Badge> </Badge>
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
<Check size={11} className="text-[var(--admin-success)]" />{" "} <Check size={11} className="text-[var(--admin-success)]" />{" "}
{cloneStats.present} aanwezig {cloneStats.present} present
</Badge> </Badge>
{cloneStats.clonable > 0 && ( {cloneStats.clonable > 0 && (
<Badge <Badge
@@ -1246,18 +1268,6 @@ export function StudioClient({
</> </>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
<Switch
checked={globalTranslate}
onCheckedChange={onGlobalTranslateChange}
aria-label="Globale vertaling"
/>
<Globe size={13} className="text-[var(--admin-text-muted)]" />
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Vertaling (globaal)
</span>
</div>
</header> </header>
{/* ── Filters + actions ─────────────────────────────── */} {/* ── Filters + actions ─────────────────────────────── */}
@@ -1283,8 +1293,14 @@ export function StudioClient({
</button> </button>
<Select value={activeSource} onValueChange={changeSource}> <Select value={activeSource} onValueChange={changeSource}>
<SelectTrigger className="h-8 w-44 text-xs"> <SelectTrigger
<SelectValue placeholder="Furni source" /> aria-label="Furniture source"
className="h-8 w-44 text-xs"
>
<SelectValue>
{sources.find((entry) => entry.id === activeSource)?.name ??
`${source.label} (official)`}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">{source.label} (official)</SelectItem> <SelectItem value="">{source.label} (official)</SelectItem>
@@ -1297,8 +1313,17 @@ export function StudioClient({
</Select> </Select>
<Select value={typeFilter} onValueChange={setTypeFilter}> <Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-8 w-32 text-xs"> <SelectTrigger
<SelectValue placeholder="Type" /> aria-label="Furniture type"
className="h-8 w-32 text-xs"
>
<SelectValue>
{typeFilter === "all"
? "All types"
: typeFilter === "flooritem"
? "Floor items"
: "Wall items"}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All types</SelectItem> <SelectItem value="all">All types</SelectItem>
@@ -1308,8 +1333,13 @@ export function StudioClient({
</Select> </Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}> <Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="h-8 w-40 text-xs"> <SelectTrigger
<SelectValue placeholder="Category" /> aria-label="Furniture category"
className="h-8 w-40 text-xs"
>
<SelectValue>
{categoryFilter === "all" ? "All categories" : categoryFilter}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categories.map((cat) => ( {categories.map((cat) => (
@@ -1321,13 +1351,31 @@ export function StudioClient({
</Select> </Select>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-8 w-36 text-xs"> <SelectTrigger
<SelectValue placeholder="Status" /> aria-label="Import status"
className="h-8 w-36 text-xs"
>
<SelectValue>
{
(
{
all: "All statuses",
notImported: "Not imported",
imported: "Imported",
inCatalog: "In catalog",
missingNitro: "Missing nitro",
missingFurnidata: "Missing furnidata",
missingCatalog: "Missing catalog entry",
} as Record<string, string>
)[statusFilter]
}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
All statuses{" "} All statuses{" "}
{notImportedCount > 0 && `(${notImportedCount} te importeren)`} {notImportedCount > 0 &&
`(${notImportedCount} available to import)`}
</SelectItem> </SelectItem>
<SelectItem value="notImported">Not imported</SelectItem> <SelectItem value="notImported">Not imported</SelectItem>
<SelectItem value="imported">Imported</SelectItem> <SelectItem value="imported">Imported</SelectItem>
@@ -1341,9 +1389,25 @@ export function StudioClient({
</Select> </Select>
<Select value={sortBy} onValueChange={setSortBy}> <Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="h-8 w-44 text-xs"> <SelectTrigger
aria-label="Sort furniture"
className="h-8 w-44 text-xs"
>
<ArrowUpDown size={12} className="mr-1" /> <ArrowUpDown size={12} className="mr-1" />
<SelectValue placeholder="Sort" /> <SelectValue>
{
(
{
default: "Default order",
name: "Name A–Z",
classname: "Classname A–Z",
category: "Category",
"imported-first": "Imported first",
"not-imported-first": "Not imported first",
} as Record<string, string>
)[sortBy]
}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default">Default order</SelectItem> <SelectItem value="default">Default order</SelectItem>
@@ -1357,6 +1421,14 @@ export function StudioClient({
</SelectContent> </SelectContent>
</Select> </Select>
{(typeFilter !== "all" ||
categoryFilter !== "all" ||
statusFilter !== "all" ||
selectedPage !== null) && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Reset filters
</Button>
)}
{missingNitroCount > 0 && ( {missingNitroCount > 0 && (
<button <button
type="button" type="button"
@@ -1378,6 +1450,7 @@ export function StudioClient({
type="button" type="button"
onClick={() => setViewMode("grid")} onClick={() => setViewMode("grid")}
aria-label="Grid view" aria-label="Grid view"
aria-pressed={viewMode === "grid"}
className={cn( className={cn(
"grid h-8 w-8 place-items-center transition-colors", "grid h-8 w-8 place-items-center transition-colors",
viewMode === "grid" viewMode === "grid"
@@ -1391,6 +1464,7 @@ export function StudioClient({
type="button" type="button"
onClick={() => setViewMode("list")} onClick={() => setViewMode("list")}
aria-label="List view" aria-label="List view"
aria-pressed={viewMode === "list"}
className={cn( className={cn(
"grid h-8 w-8 place-items-center transition-colors", "grid h-8 w-8 place-items-center transition-colors",
viewMode === "list" viewMode === "list"
@@ -1401,19 +1475,152 @@ export function StudioClient({
<List size={13} /> <List size={13} />
</button> </button>
</div> </div>
</div>
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
<summary className="cursor-pointer py-1 text-sm font-medium">
Import options{" "}
<span className="font-normal text-[var(--admin-text-muted)]">
· Translation {translate ? "on" : "off"}
</span>
</summary>
<div className="flex max-h-48 flex-wrap items-center gap-3 overflow-auto py-3">
{" "}
{/* ── Translate control (per-import) ─────────────── */}
<div className="flex items-center gap-2">
<div className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5">
<Switch
checked={translate}
onCheckedChange={setTranslate}
aria-label="Translate this import"
/>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Translate
</span>
</div>
<Popover open={langPickerOpen} onOpenChange={setLangPickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
disabled={!translate}
>
<Languages size={13} />
{translateLangs.length === 0
? "No languages"
: translateLangs.length === ALL_LANGS.length
? "All languages"
: `${translateLangs.length} languages`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-[var(--admin-text)]">
Languages
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([...ALL_LANGS])}
>
All
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([])}
>
None
</Button>
</div>
</div>
<div className="grid max-h-56 grid-cols-2 gap-1 overflow-y-auto">
{ALL_LANGS.map((lang) => {
const on = translateLangs.includes(lang);
return (
<button
key={lang}
type="button"
onClick={() => toggleLang(lang)}
className={cn(
"flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
on
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
: "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Checkbox
style={{
display: "inline-flex",
width: 18,
height: 18,
}}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={on}
aria-label={`Language ${lang}`}
tabIndex={-1}
/>
{lang.toUpperCase()}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
disabled={buildingLanguages}
onClick={buildAllLanguages}
title="Translate the entire catalog into all languages"
>
{buildingLanguages ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Globe size={13} />
)}
Translate entire catalog
</Button>
</div>
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
<Switch
checked={globalTranslate}
onCheckedChange={onGlobalTranslateChange}
aria-label="Default translation"
/>
<Globe size={13} className="text-[var(--admin-text-muted)]" />
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Default for future imports
</span>
</div>
</div>
</details>
<fieldset
aria-label="Furniture selection"
className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-canvas)] px-4 py-2"
>
<button <button
type="button" type="button"
onClick={toggleAll} onClick={toggleAll}
className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
> >
<CheckboxDot checked={allSelected} /> <CheckboxDot checked={allSelected} />
Select all {allSelected ? "Deselect visible" : "Select visible"}
</button> </button>
{selected.size > 0 && ( {selected.size > 0 && (
<span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]"> <span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]">
{selected.size} selected {
filteredItems.filter((item) => selected.has(item.classname))
.length
}{" "}
selected in this view
</span> </span>
)} )}
@@ -1428,110 +1635,12 @@ export function StudioClient({
</button> </button>
)} )}
{/* ── Translate control (per-import) ─────────────── */}
<div className="flex items-center gap-2">
<div className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5">
<Switch
checked={translate}
onCheckedChange={setTranslate}
aria-label="Vertalen bij import"
/>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Vertalen
</span>
</div>
<Popover open={langPickerOpen} onOpenChange={setLangPickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
disabled={!translate}
>
<Languages size={13} />
{translateLangs.length === 0
? "Geen talen"
: translateLangs.length === ALL_LANGS.length
? "Alle talen"
: `${translateLangs.length} talen`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-[var(--admin-text)]">
Talen
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([...ALL_LANGS])}
>
Alles
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([])}
>
Geen
</Button>
</div>
</div>
<div className="grid max-h-56 grid-cols-2 gap-1 overflow-y-auto">
{ALL_LANGS.map((lang) => {
const on = translateLangs.includes(lang);
return (
<button
key={lang}
type="button"
onClick={() => toggleLang(lang)}
className={cn(
"flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
on
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
: "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Checkbox
checked={on}
aria-label={`Taal ${lang}`}
tabIndex={-1}
/>
{lang.toUpperCase()}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
disabled={buildingLanguages}
onClick={buildAllLanguages}
title="Vertaal de hele catalogus in alle talen (achteraf)"
>
{buildingLanguages ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Globe size={13} />
)}
Alles vertalen
</Button>
</div>
<div className="flex-1" /> <div className="flex-1" />
{cloneStats && cloneStats.clonable > 0 && !batchProgress && ( {cloneStats && cloneStats.clonable > 0 && !batchProgress && (
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}> <Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
<CloudDownload size={14} /> <CloudDownload size={14} />
Alles importeren ({cloneStats.clonable}) Import all missing ({cloneStats.clonable})
</Button> </Button>
)} )}
@@ -1540,14 +1649,14 @@ export function StudioClient({
size="sm" size="sm"
className="gap-1.5" className="gap-1.5"
disabled={!!batchProgress} disabled={!!batchProgress}
onClick={importBatch} onClick={() => importBatch()}
> >
{batchProgress ? ( {batchProgress ? (
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
) : ( ) : (
<CloudDownload size={14} /> <CloudDownload size={14} />
)} )}
Import {selectedNonImported} auto Import selected ({selectedNonImported})
</Button> </Button>
)} )}
@@ -1564,7 +1673,7 @@ export function StudioClient({
) : ( ) : (
<RefreshCw size={14} /> <RefreshCw size={14} />
)} )}
Nitro regeneren ({selectedMissingNitro.length}) Rebuild Nitro ({selectedMissingNitro.length})
</Button> </Button>
)} )}
@@ -1581,25 +1690,45 @@ export function StudioClient({
) : ( ) : (
<Trash2 size={14} /> <Trash2 size={14} />
)} )}
Verwijder ({selectedImported.length}) Delete ({selectedImported.length})
</Button> </Button>
)} )}
</div> </fieldset>
{selected.size >
filteredItems.filter((item) => selected.has(item.classname)).length && (
<p className="shrink-0 px-4 py-1 text-xs text-[var(--admin-text-muted)]">
Some selected furniture is hidden by your filters. Actions apply to
the visible selection.
</p>
)}
{/* ── Body: rail + grid + drawer ────────────────────── */} {/* ── Body: rail + grid + drawer ────────────────────── */}
<div className="relative flex min-h-0 flex-1"> <div className="relative flex min-h-0 flex-1">
{railOpen && (
<button
type="button"
aria-label="Close category panel"
onClick={() => setRailOpen(false)}
className="absolute inset-0 z-20 bg-[var(--admin-text)]/30 lg:hidden"
/>
)}
{railOpen ? ( {railOpen ? (
<CatalogRail <CatalogRail
tree={tree} tree={tree}
selectedId={selectedPage} selectedId={selectedPage}
onSelect={setSelectedPage} onSelect={(id) => {
setSelectedPage(id);
if (!window.matchMedia("(min-width: 1024px)").matches)
setRailOpen(false);
}}
onClose={() => setRailOpen(false)}
/> />
) : null} ) : null}
<main className="flex min-h-0 flex-1 flex-col"> <main className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{Array.from({ length: 10 }, (_, i) => i).map((n) => ( {Array.from({ length: 10 }, (_, i) => i).map((n) => (
<div <div
key={`skeleton-${n}`} key={`skeleton-${n}`}
@@ -1616,6 +1745,13 @@ export function StudioClient({
) : error ? ( ) : error ? (
<div className="py-16 text-center text-sm text-[var(--admin-warning)]"> <div className="py-16 text-center text-sm text-[var(--admin-warning)]">
{error} {error}
<Button
variant="outline"
className="ml-3"
onClick={() => fetchItems(activeSearch, 1, activeSource)}
>
Try again
</Button>
</div> </div>
) : filteredItems.length === 0 ? ( ) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center py-16 text-center"> <div className="flex flex-col items-center py-16 text-center">
@@ -1639,7 +1775,7 @@ export function StudioClient({
) : ( ) : (
<> <>
{viewMode === "grid" ? ( {viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{filteredItems.map((item) => { {filteredItems.map((item) => {
const isSel = selected.has(item.classname); const isSel = selected.has(item.classname);
return ( return (
@@ -1647,7 +1783,6 @@ export function StudioClient({
<button <button
type="button" type="button"
onClick={() => setDetail(item)} onClick={() => setDetail(item)}
onDoubleClick={() => toggleSelect(item.classname)}
className={cn( className={cn(
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]", "flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
isSel isSel
@@ -1683,7 +1818,7 @@ export function StudioClient({
</div> </div>
</div> </div>
<div className="flex items-center gap-1 px-2 pb-1.5"> <div className="flex items-center gap-1 px-2 pb-1.5">
<Badge className="h-4 text-[var(--admin-accent-foreground)]"> <Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type} {TYPE_LABELS[item.type] ?? item.type}
</Badge> </Badge>
{item.alreadyImported ? ( {item.alreadyImported ? (
@@ -1724,7 +1859,7 @@ export function StudioClient({
)} )}
</> </>
) : ( ) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]"> <Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
New New
</Badge> </Badge>
)} )}
@@ -1737,12 +1872,13 @@ export function StudioClient({
toggleSelect(item.classname); toggleSelect(item.classname);
}} }}
className={cn( className={cn(
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors", "absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-md border transition-colors",
isSel isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]" ? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60", : "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)} )}
aria-label={isSel ? "Deselect" : "Select"} aria-label={`${isSel ? "Deselect" : "Select"} ${item.name}`}
aria-pressed={isSel}
> >
{isSel ? ( {isSel ? (
<Check <Check
@@ -1757,11 +1893,17 @@ export function StudioClient({
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]"> <div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
<table className="w-full text-xs"> <table className="w-full min-w-[660px] text-sm">
<thead className="bg-[var(--admin-accent)]/8"> <thead className="sticky top-0 z-10 bg-[var(--admin-surface)]">
<tr> <tr>
<th className="w-9 px-2 py-2 text-left"> <th className="w-9 px-2 py-2 text-left">
<Checkbox <Checkbox
style={{
display: "inline-flex",
width: 18,
height: 18,
}}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={allSelected} checked={allSelected}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
aria-label="Select all" aria-label="Select all"
@@ -1794,10 +1936,20 @@ export function StudioClient({
return ( return (
<tr <tr
key={item.classname} key={item.classname}
className="border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5" aria-selected={isSel}
className={cn(
"border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5",
isSel && "bg-[var(--admin-accent)]/10",
)}
> >
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<Checkbox <Checkbox
style={{
display: "inline-flex",
width: 18,
height: 18,
}}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={isSel} checked={isSel}
onCheckedChange={() => onCheckedChange={() =>
toggleSelect(item.classname) toggleSelect(item.classname)
@@ -1873,7 +2025,7 @@ export function StudioClient({
)} )}
</div> </div>
) : ( ) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]"> <Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
New New
</Badge> </Badge>
)} )}
@@ -1886,7 +2038,7 @@ export function StudioClient({
onClick={() => importSingle(item)} onClick={() => importSingle(item)}
disabled={importingId !== null} disabled={importingId !== null}
aria-label={`Import ${item.classname}`} aria-label={`Import ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50" className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
> >
{importingId === item.classname ? ( {importingId === item.classname ? (
<Loader2 <Loader2
@@ -1905,7 +2057,7 @@ export function StudioClient({
setEditingClassname(item.classname) setEditingClassname(item.classname)
} }
aria-label={`Edit nitro ${item.classname}`} aria-label={`Edit nitro ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
> >
<Pencil size={12} /> <Pencil size={12} />
</button> </button>
@@ -1919,7 +2071,7 @@ export function StudioClient({
item.classname, item.classname,
)} )}
aria-label={`Regenerate nitro ${item.classname}`} aria-label={`Regenerate nitro ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50" className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
> >
{regeneratingNitro.has( {regeneratingNitro.has(
item.classname, item.classname,
@@ -1985,11 +2137,12 @@ export function StudioClient({
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch} onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
onDismiss={() => { onDismiss={() => {
setBatchProgress(null); setBatchProgress(null);
batchDoneRef.current = false;
setBatchDone(false); setBatchDone(false);
setVerification(null); setVerification(null);
setFailedClassnames([]); setFailedClassnames([]);
}} }}
onRetry={activeSource ? retryFailed : undefined} onRetry={retryFailed}
/> />
) : null} ) : null}
</main> </main>