Skip to content
File

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

typescript461 lines
1import { lazy, Suspense, useCallback, useRef, useState } from "react";
2import { Link, useNavigate, useParams } from "@tanstack/react-router";
3import { useQueryClient } from "@tanstack/react-query";
4 
5import {
6 ArrowDown,
7 ArrowUp,
8 ChevronRight,
9 FileText,
10 ListIndentDecrease,
11 ListIndentIncrease,
12 MoreHorizontal,
13 Move,
14 PenTool,
15 Plus,
16 Trash2,
17} from "lucide-react";
18import type { Page } from "@/shared/types";
19import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
20import { api, toApiError } from "@/client/lib/api";
21import { useCurrentWorkspace } from "@/client/components/workspace/use-workspace-view";
22import { replicaCommands } from "@/client/stores/db/workspace-replica";
23import { useAuthStore } from "@/client/stores/auth-store";
24import {
25 getActiveDescendantIds,
26 getArchivePageConfirmMessage,
27 shouldNavigateAwayAfterArchive,
28} from "@/client/lib/page-archive";
29import { useCreatePage } from "@/client/hooks/use-create-page";
30import { EmojiIcon } from "@/client/components/ui/emoji-icon";
31import { confirm } from "@/client/components/confirm-store";
32import { DropdownPortal } from "@/client/components/ui/dropdown-portal";
33import { deriveSidebarRowAffordance } from "@/client/lib/affordance/sidebar";
34import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state";
35import {
36 resolveIndent,
37 resolveMoveDown,
38 resolveMoveUp,
39 resolveOutdent,
40 type MoveResolution,
41 type MoveResult,
42 type PageTreeIndex,
43} from "@/client/lib/page-tree-model";
44import { toast } from "@/client/components/toast-store";
45import { archivedPagesQueryKey } from "@/client/lib/queries/archived-pages";
46const SidebarMoveDialog = lazy(() =>
47 import("./sidebar-move-dialog").then((mod) => ({ default: mod.SidebarMoveDialog })),
48);
49import { getSidebarTreeChevronLeft, getSidebarTreeContentPaddingLeft } from "./tree-metrics";
50 
51interface PageTreeItemProps {
52 page: Page;
53 depth: number;
54 index: PageTreeIndex;
55 allPages: Page[];
56 alwaysShowActions: boolean;
57 activeAncestorIds: Set<string>;
58 menuZIndex?: number;
59 workspaceRole: "owner" | "admin" | "member" | "guest" | "none";
60 online: boolean;
61}
62 
63const EMPTY_CHILDREN: readonly never[] = [];
64 
65const MENU_ITEM_CLASS =
66 "group flex min-h-8 w-full items-center gap-2 rounded px-2 py-1.5 text-[13px] text-left transition-[background-color,color] focus-visible:outline-none disabled:opacity-50 disabled:hover:bg-transparent";
67const MENU_NEUTRAL_ITEM_CLASS =
68 "text-zinc-300 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:bg-zinc-700 focus-visible:text-zinc-100 disabled:hover:text-zinc-300";
69const MENU_DANGER_ITEM_CLASS =
70 "text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:bg-red-500/10 focus-visible:text-red-300 disabled:hover:text-red-400";
71const MENU_ICON_CLASS =
72 "flex w-4 shrink-0 items-center justify-center text-zinc-400 transition-colors group-hover:text-current group-focus-visible:text-current";
73const MENU_DANGER_ICON_CLASS = MENU_ICON_CLASS;
74const MENU_LABEL_CLASS = "flex-1";
75const MENU_SEPARATOR_CLASS = "my-1 h-px bg-zinc-800";
76 
77export function PageTreeItem({
78 page,
79 depth,
80 index,
81 allPages,
82 alwaysShowActions,
83 activeAncestorIds,
84 menuZIndex,
85 workspaceRole,
86 online,
87}: PageTreeItemProps) {
88 const childPages = index.childrenByParent.get(page.id) ?? EMPTY_CHILDREN;
89 const params = useParams({ strict: false }) as {
90 workspaceSlug?: string;
91 pageId?: string;
92 };
93 const navigate = useNavigate();
94 const queryClient = useQueryClient();
95 const currentWorkspace = useCurrentWorkspace();
96 const currentUser = useAuthStore((s) => s.user);
97 const rowAffordance = deriveSidebarRowAffordance({
98 workspaceRole,
99 ownsPage: currentUser?.id === page.created_by,
100 online,
101 });
102 const { createPage, isCreating: creating } = useCreatePage();
103 const isActive = params.pageId === page.id;
104 const shouldExpand = activeAncestorIds.has(page.id) || isActive;
105 const [userExpanded, setUserExpanded] = useState(shouldExpand);
106 const [prevShouldExpand, setPrevShouldExpand] = useState(shouldExpand);
107 // Auto-expand when becoming active or hosting an active descendant; user
108 // toggles still win until shouldExpand transitions again. Pattern from
109 // https://react.dev/reference/react/useState#storing-information-from-previous-renders
110 if (shouldExpand !== prevShouldExpand) {
111 setPrevShouldExpand(shouldExpand);
112 if (shouldExpand) setUserExpanded(true);
113 }
114 const isExpanded = userExpanded;
115 const [menuOpen, setMenuOpen] = useState(false);
116 const [moveDialogOpen, setMoveDialogOpen] = useState(false);
117 const [archiving, setArchiving] = useState(false);
118 const [moving, setMoving] = useState(false);
119 const menuRef = useRef<HTMLDivElement>(null);
120 const hasChildren = childPages.length > 0;
121 const moreVisibility =
122 alwaysShowActions || menuOpen || isActive ? "opacity-100" : "opacity-40 group-hover:opacity-100";
123 const addVisibility = alwaysShowActions || menuOpen || isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100";
124 
125 const toggleExpand = useCallback((e: React.MouseEvent) => {
126 e.preventDefault();
127 e.stopPropagation();
128 setUserExpanded((v) => !v);
129 }, []);
130 
131 const executeMove = useCallback(
132 async (resolution: MoveResolution) => {
133 if (!currentWorkspace || moving) return;
134 setMoving(true);
135 setMenuOpen(false);
136 const previousParentId = page.parent_id;
137 const previousPosition = page.position;
138 await replicaCommands.patchPage(currentWorkspace.id, page.id, {
139 parent_id: resolution.proposal.parentId,
140 position: resolution.proposal.position,
141 });
142 try {
143 const updated = await api.pages.update(currentWorkspace.id, page.id, {
144 parent_id: resolution.proposal.parentId,
145 position: resolution.proposal.position,
146 });
147 await replicaCommands.upsertPage(currentWorkspace.id, updated);
148 } catch (err) {
149 await replicaCommands.patchPage(currentWorkspace.id, page.id, {
150 parent_id: previousParentId,
151 position: previousPosition,
152 });
153 toast.error(toApiError(err).message || "Failed to move page");
154 throw err;
155 } finally {
156 setMoving(false);
157 }
158 },
159 [currentWorkspace, moving, page.id, page.parent_id, page.position],
160 );
161 
162 const handleArchive = useCallback(
163 async (e: React.MouseEvent) => {
164 e.preventDefault();
165 e.stopPropagation();
166 if (!currentWorkspace || archiving) return;
167 setMenuOpen(false);
168 const activeDescendantIds = getActiveDescendantIds(index, page.id);
169 const ok = await confirm({
170 title: "Archive page",
171 message: getArchivePageConfirmMessage(page.title, activeDescendantIds.length),
172 });
173 if (!ok) return;
174 setArchiving(true);
175 try {
176 const archived = await api.pages.delete(currentWorkspace.id, page.id);
177 await replicaCommands.removePages(currentWorkspace.id, archived.archived_page_ids);
178 await queryClient.invalidateQueries({ queryKey: archivedPagesQueryKey(currentWorkspace.id) });
179 if (shouldNavigateAwayAfterArchive(params.pageId, archived.archived_page_ids)) {
180 navigate({ to: "/$workspaceSlug", params: { workspaceSlug: params.workspaceSlug || currentWorkspace.slug } });
181 }
182 } catch (err) {
183 toast.error(toApiError(err).message || "Failed to archive page");
184 } finally {
185 setArchiving(false);
186 setMenuOpen(false);
187 }
188 },
189 [
190 currentWorkspace,
191 archiving,
192 page.id,
193 page.title,
194 index,
195 params.pageId,
196 params.workspaceSlug,
197 navigate,
198 queryClient,
199 ],
200 );
201 
202 const handleCreateSubpage = useCallback(
203 (e: React.MouseEvent) => {
204 e.preventDefault();
205 e.stopPropagation();
206 createPage({ parentId: page.id, onCreated: () => setUserExpanded(true) });
207 },
208 [createPage, page.id],
209 );
210 
211 const openMoveDialog = useCallback(
212 (e: React.MouseEvent) => {
213 e.preventDefault();
214 e.stopPropagation();
215 if (!isActionEnabled(rowAffordance.movePage)) return;
216 setMenuOpen(false);
217 setMoveDialogOpen(true);
218 },
219 [rowAffordance.movePage],
220 );
221 
222 const runQuickMove = useCallback(
223 (e: React.MouseEvent, result: MoveResult) => {
224 e.preventDefault();
225 e.stopPropagation();
226 if (!isActionEnabled(rowAffordance.movePage) || !result.ok) return;
227 void executeMove(result);
228 },
229 [executeMove, rowAffordance.movePage],
230 );
231 
232 const rowPaddingLeft = getSidebarTreeContentPaddingLeft(depth);
233 const chevronLeft = getSidebarTreeChevronLeft(depth);
234 
235 const renderActionsMenu = () => {
236 const moveUp = resolveMoveUp(allPages, page, index);
237 const moveDown = resolveMoveDown(allPages, page, index);
238 const indent = resolveIndent(allPages, page, index);
239 const outdent = resolveOutdent(allPages, page, index);
240 const moveTitle = rowAffordance.movePage.kind === "disabled" ? rowAffordance.movePage.reason : undefined;
241 const moveDisabledReason = (result: MoveResult) =>
242 rowAffordance.movePage.kind === "disabled"
243 ? rowAffordance.movePage.reason
244 : result.ok
245 ? undefined
246 : result.message;
247 return (
248 <DropdownPortal
249 triggerRef={menuRef}
250 zIndex={menuZIndex}
251 width={208}
252 className="p-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
253 onClose={() => setMenuOpen(false)}
254 >
255 <div role="menu" aria-label="Page actions">
256 {isActionVisible(rowAffordance.movePage) && (
257 <>
258 <button
259 role="menuitem"
260 onMouseDown={(e) => e.preventDefault()}
261 onClick={openMoveDialog}
262 disabled={!isActionEnabled(rowAffordance.movePage)}
263 className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`}
264 title={moveTitle}
265 >
266 <span className={MENU_ICON_CLASS}>
267 <Move className="h-3.5 w-3.5" />
268 </span>
269 <span className={MENU_LABEL_CLASS}>Move…</span>
270 </button>
271
272 <div className={MENU_SEPARATOR_CLASS} role="separator" />
273
274 <button
275 role="menuitem"
276 onMouseDown={(e) => e.preventDefault()}
277 onClick={(e) => runQuickMove(e, moveUp)}
278 disabled={!isActionEnabled(rowAffordance.movePage) || !moveUp.ok}
279 className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`}
280 title={moveDisabledReason(moveUp)}
281 >
282 <span className={MENU_ICON_CLASS}>
283 <ArrowUp className="h-3.5 w-3.5" />
284 </span>
285 <span className={MENU_LABEL_CLASS}>Move up</span>
286 </button>
287 <button
288 role="menuitem"
289 onMouseDown={(e) => e.preventDefault()}
290 onClick={(e) => runQuickMove(e, moveDown)}
291 disabled={!isActionEnabled(rowAffordance.movePage) || !moveDown.ok}
292 className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`}
293 title={moveDisabledReason(moveDown)}
294 >
295 <span className={MENU_ICON_CLASS}>
296 <ArrowDown className="h-3.5 w-3.5" />
297 </span>
298 <span className={MENU_LABEL_CLASS}>Move down</span>
299 </button>
300
301 <div className={MENU_SEPARATOR_CLASS} role="separator" />
302
303 <button
304 role="menuitem"
305 onMouseDown={(e) => e.preventDefault()}
306 onClick={(e) => runQuickMove(e, indent)}
307 disabled={!isActionEnabled(rowAffordance.movePage) || !indent.ok}
308 className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`}
309 title={moveDisabledReason(indent)}
310 >
311 <span className={MENU_ICON_CLASS}>
312 <ListIndentIncrease className="h-3.5 w-3.5" />
313 </span>
314 <span className={MENU_LABEL_CLASS}>Indent</span>
315 </button>
316 <button
317 role="menuitem"
318 onMouseDown={(e) => e.preventDefault()}
319 onClick={(e) => runQuickMove(e, outdent)}
320 disabled={!isActionEnabled(rowAffordance.movePage) || !outdent.ok}
321 className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`}
322 title={moveDisabledReason(outdent)}
323 >
324 <span className={MENU_ICON_CLASS}>
325 <ListIndentDecrease className="h-3.5 w-3.5" />
326 </span>
327 <span className={MENU_LABEL_CLASS}>Outdent</span>
328 </button>
329 </>
330 )}
331
332 {isActionVisible(rowAffordance.movePage) && isActionVisible(rowAffordance.archivePage) && (
333 <div className={MENU_SEPARATOR_CLASS} role="separator" />
334 )}
335
336 {isActionVisible(rowAffordance.archivePage) && (
337 <button
338 role="menuitem"
339 onMouseDown={(e) => e.preventDefault()}
340 onClick={handleArchive}
341 disabled={archiving || !isActionEnabled(rowAffordance.archivePage)}
342 className={`${MENU_ITEM_CLASS} ${MENU_DANGER_ITEM_CLASS}`}
343 title={rowAffordance.archivePage.kind === "disabled" ? rowAffordance.archivePage.reason : undefined}
344 >
345 <span className={MENU_DANGER_ICON_CLASS}>
346 <Trash2 className="h-3.5 w-3.5" />
347 </span>
348 <span className={MENU_LABEL_CLASS}>{archiving ? "Archiving..." : "Archive"}</span>
349 </button>
350 )}
351 </div>
352 </DropdownPortal>
353 );
354 };
355 
356 return (
357 <div>
358 <Link
359 to="/$workspaceSlug/$pageId"
360 params={{ workspaceSlug: params.workspaceSlug || currentWorkspace?.slug || "", pageId: page.id }}
361 data-page-row
362 data-page-id={page.id}
363 data-depth={depth}
364 className={`group relative flex h-8 items-center gap-1 rounded-md px-2 text-sm transition-colors ${
365 isActive ? "bg-accent-500/10 text-accent-400" : "text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200"
366 }`}
367 style={{ paddingLeft: rowPaddingLeft }}
368 >
369 {hasChildren && (
370 <button
371 onClick={toggleExpand}
372 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"
373 style={{ left: chevronLeft }}
374 tabIndex={-1}
375 aria-label={isExpanded ? "Collapse" : "Expand"}
376 >
377 <ChevronRight className={`h-3 w-3 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
378 </button>
379 )}
380
381 <span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs">
382 {page.icon ? (
383 <EmojiIcon emoji={page.icon} size={14} />
384 ) : page.kind === "canvas" ? (
385 <PenTool className="h-3.5 w-3.5 text-zinc-500" />
386 ) : (
387 <FileText className="h-3.5 w-3.5 text-zinc-500" />
388 )}
389 </span>
390
391 <span className="truncate" title={page.title || DEFAULT_PAGE_TITLE}>
392 {page.title || DEFAULT_PAGE_TITLE}
393 </span>
394
395 {isActionVisible(rowAffordance.createSubpage) && (
396 <button
397 onClick={handleCreateSubpage}
398 disabled={creating || !isActionEnabled(rowAffordance.createSubpage)}
399 className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-zinc-700 ${addVisibility} disabled:opacity-50`}
400 tabIndex={-1}
401 aria-label="Create subpage"
402 title={rowAffordance.createSubpage.kind === "disabled" ? rowAffordance.createSubpage.reason : undefined}
403 >
404 <Plus className="h-3.5 w-3.5" />
405 </button>
406 )}
407
408 {(isActionVisible(rowAffordance.movePage) || isActionVisible(rowAffordance.archivePage)) && (
409 <div ref={menuRef} className="shrink-0">
410 <button
411 onClick={(e) => {
412 e.preventDefault();
413 e.stopPropagation();
414 setMenuOpen((v) => !v);
415 }}
416 className={`flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-700 ${moreVisibility}`}
417 tabIndex={-1}
418 aria-label="Page options"
419 >
420 <MoreHorizontal className="h-3.5 w-3.5" />
421 </button>
422 {menuOpen && renderActionsMenu()}
423 </div>
424 )}
425 </Link>
426
427 {moveDialogOpen && (
428 <Suspense fallback={null}>
429 <SidebarMoveDialog
430 open
431 page={page}
432 allPages={allPages}
433 index={index}
434 onClose={() => setMoveDialogOpen(false)}
435 onConfirm={executeMove}
436 />
437 </Suspense>
438 )}
439
440 {isExpanded && hasChildren && (
441 <div>
442 {childPages.map((child) => (
443 <PageTreeItem
444 key={child.id}
445 page={child}
446 depth={depth + 1}
447 index={index}
448 allPages={allPages}
449 alwaysShowActions={alwaysShowActions}
450 activeAncestorIds={activeAncestorIds}
451 menuZIndex={menuZIndex}
452 workspaceRole={workspaceRole}
453 online={online}
454 />
455 ))}
456 </div>
457 )}
458 </div>
459 );
460}