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
708 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|