"use client"; import { useCallback, useMemo, type ReactNode } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, CircleDot, FileText, Folder, FolderOpen, FolderPlus, MoreHorizontal, PenTool, Plus, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { getBaseUrl } from "@/lib/trpc"; import { isSidebarNodeExpanded, isSidebarSectionExpanded, useSidebarStore, } from "@/lib/stores/sidebar-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { cn } from "@/lib/utils"; /** Matches server `ObjectTreeNode` shape from objects.getTree */ export type TreeNodeData = { id: string; title: string; type: string; icon: string | null; parentId: string | null; childCount: number; children: TreeNodeData[]; }; export type PartitionedTrees = { projects: TreeNodeData[]; documents: TreeNodeData[]; whiteboards: TreeNodeData[]; }; function hasDescendantType(node: TreeNodeData, type: string): boolean { if (node.type === type) return true; return node.children.some((c) => hasDescendantType(c, type)); } export function partitionRoots(roots: TreeNodeData[]): PartitionedTrees { const projects: TreeNodeData[] = []; const documents: TreeNodeData[] = []; const whiteboards: TreeNodeData[] = []; for (const root of roots) { if (root.type === "project") { projects.push(root); continue; } if (root.type === "whiteboard") { whiteboards.push(root); continue; } if (root.type === "document") { documents.push(root); continue; } if (root.type === "group") { const hasWb = hasDescendantType(root, "whiteboard"); const hasDoc = hasDescendantType(root, "document"); if (hasWb && !hasDoc) { whiteboards.push(root); } else if (hasDoc) { documents.push(root); } else { projects.push(root); } continue; } projects.push(root); } return { projects, documents, whiteboards }; } const EMPTY_PARTITIONED: PartitionedTrees = { projects: [], documents: [], whiteboards: [], }; const MOCK_PARTITIONED: PartitionedTrees = { projects: [ { id: "10000000-0000-4000-8000-000000000001", title: "Project Alpha", type: "project", icon: null, parentId: null, childCount: 2, children: [ { id: "10000000-0000-4000-8000-000000000002", title: "Sprint 1", type: "group", icon: null, parentId: "10000000-0000-4000-8000-000000000001", childCount: 2, children: [ { id: "10000000-0000-4000-8000-000000000003", title: "Task 1", type: "task", icon: null, parentId: "10000000-0000-4000-8000-000000000002", childCount: 0, children: [], }, { id: "10000000-0000-4000-8000-000000000004", title: "Task 2", type: "task", icon: null, parentId: "10000000-0000-4000-8000-000000000002", childCount: 0, children: [], }, ], }, { id: "10000000-0000-4000-8000-000000000005", title: "Sprint 2", type: "group", icon: null, parentId: "10000000-0000-4000-8000-000000000001", childCount: 0, children: [], }, ], }, { id: "10000000-0000-4000-8000-000000000006", title: "Project Beta", type: "project", icon: null, parentId: null, childCount: 0, children: [], }, ], documents: [ { id: "20000000-0000-4000-8000-000000000001", title: "Documents", type: "group", icon: null, parentId: null, childCount: 2, children: [ { id: "20000000-0000-4000-8000-000000000002", title: "Meeting Notes", type: "document", icon: null, parentId: "20000000-0000-4000-8000-000000000001", childCount: 0, children: [], }, { id: "20000000-0000-4000-8000-000000000003", title: "Product Spec", type: "document", icon: null, parentId: "20000000-0000-4000-8000-000000000001", childCount: 0, children: [], }, ], }, ], whiteboards: [ { id: "30000000-0000-4000-8000-000000000001", title: "Whiteboards", type: "group", icon: null, parentId: null, childCount: 1, children: [ { id: "30000000-0000-4000-8000-000000000002", title: "Brainstorm", type: "whiteboard", icon: null, parentId: "30000000-0000-4000-8000-000000000001", childCount: 0, children: [], }, ], }, ], }; function TypeIcon({ type }: { type: string }) { switch (type) { case "project": return ; case "group": return ; case "document": return ; case "whiteboard": return ; case "task": return ; default: return ; } } async function fetchObjectsTree(workspaceId: string): Promise<{ tree: TreeNodeData[] }> { const input = encodeURIComponent(JSON.stringify({ json: { workspaceId } })); const res = await fetch(`${getBaseUrl()}/api/trpc/objects.getTree?input=${input}`, { credentials: "include", headers: { Accept: "application/json" }, }); if (!res.ok) { throw new Error(`getTree failed: ${res.status}`); } const payload = (await res.json()) as unknown; const tree = extractTreeFromTrpcPayload(payload); if (!tree) { throw new Error("getTree: unexpected response shape"); } return { tree }; } function extractTreeFromTrpcPayload(payload: unknown): TreeNodeData[] | null { if (Array.isArray(payload)) { const first = payload[0] as { result?: { data?: { json?: { tree?: TreeNodeData[] } } } }; return first?.result?.data?.json?.tree ?? null; } const single = payload as { result?: { data?: { json?: { tree?: TreeNodeData[] } } } }; return single.result?.data?.json?.tree ?? null; } function useObjectsTreeQuery(workspaceId: string | undefined) { return useQuery({ queryKey: ["objects", "getTree", workspaceId], queryFn: () => fetchObjectsTree(workspaceId!), enabled: Boolean(workspaceId), retry: false, }); } function countNodes(roots: TreeNodeData[]): number { let n = 0; const walk = (nodes: TreeNodeData[]) => { for (const node of nodes) { n += 1; walk(node.children); } }; walk(roots); return n; } function CollapsibleBody({ open, children, }: { open: boolean; children: ReactNode; }) { return (
{children}
); } export function TreeNode({ node, level, collapsed, base, pathname, }: { node: TreeNodeData; level: number; collapsed: boolean; base: string; pathname: string | null; }) { const expandedNodes = useSidebarStore((s) => s.expandedNodes); const toggleNode = useSidebarStore((s) => s.toggleNode); const hasChildren = node.children.length > 0; const expanded = isSidebarNodeExpanded(expandedNodes, node.id); const href = `${base}/o/${node.id}`; const active = pathname === href || (pathname?.startsWith(`${base}/o/${node.id}/`) ?? false); const onToggleExpand = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); toggleNode(node.id); }, [node.id, toggleNode], ); if (collapsed) { const link = ( ); return (
{link} {node.title} {hasChildren ? (
{node.children.map((ch) => ( ))}
) : null}
); } const indentPx = 8 + level * 16; return (
{hasChildren ? ( ) : ( )} {node.title} {node.childCount > 0 ? ( {node.childCount} ) : null}
Add child e.stopPropagation()}> Rename Duplicate Archive
{hasChildren ? (
{node.children.map((ch) => ( ))}
) : null}
); } function SectionHeader({ title, sectionKey, collapsed, }: { title: string; sectionKey: string; collapsed: boolean; }) { const expandedSections = useSidebarStore((s) => s.expandedSections); const toggleSection = useSidebarStore((s) => s.toggleSection); const open = isSidebarSectionExpanded(expandedSections, sectionKey); if (collapsed) { return (
{title}
); } return ( ); } export function NavTree({ collapsed, trees: treesProp, }: { collapsed: boolean; trees?: PartitionedTrees; }) { const pathname = usePathname(); const params = useParams(); const workspace = useWorkspaceStore((s) => s.currentWorkspace); const slugParam = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : ""; const base = workspace?.slug ? `/${workspace.slug}` : slugParam ? `/${slugParam}` : ""; const workspaceId = workspace?.id; const { data, isLoading, isError } = useObjectsTreeQuery(workspaceId); const partitioned = useMemo(() => { if (treesProp) return treesProp; if (!workspaceId) return MOCK_PARTITIONED; if (isLoading) return EMPTY_PARTITIONED; if (isError || !data?.tree) return MOCK_PARTITIONED; return partitionRoots(data.tree); }, [treesProp, workspaceId, isLoading, isError, data?.tree]); const totalCount = countNodes(partitioned.projects) + countNodes(partitioned.documents) + countNodes(partitioned.whiteboards); const liveEmpty = Boolean(workspaceId) && !isLoading && !isError && data?.tree && data.tree.length === 0; const showLoading = Boolean(workspaceId) && isLoading && !treesProp; const expandedSections = useSidebarStore((s) => s.expandedSections); const projectsOpen = isSidebarSectionExpanded(expandedSections, "projects"); const documentsOpen = isSidebarSectionExpanded(expandedSections, "documents"); const whiteboardsOpen = isSidebarSectionExpanded(expandedSections, "whiteboards"); if (showLoading) { return (
); } return (
{liveEmpty ? (
No projects, documents, or whiteboards yet.
Create a project to get started.
) : null} {!liveEmpty && totalCount === 0 ? (
Nothing to show yet.
) : null} {!liveEmpty && totalCount > 0 ? ( <>
{partitioned.projects.map((node) => ( ))}
{!collapsed ? ( ) : (
New Project
)}
{partitioned.documents.map((node) => ( ))}
{partitioned.whiteboards.map((node) => ( ))}
) : null}
); }