Skip to content
File

Blob: src/client/components/sidebar/shared-page-tree.tsx

typescript329 lines
1import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react";
2import { ChevronRight, FileText } from "lucide-react";
3import { Skeleton } from "@/client/components/ui/skeleton";
4import { api } from "@/client/lib/api";
5import type { Page } from "@/shared/types";
6import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
7import { EmojiIcon } from "@/client/components/ui/emoji-icon";
8import type { ShareRootPage } from "@/client/lib/share-page-model";
9import {
10 getSidebarTreeChevronLeft,
11 getSidebarTreeContentPaddingLeft,
12 getSidebarTreeStandalonePaddingLeft,
13} from "./tree-metrics";
14 
15interface TreeNodeData {
16 page: Page;
17 children: TreeNodeData[] | null; // null = not loaded
18 expanded: boolean;
19}
20 
21interface TreeState {
22 pagesById: Map<string, Page>;
23 childrenByParentId: Map<string, string[]>;
24 expandedIds: Set<string>;
25}
26 
27type TreeAction =
28 | { type: "children-loaded"; parentId: string; children: Page[] }
29 | { type: "expand"; pageId: string }
30 | { type: "toggle"; pageId: string };
31 
32function createTreeState(): TreeState {
33 return {
34 pagesById: new Map(),
35 childrenByParentId: new Map(),
36 expandedIds: new Set(),
37 };
38}
39 
40function treeReducer(state: TreeState, action: TreeAction): TreeState {
41 switch (action.type) {
42 case "children-loaded": {
43 const pagesById = new Map(state.pagesById);
44 const childrenByParentId = new Map(state.childrenByParentId);
45 for (const child of action.children) {
46 pagesById.set(child.id, child);
47 }
48 childrenByParentId.set(
49 action.parentId,
50 action.children.map((child) => child.id),
51 );
52 return { ...state, pagesById, childrenByParentId };
53 }
54 case "expand": {
55 if (state.expandedIds.has(action.pageId)) return state;
56 const expandedIds = new Set(state.expandedIds);
57 expandedIds.add(action.pageId);
58 return { ...state, expandedIds };
59 }
60 case "toggle": {
61 const expandedIds = new Set(state.expandedIds);
62 if (expandedIds.has(action.pageId)) expandedIds.delete(action.pageId);
63 else expandedIds.add(action.pageId);
64 return { ...state, expandedIds };
65 }
66 }
67}
68 
69function isTreeNode(node: TreeNodeData | null): node is TreeNodeData {
70 return node !== null;
71}
72 
73function TreeNode({
74 node,
75 depth,
76 activePageId,
77 onNavigate,
78 onToggle,
79}: {
80 node: TreeNodeData;
81 depth: number;
82 activePageId: string;
83 onNavigate: (pageId: string) => void;
84 onToggle: (pageId: string) => void;
85}) {
86 const isActive = node.page.id === activePageId;
87 const showChevron = node.children === null || node.children.length > 0;
88 const rowPaddingLeft = getSidebarTreeContentPaddingLeft(depth);
89 const chevronLeft = getSidebarTreeChevronLeft(depth);
90 
91 return (
92 <div>
93 <div
94 className={`relative flex h-8 w-full items-center gap-1 rounded-md text-sm transition-colors ${
95 isActive ? "bg-accent-500/10 text-accent-400" : "text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200"
96 }`}
97 style={{ paddingLeft: rowPaddingLeft }}
98 >
99 {showChevron && (
100 <button
101 onClick={() => onToggle(node.page.id)}
102 className="absolute top-1/2 flex h-5 w-4 -translate-y-1/2 items-center justify-center rounded text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-300"
103 style={{ left: chevronLeft }}
104 aria-label={node.expanded ? "Collapse" : "Expand"}
105 >
106 <ChevronRight className={`h-3 w-3 transition-transform ${node.expanded ? "rotate-90" : ""}`} />
107 </button>
108 )}
109 <button onClick={() => onNavigate(node.page.id)} className="flex min-w-0 flex-1 items-center gap-1 text-left">
110 <span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs">
111 {node.page.icon ? <EmojiIcon emoji={node.page.icon} size={14} /> : <FileText className="h-3.5 w-3.5" />}
112 </span>
113 <span className="truncate">{node.page.title || DEFAULT_PAGE_TITLE}</span>
114 </button>
115 </div>
116 {node.expanded &&
117 (node.children === null ? (
118 <div className="space-y-1 py-0.5" style={{ paddingLeft: getSidebarTreeContentPaddingLeft(depth + 1) }}>
119 <div className="flex h-8 items-center gap-1">
120 <Skeleton className="h-5 w-5 shrink-0 rounded" />
121 <Skeleton className="h-3.5 w-3/4" />
122 </div>
123 <div className="flex h-8 items-center gap-1">
124 <Skeleton className="h-5 w-5 shrink-0 rounded" />
125 <Skeleton className="h-3.5 w-1/2" />
126 </div>
127 </div>
128 ) : (
129 node.children.map((child) => (
130 <TreeNode
131 key={child.page.id}
132 node={child}
133 depth={depth + 1}
134 activePageId={activePageId}
135 onNavigate={onNavigate}
136 onToggle={onToggle}
137 />
138 ))
139 ))}
140 </div>
141 );
142}
143 
144export function SharedPageTree({
145 workspaceId,
146 rootPage,
147 shareToken,
148 activePageId,
149 autoExpandPathIds,
150 onNavigate,
151}: {
152 workspaceId: string;
153 rootPage: ShareRootPage;
154 shareToken: string;
155 activePageId: string;
156 autoExpandPathIds: string[];
157 onNavigate: (pageId: string) => void;
158}) {
159 return (
160 <SharedPageTreeForScope
161 key={`${workspaceId}:${shareToken}:${rootPage.id}`}
162 workspaceId={workspaceId}
163 rootPage={rootPage}
164 shareToken={shareToken}
165 activePageId={activePageId}
166 autoExpandPathIds={autoExpandPathIds}
167 onNavigate={onNavigate}
168 />
169 );
170}
171 
172function SharedPageTreeForScope({
173 workspaceId,
174 rootPage,
175 shareToken,
176 activePageId,
177 autoExpandPathIds,
178 onNavigate,
179}: {
180 workspaceId: string;
181 rootPage: ShareRootPage;
182 shareToken: string;
183 activePageId: string;
184 autoExpandPathIds: string[];
185 onNavigate: (pageId: string) => void;
186}) {
187 const [treeState, dispatch] = useReducer(treeReducer, undefined, createTreeState);
188 const lastAutoExpandPathKeyRef = useRef<string | null>(null);
189 const autoExpandPathKey = autoExpandPathIds.join("\u0000");
190 const rootChildIds = treeState.childrenByParentId.get(rootPage.id) ?? null;
191 
192 const nodesById = useMemo(() => {
193 const map = new Map<string, TreeNodeData>();
194 function toNode(pageId: string): TreeNodeData | null {
195 const page = treeState.pagesById.get(pageId);
196 if (!page) return null;
197 const childIds = treeState.childrenByParentId.get(page.id) ?? null;
198 let children: TreeNodeData[] | null = null;
199 if (childIds) {
200 children = childIds.map(toNode).filter(isTreeNode);
201 }
202 const expanded = treeState.expandedIds.has(page.id);
203 const node = { page, children, expanded };
204 map.set(page.id, node);
205 return node;
206 }
207 for (const childId of rootChildIds ?? []) {
208 toNode(childId);
209 }
210 return map;
211 }, [rootChildIds, treeState]);
212 
213 const loadChildren = useCallback(
214 async (parentId: string) => {
215 try {
216 const children = await api.pages.children(workspaceId, parentId, shareToken);
217 dispatch({ type: "children-loaded", parentId, children });
218 return children;
219 } catch {
220 return [];
221 }
222 },
223 [shareToken, workspaceId],
224 );
225 
226 const getLoadedChildIds = useEffectEvent((parentId: string) => treeState.childrenByParentId.get(parentId) ?? null);
227 
228 useEffect(() => {
229 void loadChildren(rootPage.id);
230 }, [loadChildren, rootPage.id]);
231 
232 useEffect(() => {
233 if (rootChildIds === null) return;
234 if (lastAutoExpandPathKeyRef.current === autoExpandPathKey) return;
235 if (!autoExpandPathKey) {
236 lastAutoExpandPathKeyRef.current = autoExpandPathKey;
237 return;
238 }
239 
240 let cancelled = false;
241 
242 async function expandActivePath() {
243 let visibleChildIds: readonly string[] = rootChildIds ?? [];
244 
245 for (const pageId of autoExpandPathIds) {
246 if (cancelled) return;
247 if (!visibleChildIds.includes(pageId)) return;
248 
249 dispatch({ type: "expand", pageId });
250 
251 let childIds = getLoadedChildIds(pageId);
252 if (!childIds) {
253 const children = await loadChildren(pageId);
254 if (cancelled) return;
255 childIds = children.map((child) => child.id);
256 }
257 visibleChildIds = childIds;
258 }
259 }
260 
261 void expandActivePath().finally(() => {
262 if (!cancelled) {
263 lastAutoExpandPathKeyRef.current = autoExpandPathKey;
264 }
265 });
266 
267 return () => {
268 cancelled = true;
269 };
270 }, [autoExpandPathIds, autoExpandPathKey, loadChildren, rootChildIds]);
271 
272 const handleToggle = useCallback(
273 (pageId: string) => {
274 const childrenLoaded = treeState.childrenByParentId.has(pageId);
275 dispatch({ type: "toggle", pageId });
276 if (!childrenLoaded) {
277 void loadChildren(pageId);
278 }
279 },
280 [loadChildren, treeState.childrenByParentId],
281 );
282 
283 return (
284 <nav className="w-[260px] shrink-0 overflow-y-auto border-r border-zinc-800/60 bg-zinc-900 px-2 py-4">
285 <button
286 onClick={() => onNavigate(rootPage.id)}
287 className={`mb-1 flex h-8 w-full items-center gap-1 rounded-md text-left text-sm font-medium transition-colors ${
288 activePageId === rootPage.id
289 ? "bg-accent-500/10 text-accent-400"
290 : "text-zinc-300 hover:bg-zinc-800/50 hover:text-zinc-200"
291 }`}
292 style={{ paddingLeft: getSidebarTreeStandalonePaddingLeft(0) }}
293 >
294 <span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs">
295 {rootPage.icon ? <EmojiIcon emoji={rootPage.icon} size={14} /> : <FileText className="h-3.5 w-3.5" />}
296 </span>
297 <span className="truncate">{rootPage.title || DEFAULT_PAGE_TITLE}</span>
298 </button>
299 {rootChildIds === null ? (
300 <div className="space-y-1 px-2 pt-1">
301 <div className="flex h-8 items-center gap-1">
302 <Skeleton className="h-5 w-5 shrink-0 rounded" />
303 <Skeleton className="h-3.5 w-3/4" />
304 </div>
305 <div className="flex h-8 items-center gap-1">
306 <Skeleton className="h-5 w-5 shrink-0 rounded" />
307 <Skeleton className="h-3.5 w-1/2" />
308 </div>
309 </div>
310 ) : (
311 rootChildIds.map((id) => {
312 const node = nodesById.get(id);
313 if (!node) return null;
314 return (
315 <TreeNode
316 key={id}
317 node={node}
318 depth={0}
319 activePageId={activePageId}
320 onNavigate={onNavigate}
321 onToggle={handleToggle}
322 />
323 );
324 })
325 )}
326 </nav>
327 );
328}