mono/packages/ui/src/components/CategoryTreeView.tsx
2026-03-21 20:18:25 +01:00

150 lines
6.2 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { fetchCategories, type Category } from "@/modules/categories/client-categories";
import { useNavigate, useParams } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useState, useCallback, useMemo } from "react";
import { FolderTree, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useAppConfig } from '@/hooks/useSystemInfo';
interface CategoryTreeViewProps {
/** Called after a category is selected (useful for closing mobile sheet) */
onNavigate?: () => void;
/** Only show categories whose meta.type matches this value */
filterType?: string;
}
const COLLAPSED_KEY = 'categoryTreeCollapsed';
/** Recursively filter a category tree by meta.type */
const filterByType = (cats: Category[], type: string): Category[] =>
cats.reduce<Category[]>((acc, cat) => {
if (cat.meta?.type === type) {
// Category matches — include it, but also filter its children
const filteredChildren = cat.children
? cat.children.filter(rel => !rel.child.meta?.type || rel.child.meta.type === type)
: undefined;
acc.push({ ...cat, children: filteredChildren });
}
return acc;
}, []);
const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => {
const params = useParams();
const wildcardPath = params['*'];
const activeSlug = wildcardPath ? wildcardPath.split('/').filter(Boolean).pop() : undefined;
const navigate = useNavigate();
// Track explicitly collapsed nodes (everything else is expanded by default)
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem(COLLAPSED_KEY);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch { return new Set(); }
});
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language;
const { data: categories = [], isLoading } = useQuery({
queryKey: ['categories'],
queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang }),
staleTime: 1000 * 60 * 5,
});
const filteredCategories = useMemo(
() => filterType ? filterByType(categories, filterType) : categories,
[categories, filterType]
);
const toggleExpand = useCallback((id: string) => {
setCollapsedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next]));
return next;
});
}, []);
const handleSelect = useCallback((slug: string | null) => {
if (slug) {
navigate(`/categories/${slug}`);
} else {
navigate('/');
}
onNavigate?.();
}, [navigate, onNavigate]);
const renderNode = (cat: Category, depth: number = 0, parentPath: string = '') => {
const fullPath = parentPath ? `${parentPath}/${cat.slug}` : cat.slug;
const hasChildren = cat.children && cat.children.length > 0;
const isActive = cat.slug === activeSlug;
const isExpanded = hasChildren && !collapsedIds.has(cat.id);
return (
<div key={cat.id}>
<Collapsible open={isExpanded} onOpenChange={() => hasChildren && toggleExpand(cat.id)}>
<div
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors",
"hover:bg-muted/60",
isActive && "bg-primary/10 text-primary font-medium",
)}
style={{ paddingLeft: `${8 + depth * 14}px` }}
>
{/* Expand/collapse chevron */}
{hasChildren ? (
<CollapsibleTrigger asChild>
<button
className="shrink-0 p-0.5 rounded hover:bg-muted"
onClick={(e) => e.stopPropagation()}
>
{isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
}
</button>
</CollapsibleTrigger>
) : (
<span className="w-[18px] shrink-0" /> /* spacer */
)}
{/* Category label — clickable to navigate */}
<button
className="flex items-center gap-1.5 truncate text-left flex-1 min-w-0"
onClick={() => handleSelect(fullPath)}
>
<FolderTree className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{cat.name}</span>
</button>
</div>
{hasChildren && (
<CollapsibleContent>
{cat.children!.map(rel => renderNode(rel.child, depth + 1, fullPath))}
</CollapsibleContent>
)}
</Collapsible>
</div>
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<nav className="flex flex-col gap-0.5 py-2 text-sm select-none">
{/* Category tree */}
{filteredCategories.map(cat => renderNode(cat))}
</nav>
);
};
export default CategoryTreeView;