'use client' import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Copy, Eye, EyeOff, EyeOff as EyeOffIcon, FolderTree, Loader2, Package, Pencil, Plus, Power, PowerOff, Search, ToggleLeft, ToggleRight, Trash2, X, } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { translateCaption } from '@/lib/catalog-translations' import { cn } from '@/lib/utils' /* ─── Types ──────────────────────────────────────────────── */ interface TreeNode { id: number caption: string parentId: number enabled: string visible: string iconImage: number iconColor: number pageLayout: string childCount: number itemCount: number } interface CatalogTreeProps { rootPages: TreeNode[] catalogType?: 'normal' | 'bc' } /* ─── Context for expand/collapse all ────────────────────── */ interface TreeContext { expandSignal: number collapseSignal: number filterMode: FilterMode onToggleEnabled: (node: TreeNode) => void onToggleVisible: (node: TreeNode) => void onDeletePage: (node: TreeNode) => void searchQuery: string catalogType: 'normal' | 'bc' } const TreeCtx = createContext({ expandSignal: 0, collapseSignal: 0, filterMode: 'all', onToggleEnabled: () => {}, onToggleVisible: () => {}, onDeletePage: () => {}, searchQuery: '', catalogType: 'normal', }) /* ─── Catalog Icon ───────────────────────────────────────── */ export function CatalogIcon({ iconImage, size = 20 }: { iconImage: number; size?: number }) { const [error, setError] = useState(false) if (error || iconImage <= 0) { return } return ( setError(true)} /> ) } /* ─── Layout label helper ────────────────────────────────── */ export const LAYOUT_COLORS: Record = { default_3x3: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]', frontpage: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]', spaces_new: 'bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]', pets: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]', pets2: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]', pets3: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]', marketplace: 'bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]', recycler: 'bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]', club_buy: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]', single_bundle: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]', room_bundle: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]', } /* ─── Highlight matched text ─────────────────────────────── */ function HighlightText({ text, query }: { text: string; query: string }) { if (!query.trim()) return <>{text} const idx = text.toLowerCase().indexOf(query.toLowerCase()) if (idx === -1) return <>{text} return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ) } /* ─── Main Tree Component ────────────────────────────────── */ type FilterMode = 'all' | 'enabled' | 'disabled' export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreeProps) { const router = useRouter() const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState(null) const [searching, setSearching] = useState(false) const searchTimeout = useRef | null>(null) const searchInputRef = useRef(null) const [expandSignal, setExpandSignal] = useState(0) const [collapseSignal, setCollapseSignal] = useState(0) const [filterMode, setFilterMode] = useState('all') // "/" keyboard shortcut to focus search useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return e.preventDefault() searchInputRef.current?.focus() } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, []) // Debounced search // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (!searchQuery.trim()) { setSearchResults(null) return } if (searchTimeout.current) clearTimeout(searchTimeout.current) searchTimeout.current = setTimeout(async () => { setSearching(true) try { const catParam = catalogType === 'bc' ? '&catalog=bc' : '' const res = await fetch( `/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`, ) if (res.ok) { const data = await res.json() setSearchResults(data.pages || []) } } catch (error) { console.error('[CatalogTree] Search failed:', error) } finally { setSearching(false) } }, 300) return () => { if (searchTimeout.current) clearTimeout(searchTimeout.current) } }, [searchQuery]) // Quick actions from context menu const catParam = catalogType === 'bc' ? '&catalog=bc' : '' const catBody = catalogType === 'bc' ? ', "catalog": "bc"' : '' const handleToggleEnabled = useCallback( async (node: TreeNode) => { try { const res = await fetch('/api/admin/catalog/tree', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`, }) if (!res.ok) throw new Error() toast.success(`${node.caption} ${node.enabled === '1' ? 'disabled' : 'enabled'}`) router.refresh() } catch { toast.error('Failed to toggle. Open the page to edit.') } }, [router, catBody], ) const handleToggleVisible = useCallback( async (node: TreeNode) => { try { const res = await fetch('/api/admin/catalog/tree', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`, }) if (!res.ok) throw new Error() toast.success(`${node.caption} ${node.visible === '1' ? 'hidden' : 'visible'}`) router.refresh() } catch { toast.error('Failed to toggle. Open the page to edit.') } }, [router, catBody], ) const handleDeletePage = useCallback( async (node: TreeNode) => { const childInfo = node.childCount > 0 ? ` Its ${node.childCount} child page(s) will be moved to parent.` : '' const itemInfo = node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : '' if (!confirm(`Delete "${node.caption}"?${childInfo}${itemInfo}`)) return try { const res = await fetch( `/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`, { method: 'DELETE' }, ) if (!res.ok) throw new Error() toast.success(`"${node.caption}" deleted`) router.refresh() } catch { toast.error('Failed to delete page.') } }, [router, catParam], ) const unfilteredPages = searchResults ?? rootPages const displayPages = filterMode === 'all' ? unfilteredPages : unfilteredPages.filter((p) => filterMode === 'enabled' ? p.enabled === '1' : p.enabled !== '1', ) return (
{/* Header */}

Catalog Structure

{displayPages.length} {filterMode !== 'all' ? ` / ${unfilteredPages.length}` : ''} pages
{/* Status filter */}

Show all pages

Show enabled only

Show disabled only

{/* Expand / Collapse all */} {searchResults === null && ( <>

Expand all

Collapse all

)} {/* Search */}
setSearchQuery(e.target.value)} className="h-8 pl-8 pr-8 text-sm" onKeyDown={(e) => { if (e.key === 'Escape') { setSearchQuery('') searchInputRef.current?.blur() } }} /> {searching ? ( ) : ( searchQuery && ( ) )}
{/* Tree body */}
{searchResults !== null && (

{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for " {searchQuery}"

)} {displayPages.map((page) => ( ))} {displayPages.length === 0 && (

{searchResults !== null ? 'No pages match your search' : 'No catalog pages found'}

)}
) } /* ─── Tree Item ──────────────────────────────────────────── */ function TreeItem({ node, level, // biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API isSearchResult, defaultExpanded, }: { node: TreeNode level: number isSearchResult?: boolean defaultExpanded?: boolean }) { const { expandSignal, collapseSignal, filterMode, searchQuery, onToggleEnabled, onToggleVisible, onDeletePage, catalogType, } = useContext(TreeCtx) const router = useRouter() const [expanded, setExpanded] = useState(false) const [children, setChildren] = useState([]) const [loading, setLoading] = useState(false) const [loaded, setLoaded] = useState(false) const hasChildren = node.childCount > 0 // Auto-expand root level on mount // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (defaultExpanded && level === 0 && hasChildren && !loaded) { loadChildren().then(() => setExpanded(true)) } }, []) // Expand all signal // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (expandSignal > 0 && hasChildren) { if (!loaded) { loadChildren().then(() => setExpanded(true)) } else { setExpanded(true) } } }, [expandSignal]) // Collapse all signal useEffect(() => { if (collapseSignal > 0) { setExpanded(false) } }, [collapseSignal]) const loadChildren = useCallback(async () => { if (loaded || !hasChildren) return setLoading(true) try { const catParam = catalogType === 'bc' ? '&catalog=bc' : '' const res = await fetch(`/api/admin/catalog/tree?parentId=${node.id}${catParam}`) if (res.ok) { const data = await res.json() setChildren(data.pages || []) setLoaded(true) } } catch (error) { console.error('[CatalogTree] Failed to load children:', error) } finally { setLoading(false) } }, [node.id, loaded, hasChildren, catalogType]) const handleToggle = () => { if (!expanded && !loaded) { loadChildren().then(() => setExpanded(true)) return } setExpanded(!expanded) } const isDisabled = node.enabled !== '1' const isHidden = node.visible !== '1' const isEmpty = node.itemCount === 0 && node.childCount === 0 const layoutColor = LAYOUT_COLORS[node.pageLayout] || 'bg-muted text-muted-foreground' const italianName = translateCaption(node.caption) const displayName = italianName ?? node.caption return (
{/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */}
router.push( catalogType === 'bc' ? `/admin/catalog/builder-club/${node.id}` : `/admin/catalog/${node.id}`, ) } onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() router.push( catalogType === 'bc' ? `/admin/catalog/builder-club/${node.id}` : `/admin/catalog/${node.id}`, ) } }} > {/* Expand/collapse toggle */} {hasChildren ? ( ) : ( )} {/* Catalog icon */} {/* Caption link */} {italianName && ( )} {/* Quick toggle enabled on hover */}

{isDisabled ? 'Enable' : 'Disable'}

{/* Quick add subpage */}

Create subpage

{/* Status indicators */}
{isHidden && } {isDisabled && ( OFF )} {node.itemCount > 0 && ( {node.itemCount} )} {/* Layout badge on hover */} {node.pageLayout.replace('default_3x3', '3x3')}

{node.caption} #{node.id}

Layout {node.pageLayout}
Items {node.itemCount}
Subpages {node.childCount}
Icon {node.iconImage}
Visible {node.visible === '1' ? 'Yes' : 'No'}
Enabled {node.enabled === '1' ? 'Yes' : 'No'}

Right-click for actions · Double-click to open

{/* Right-click context menu */} {node.caption} #{node.id} router.push( catalogType === 'bc' ? `/admin/catalog/builder-club/${node.id}` : `/admin/catalog/${node.id}`, ) } > Edit Page navigator.clipboard.writeText(String(node.id))}> Copy ID onToggleEnabled(node)}> {node.enabled === '1' ? ( ) : ( )} {node.enabled === '1' ? 'Disable' : 'Enable'} onToggleVisible(node)}> {node.visible === '1' ? ( ) : ( )} {node.visible === '1' ? 'Hide' : 'Show'} router.push( catalogType === 'bc' ? `/admin/catalog/builder-club/${node.id}` : `/admin/catalog?newPageParent=${node.id}`, ) } > Create Subpage onDeletePage(node)}> Delete
{/* Children */} {expanded && children.length > 0 && (() => { const filteredChildren = filterMode === 'all' ? children : children.filter((c) => filterMode === 'enabled' ? c.enabled === '1' : c.enabled !== '1', ) return filteredChildren.length > 0 ? (
{/* Tree guide line */}
{filteredChildren.map((child) => ( ))}
) : null })()}
) }