"use client"; import { useCallback, useMemo, type ReactNode } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { ChevronRight, CircleDot, FileText, Folder, LayoutGrid, List as ListIcon, 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 type { ObjectType } from "@tasks/shared"; import { api } from "@/lib/trpc"; import { isSidebarNodeExpanded, 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[]; }; export function partitionRoots(roots: TreeNodeData[]): PartitionedTrees { return { projects: roots, documents: [], whiteboards: [], }; } const EMPTY_PARTITIONED: PartitionedTrees = { projects: [], documents: [], whiteboards: [], }; const SPACE_COLORS = [ "bg-amber-500", "bg-blue-500", "bg-emerald-500", "bg-purple-500", "bg-pink-500", "bg-red-500", "bg-cyan-500", "bg-orange-500", ]; function SpaceIcon({ node }: { node: TreeNodeData }) { if (node.icon) { return {node.icon}; } const letter = (node.title || "S").charAt(0).toUpperCase(); const colorIndex = node.title.length % SPACE_COLORS.length; const colorClass = SPACE_COLORS[colorIndex]; return ( {letter} ); } function TypeIcon({ type, node }: { type: string; node?: TreeNodeData }) { switch (type) { case "project": case "space": return node ? : ; case "group": return ; case "task": return ; case "document": return ; case "whiteboard": return ; default: return ; } } 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}
); } function showCreateChildActions(type: string): boolean { return type === "project" || type === "space" || type === "group"; } function hrefForNode(base: string, nodeId: string, nodeType: string): string { switch (nodeType) { case "document": return `${base}/docs/${nodeId}`; case "whiteboard": return `${base}/whiteboards/${nodeId}`; default: return `${base}/${nodeId}`; } } function MoreMenuItems({ node, href, workspaceHandle, }: { node: TreeNodeData; href: string; workspaceHandle: string; }) { const utils = api.useUtils(); const archiveObj = api.objects.archive.useMutation({ onSuccess: () => { void utils.objects.getTree.invalidate(); }, }); const deleteObj = api.objects.delete.useMutation({ onSuccess: () => { void utils.objects.getTree.invalidate(); }, }); const duplicateObj = api.objects.create.useMutation({ onSuccess: () => { void utils.objects.getTree.invalidate(); }, }); const toggleFav = api.favorites.toggle.useMutation({ onSuccess: () => { void utils.favorites.list.invalidate(); }, }); return ( <> { toggleFav.mutate({ objectId: node.id }); }} > Favorite { /* rename - complex, placeholder */ }}>Rename { void navigator.clipboard.writeText(window.location.origin + href); }} > Copy link Color & Icon { duplicateObj.mutate({ workspace: workspaceHandle, type: node.type as ObjectType, title: `${node.title} (copy)`, parentId: node.parentId ?? undefined, }); }} > Duplicate { archiveObj.mutate({ workspace: workspaceHandle, id: node.id }); }} > Archive { if (window.confirm(`Delete "${node.title}"?`)) { deleteObj.mutate({ workspace: workspaceHandle, id: node.id }); } }} > Delete ); } function CreateChildMenu({ parentId, workspaceHandle, }: { parentId: string; workspaceHandle: string; }) { const utils = api.useUtils(); const create = api.objects.create.useMutation({ onSuccess: () => { void utils.objects.getTree.invalidate(); }, }); const handleCreate = (type: ObjectType, title: string) => { create.mutate({ workspace: workspaceHandle, type, title, parentId }); }; return ( <> handleCreate("task", "Untitled List")}>List handleCreate("document", "Untitled Doc")}>Doc handleCreate("group", "Untitled Folder")}>Folder handleCreate("whiteboard", "Untitled Whiteboard")}> Whiteboard ); } export function TreeNode({ node, level, collapsed, base, pathname, workspaceHandle, }: { node: TreeNodeData; level: number; collapsed: boolean; base: string; pathname: string | null; workspaceHandle: string; }) { 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 = hrefForNode(base, node.id, node.type); const active = pathname === href || (pathname?.startsWith(href + "/") ?? false); const showPlus = showCreateChildActions(node.type); 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 = level === 0 ? 0 : 8 + (level - 1) * 16; return (
{hasChildren ? ( ) : ( )} {node.title} {node.childCount > 0 ? ( {node.childCount} ) : null}
{showPlus ? ( e.stopPropagation()}> ) : null} e.stopPropagation()}>
{hasChildren ? (
{node.children.map((ch) => ( ))}
) : null}
); } 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 workspaceHandle = workspace?.slug ?? workspace?.id ?? ""; const favoritesQuery = api.favorites.list.useQuery(undefined, { enabled: Boolean(workspaceHandle), }); const favorites = favoritesQuery.data ?? []; const { data, isLoading, isError } = api.objects.getTree.useQuery( { workspace: workspaceHandle }, { enabled: Boolean(workspaceHandle) }, ); const partitioned = useMemo(() => { if (treesProp) return treesProp; if (!workspace?.id || isLoading) return EMPTY_PARTITIONED; if (isError || !data?.tree) return EMPTY_PARTITIONED; return partitionRoots(data.tree); }, [treesProp, workspace?.id, isLoading, isError, data?.tree]); const totalCount = countNodes(partitioned.projects); const liveEmpty = Boolean(workspace?.id) && !isLoading && !isError && data?.tree && data.tree.length === 0; const showLoading = Boolean(workspace?.id) && isLoading && !treesProp; if (showLoading) { return (
); } return (
{!collapsed ? (
Favorites
{favorites.length === 0 ? (
No favorites yet
) : (
{favorites.map((fav) => { const favHref = hrefForNode(base, fav.objectId, fav.objectType); return ( {fav.objectTitle} ); })}
)}
) : null} {!collapsed ? (
Spaces New Space
) : null} {liveEmpty ? (
No spaces yet.
Create a space to get started.
) : null} {!liveEmpty && totalCount === 0 ? (
Nothing to show yet.
) : null} {!liveEmpty && totalCount > 0 ? (
{partitioned.projects.map((node) => ( ))}
) : null}
); }