import { lazy, Suspense, useCallback, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, ChevronRight, FileText, ListIndentDecrease, ListIndentIncrease, MoreHorizontal, Move, PenTool, Plus, Trash2, } from "lucide-react"; import type { Page } from "@/shared/types"; import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; import { api, toApiError } from "@/client/lib/api"; import { useCurrentWorkspace } from "@/client/components/workspace/use-workspace-view"; import { replicaCommands } from "@/client/stores/db/workspace-replica"; import { useAuthStore } from "@/client/stores/auth-store"; import { getActiveDescendantIds, getArchivePageConfirmMessage, shouldNavigateAwayAfterArchive, } from "@/client/lib/page-archive"; import { useCreatePage } from "@/client/hooks/use-create-page"; import { EmojiIcon } from "@/client/components/ui/emoji-icon"; import { confirm } from "@/client/components/confirm-store"; import { DropdownPortal } from "@/client/components/ui/dropdown-portal"; import { deriveSidebarRowAffordance } from "@/client/lib/affordance/sidebar"; import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state"; import { resolveIndent, resolveMoveDown, resolveMoveUp, resolveOutdent, type MoveResolution, type MoveResult, type PageTreeIndex, } from "@/client/lib/page-tree-model"; import { toast } from "@/client/components/toast-store"; import { archivedPagesQueryKey } from "@/client/lib/queries/archived-pages"; const SidebarMoveDialog = lazy(() => import("./sidebar-move-dialog").then((mod) => ({ default: mod.SidebarMoveDialog })), ); import { getSidebarTreeChevronLeft, getSidebarTreeContentPaddingLeft } from "./tree-metrics"; interface PageTreeItemProps { page: Page; depth: number; index: PageTreeIndex; allPages: Page[]; alwaysShowActions: boolean; activeAncestorIds: Set; menuZIndex?: number; workspaceRole: "owner" | "admin" | "member" | "guest" | "none"; online: boolean; } const EMPTY_CHILDREN: readonly never[] = []; const MENU_ITEM_CLASS = "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"; const MENU_NEUTRAL_ITEM_CLASS = "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"; const MENU_DANGER_ITEM_CLASS = "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"; const MENU_ICON_CLASS = "flex w-4 shrink-0 items-center justify-center text-zinc-400 transition-colors group-hover:text-current group-focus-visible:text-current"; const MENU_DANGER_ICON_CLASS = MENU_ICON_CLASS; const MENU_LABEL_CLASS = "flex-1"; const MENU_SEPARATOR_CLASS = "my-1 h-px bg-zinc-800"; export function PageTreeItem({ page, depth, index, allPages, alwaysShowActions, activeAncestorIds, menuZIndex, workspaceRole, online, }: PageTreeItemProps) { const childPages = index.childrenByParent.get(page.id) ?? EMPTY_CHILDREN; const params = useParams({ strict: false }) as { workspaceSlug?: string; pageId?: string; }; const navigate = useNavigate(); const queryClient = useQueryClient(); const currentWorkspace = useCurrentWorkspace(); const currentUser = useAuthStore((s) => s.user); const rowAffordance = deriveSidebarRowAffordance({ workspaceRole, ownsPage: currentUser?.id === page.created_by, online, }); const { createPage, isCreating: creating } = useCreatePage(); const isActive = params.pageId === page.id; const shouldExpand = activeAncestorIds.has(page.id) || isActive; const [userExpanded, setUserExpanded] = useState(shouldExpand); const [prevShouldExpand, setPrevShouldExpand] = useState(shouldExpand); // Auto-expand when becoming active or hosting an active descendant; user // toggles still win until shouldExpand transitions again. Pattern from // https://react.dev/reference/react/useState#storing-information-from-previous-renders if (shouldExpand !== prevShouldExpand) { setPrevShouldExpand(shouldExpand); if (shouldExpand) setUserExpanded(true); } const isExpanded = userExpanded; const [menuOpen, setMenuOpen] = useState(false); const [moveDialogOpen, setMoveDialogOpen] = useState(false); const [archiving, setArchiving] = useState(false); const [moving, setMoving] = useState(false); const menuRef = useRef(null); const hasChildren = childPages.length > 0; const moreVisibility = alwaysShowActions || menuOpen || isActive ? "opacity-100" : "opacity-40 group-hover:opacity-100"; const addVisibility = alwaysShowActions || menuOpen || isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"; const toggleExpand = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setUserExpanded((v) => !v); }, []); const executeMove = useCallback( async (resolution: MoveResolution) => { if (!currentWorkspace || moving) return; setMoving(true); setMenuOpen(false); const previousParentId = page.parent_id; const previousPosition = page.position; await replicaCommands.patchPage(currentWorkspace.id, page.id, { parent_id: resolution.proposal.parentId, position: resolution.proposal.position, }); try { const updated = await api.pages.update(currentWorkspace.id, page.id, { parent_id: resolution.proposal.parentId, position: resolution.proposal.position, }); await replicaCommands.upsertPage(currentWorkspace.id, updated); } catch (err) { await replicaCommands.patchPage(currentWorkspace.id, page.id, { parent_id: previousParentId, position: previousPosition, }); toast.error(toApiError(err).message || "Failed to move page"); throw err; } finally { setMoving(false); } }, [currentWorkspace, moving, page.id, page.parent_id, page.position], ); const handleArchive = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!currentWorkspace || archiving) return; setMenuOpen(false); const activeDescendantIds = getActiveDescendantIds(index, page.id); const ok = await confirm({ title: "Archive page", message: getArchivePageConfirmMessage(page.title, activeDescendantIds.length), }); if (!ok) return; setArchiving(true); try { const archived = await api.pages.delete(currentWorkspace.id, page.id); await replicaCommands.removePages(currentWorkspace.id, archived.archived_page_ids); await queryClient.invalidateQueries({ queryKey: archivedPagesQueryKey(currentWorkspace.id) }); if (shouldNavigateAwayAfterArchive(params.pageId, archived.archived_page_ids)) { navigate({ to: "/$workspaceSlug", params: { workspaceSlug: params.workspaceSlug || currentWorkspace.slug } }); } } catch (err) { toast.error(toApiError(err).message || "Failed to archive page"); } finally { setArchiving(false); setMenuOpen(false); } }, [ currentWorkspace, archiving, page.id, page.title, index, params.pageId, params.workspaceSlug, navigate, queryClient, ], ); const handleCreateSubpage = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); createPage({ parentId: page.id, onCreated: () => setUserExpanded(true) }); }, [createPage, page.id], ); const openMoveDialog = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!isActionEnabled(rowAffordance.movePage)) return; setMenuOpen(false); setMoveDialogOpen(true); }, [rowAffordance.movePage], ); const runQuickMove = useCallback( (e: React.MouseEvent, result: MoveResult) => { e.preventDefault(); e.stopPropagation(); if (!isActionEnabled(rowAffordance.movePage) || !result.ok) return; void executeMove(result); }, [executeMove, rowAffordance.movePage], ); const rowPaddingLeft = getSidebarTreeContentPaddingLeft(depth); const chevronLeft = getSidebarTreeChevronLeft(depth); const renderActionsMenu = () => { const moveUp = resolveMoveUp(allPages, page, index); const moveDown = resolveMoveDown(allPages, page, index); const indent = resolveIndent(allPages, page, index); const outdent = resolveOutdent(allPages, page, index); const moveTitle = rowAffordance.movePage.kind === "disabled" ? rowAffordance.movePage.reason : undefined; const moveDisabledReason = (result: MoveResult) => rowAffordance.movePage.kind === "disabled" ? rowAffordance.movePage.reason : result.ok ? undefined : result.message; return ( setMenuOpen(false)} >
{isActionVisible(rowAffordance.movePage) && ( <>
)} {isActionVisible(rowAffordance.movePage) && isActionVisible(rowAffordance.archivePage) && (
)} {isActionVisible(rowAffordance.archivePage) && ( )}
); }; return (
{hasChildren && ( )} {page.icon ? ( ) : page.kind === "canvas" ? ( ) : ( )} {page.title || DEFAULT_PAGE_TITLE} {isActionVisible(rowAffordance.createSubpage) && ( )} {(isActionVisible(rowAffordance.movePage) || isActionVisible(rowAffordance.archivePage)) && (
{menuOpen && renderActionsMenu()}
)} {moveDialogOpen && ( setMoveDialogOpen(false)} onConfirm={executeMove} /> )} {isExpanded && hasChildren && (
{childPages.map((child) => ( ))}
)}
); }