ubiquitous-invention/apps/web/components/sidebar/nav-tree.tsx
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

708 lines
21 KiB
TypeScript

"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 <Folder className="size-3.5 shrink-0 text-amber-600/90 dark:text-amber-400/90" />;
case "group":
return <FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />;
case "document":
return <FileText className="size-3.5 shrink-0 text-muted-foreground" />;
case "whiteboard":
return <PenTool className="size-3.5 shrink-0 text-muted-foreground" />;
case "task":
return <CircleDot className="size-3.5 shrink-0 text-muted-foreground" />;
default:
return <Folder className="size-3.5 shrink-0 text-muted-foreground" />;
}
}
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 (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
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 = (
<Link
href={href}
className={cn(
"flex h-8 w-full items-center justify-center rounded-md text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} />
</Link>
);
return (
<div className="w-full">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px] font-medium">
{node.title}
</TooltipContent>
</Tooltip>
{hasChildren ? (
<div className="flex flex-col gap-px border-l border-sidebar-border/60 pl-1">
{node.children.map((ch) => (
<TreeNode
key={ch.id}
node={ch}
level={level + 1}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
) : null}
</div>
);
}
const indentPx = 8 + level * 16;
return (
<div className="select-none">
<div
className="group relative flex min-h-7 items-center gap-0.5 rounded-md pr-1 transition-colors duration-150"
style={{ paddingLeft: indentPx }}
>
<div className="flex min-h-7 min-w-0 flex-1 items-center gap-0.5">
{hasChildren ? (
<button
type="button"
onClick={onToggleExpand}
className="flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-expanded={expanded}
aria-label={expanded ? "Collapse" : "Expand"}
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-200",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span className="size-6 shrink-0" aria-hidden />
)}
<Link
href={href}
className={cn(
"flex min-h-7 min-w-0 flex-1 items-center gap-2 rounded-sm py-1 pl-0.5 pr-2 text-sm text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent/80 hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} />
<span className="min-w-0 flex-1 truncate font-medium">{node.title}</span>
{node.childCount > 0 ? (
<span className="shrink-0 tabular-nums text-[10px] text-muted-foreground">
{node.childCount}
</span>
) : null}
</Link>
</div>
<div
className={cn(
"pointer-events-none flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity duration-150",
"group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Add child</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem>Rename</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">Archive</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{hasChildren ? (
<CollapsibleBody open={expanded}>
<div className="flex flex-col gap-px pb-0.5">
{node.children.map((ch) => (
<TreeNode
key={ch.id}
node={ch}
level={level + 1}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
) : null}
</div>
);
}
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 (
<div className="flex justify-center py-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:bg-sidebar-accent"
onClick={() => toggleSection(sectionKey)}
>
<ChevronRight
className={cn("size-3.5 transition-transform", open && "rotate-90")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
</div>
);
}
return (
<Button
type="button"
variant="ghost"
className="mb-0.5 flex h-7 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={() => toggleSection(sectionKey)}
>
<span>{title}</span>
<ChevronRight
className={cn("size-3.5 shrink-0 transition-transform duration-200", open && "rotate-90")}
/>
</Button>
);
}
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 (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 px-3 pb-4 pt-2">
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/50" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/40" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/30" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/25" />
</div>
</ScrollArea>
);
}
return (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
{liveEmpty ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
No projects, documents, or whiteboards yet.
<br />
<span className="text-[10px]">Create a project to get started.</span>
</div>
) : null}
{!liveEmpty && totalCount === 0 ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
Nothing to show yet.
</div>
) : null}
{!liveEmpty && totalCount > 0 ? (
<>
<div>
<SectionHeader title="Projects" sectionKey="projects" collapsed={collapsed} />
<CollapsibleBody open={projectsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.projects.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
{!collapsed ? (
<Button
type="button"
variant="ghost"
className="mt-1 h-7 w-full justify-start gap-2 px-2 text-xs font-medium text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={() => {
/* api.objects.create — wire when AppRouter includes objects */
}}
>
<FolderPlus className="size-3.5" />
New Project
</Button>
) : (
<div className="mt-1 flex justify-center">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => {}}
>
<FolderPlus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New Project</TooltipContent>
</Tooltip>
</div>
)}
</CollapsibleBody>
</div>
<div>
<SectionHeader title="Documents" sectionKey="documents" collapsed={collapsed} />
<CollapsibleBody open={documentsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.documents.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
</div>
<div>
<SectionHeader title="Whiteboards" sectionKey="whiteboards" collapsed={collapsed} />
<CollapsibleBody open={whiteboardsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.whiteboards.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
</div>
</>
) : null}
</div>
</ScrollArea>
);
}