File
Blob: src/client/components/sidebar/shared-page-tree.tsx
| 1 | import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react"; |
| 2 | import { ChevronRight, FileText } from "lucide-react"; |
| 3 | import { Skeleton } from "@/client/components/ui/skeleton"; |
| 4 | import { api } from "@/client/lib/api"; |
| 5 | import type { Page } from "@/shared/types"; |
| 6 | import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; |
| 7 | import { EmojiIcon } from "@/client/components/ui/emoji-icon"; |
| 8 | import type { ShareRootPage } from "@/client/lib/share-page-model"; |
| 9 | import { |
| 10 | getSidebarTreeChevronLeft, |
| 11 | getSidebarTreeContentPaddingLeft, |
| 12 | getSidebarTreeStandalonePaddingLeft, |
| 13 | } from "./tree-metrics"; |
| 14 | |
| 15 | interface TreeNodeData { |
| 16 | page: Page; |
| 17 | children: TreeNodeData[] | null; // null = not loaded |
| 18 | expanded: boolean; |
| 19 | } |
| 20 | |
| 21 | interface TreeState { |
| 22 | pagesById: Map<string, Page>; |
| 23 | childrenByParentId: Map<string, string[]>; |
| 24 | expandedIds: Set<string>; |
| 25 | } |
| 26 | |
| 27 | type TreeAction = |
| 28 | | { type: "children-loaded"; parentId: string; children: Page[] } |
| 29 | | { type: "expand"; pageId: string } |
| 30 | | { type: "toggle"; pageId: string }; |
| 31 | |
| 32 | function createTreeState(): TreeState { |
| 33 | return { |
| 34 | pagesById: new Map(), |
| 35 | childrenByParentId: new Map(), |
| 36 | expandedIds: new Set(), |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | function 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 | |
| 69 | function isTreeNode(node: TreeNodeData | null): node is TreeNodeData { |
| 70 | return node !== null; |
| 71 | } |
| 72 | |
| 73 | function 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 | |
| 144 | export 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 | |
| 172 | function 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 | } |