"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 (
);
}
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}
);
}