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

+101 -73
View File
@@ -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<Set<number>>(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<number>();
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 (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
<FolderTree size={14} className="text-[var(--admin-accent)]" />
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Catalog
</span>
<aside
aria-label="Catalog categories"
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"
>
<div className="flex items-center gap-2 px-3 pt-3">
<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 className="min-h-0 flex-1 overflow-y-auto p-1.5">
{visible.length === 0 ? (
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
No catalog pages found.
<label className="mx-3 my-3 flex items-center gap-2 rounded-md border border-[var(--admin-border)] px-2">
<Search size={14} />
<input
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>
) : (
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>
</aside>
);