Improve Catalog Studio navigation and import workflow
This commit is contained in:
1 parent
bfdf4def0f
commit
bf126fd825
4 files changed
+687
-492
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user