/** * TagFacets - Sidebar tag filter with hierarchical grouping. * * Aesthetic: Specimen cabinet / library index drawer * - Tags organized like specimens in a naturalist's drawer * - Collapsible groups with brass handle accents * - Active tags glow like pinned specimens under glass * - Taxonomy tree visual with connecting lines */ import { ChevronDownIcon, ChevronRightIcon, TagIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; interface TagData { tag: string; count: number; } interface TagsResponse { tags: TagData[]; meta: { totalTags: number }; } export interface TagFacetsProps { /** Currently selected filter tags */ activeTags: string[]; /** Add tag to filter */ onTagSelect: (tag: string) => void; /** Remove tag from filter */ onTagRemove: (tag: string) => void; /** Optional collection filter */ collection?: string; /** Additional classes */ className?: string; } /** Simple in-memory cache with TTL */ interface CacheEntry { data: TagsResponse; timestamp: number; } const cache = new Map(); const CACHE_TTL = 30000; // 30 seconds function getCached(key: string): TagsResponse | null { const entry = cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp > CACHE_TTL) { cache.delete(key); return null; } return entry.data; } function setCache(key: string, data: TagsResponse): void { cache.set(key, { data, timestamp: Date.now() }); } /** Group tags by their prefix for hierarchy display */ interface TagGroup { prefix: string; tags: TagData[]; } function groupTags(tags: TagData[]): TagGroup[] { const groups = new Map(); // First pass: identify all prefixes and root tags for (const t of tags) { const slashIdx = t.tag.indexOf("/"); if (slashIdx > 0) { const prefix = t.tag.slice(0, slashIdx); if (!groups.has(prefix)) { groups.set(prefix, []); } groups.get(prefix)!.push(t); } else { // Root-level tag if (!groups.has("")) { groups.set("", []); } groups.get("")!.push(t); } } // Convert to array and sort const result: TagGroup[] = []; // Root tags first const rootTags = groups.get(""); if (rootTags && rootTags.length > 0) { result.push({ prefix: "", tags: rootTags.sort((a, b) => b.count - a.count), }); } // Then grouped tags, sorted by prefix const prefixes = Array.from(groups.keys()) .filter((p) => p !== "") .sort(); for (const prefix of prefixes) { const prefixTags = groups.get(prefix)!; result.push({ prefix, tags: prefixTags.sort((a, b) => b.count - a.count), }); } return result; } /** Loading skeleton */ function TagFacetsSkeleton() { return (
{/* Fake group headers */} {[1, 2, 3].map((i) => (
{[1, 2].map((j) => (
))}
))}
); } /** Empty state */ function TagFacetsEmpty() { return (

No tags found

Add tags to your documents to see them here

); } /** Individual tag item */ function TagItem({ tag, count, isActive, isChild, onSelect, onRemove, }: { tag: string; count: number; isActive: boolean; isChild: boolean; onSelect: () => void; onRemove: () => void; }) { const displayName = isChild ? tag.split("/").pop() : tag; return ( ); } /** Collapsible tag group */ function TagGroupSection({ group, activeTags, onTagSelect, onTagRemove, defaultOpen = true, }: { group: TagGroup; activeTags: string[]; onTagSelect: (tag: string) => void; onTagRemove: (tag: string) => void; defaultOpen?: boolean; }) { const [isOpen, setIsOpen] = useState(defaultOpen); // Count active tags in this group const activeCount = group.tags.filter((t) => activeTags.includes(t.tag) ).length; // Root-level tags (no prefix) don't need a collapsible wrapper if (!group.prefix) { return (
{group.tags.map((t) => ( onTagRemove(t.tag)} onSelect={() => onTagSelect(t.tag)} tag={t.tag} /> ))}
); } return ( 0 && "text-primary" )} > {/* Chevron */} {isOpen ? ( ) : ( )} {/* Prefix name - brass label style */} 0 ? "text-primary/80" : "text-muted-foreground" )} > {group.prefix} {/* Group count */} {group.tags.length} {/* Active indicator dot */} {activeCount > 0 && ( )}
{group.tags.map((t) => ( onTagRemove(t.tag)} onSelect={() => onTagSelect(t.tag)} tag={t.tag} /> ))}
); } export function TagFacets({ activeTags, onTagSelect, onTagRemove, collection, className, }: TagFacetsProps) { const [tags, setTags] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Build cache key const cacheKey = `tags:${collection ?? "all"}`; // Fetch tags const fetchTags = useCallback(async () => { // Check cache first const cached = getCached(cacheKey); if (cached) { setTags(cached.tags); setLoading(false); return; } setLoading(true); setError(null); const params = new URLSearchParams(); if (collection) { params.set("collection", collection); } const url = `/api/tags${params.toString() ? `?${params.toString()}` : ""}`; const { data, error: fetchError } = await apiFetch(url); if (fetchError || !data) { setError(fetchError ?? "Failed to load tags"); setLoading(false); return; } // Cache and update state setCache(cacheKey, data); setTags(data.tags); setLoading(false); }, [cacheKey, collection]); // Initial fetch useEffect(() => { void fetchTags(); }, [fetchTags]); // Group tags by prefix const groupedTags = useMemo(() => groupTags(tags), [tags]); // Render if (loading) { return (
); } if (error) { return (

{error}

); } if (tags.length === 0) { return (
); } return (
{/* Header */}

Tags

{activeTags.length > 0 && ( )}
{/* Tag groups */}
{groupedTags.map((group) => ( ))}
); }