File
Blob: src/client/components/sidebar/sidebar.tsx
| 1 | import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { |
| 4 | Plus, |
| 5 | Search, |
| 6 | ChevronsLeft, |
| 7 | ChevronsRight, |
| 8 | Loader2, |
| 9 | Settings, |
| 10 | Archive, |
| 11 | ArrowLeft, |
| 12 | ChevronDown, |
| 13 | FileText, |
| 14 | PenTool, |
| 15 | } from "lucide-react"; |
| 16 | import { DropdownPortal } from "@/client/components/ui/dropdown-portal"; |
| 17 | import { useCurrentWorkspace, useWorkspaceRole } from "@/client/components/workspace/use-workspace-view"; |
| 18 | import { useCreatePage } from "@/client/hooks/use-create-page"; |
| 19 | import { WorkspaceSwitcher } from "./workspace-switcher"; |
| 20 | import { PageTree } from "./page-tree"; |
| 21 | import { searchShortcutLabel } from "./search-shortcut"; |
| 22 | import { useOnline } from "@/client/hooks/use-online"; |
| 23 | import { deriveSidebarBaseAffordance, type SidebarBaseAffordance } from "@/client/lib/affordance/sidebar"; |
| 24 | import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state"; |
| 25 | import { toast } from "@/client/components/toast-store"; |
| 26 | import { EmojiIcon } from "@/client/components/ui/emoji-icon"; |
| 27 | import { MobileDrawer } from "@/client/components/ui/mobile-drawer"; |
| 28 | |
| 29 | const SearchDialog = lazy(() => import("./search-dialog").then((mod) => ({ default: mod.SearchDialog }))); |
| 30 | |
| 31 | interface SidebarProps { |
| 32 | collapsed: boolean; |
| 33 | onCollapsedChange: (next: boolean) => void; |
| 34 | mobileOpen?: boolean; |
| 35 | onMobileClose?: () => void; |
| 36 | } |
| 37 | |
| 38 | import type { WorkspaceRole } from "@/shared/types"; |
| 39 | type SidebarWorkspaceRole = WorkspaceRole | "none"; |
| 40 | type CreatePageAction = SidebarBaseAffordance["createPage"]; |
| 41 | type CreatePageFn = ReturnType<typeof useCreatePage>["createPage"]; |
| 42 | |
| 43 | export function Sidebar({ collapsed, onCollapsedChange, mobileOpen, onMobileClose }: SidebarProps) { |
| 44 | const currentWorkspace = useCurrentWorkspace(); |
| 45 | const { createPage, isCreating } = useCreatePage(); |
| 46 | const online = useOnline(); |
| 47 | const workspaceRole: SidebarWorkspaceRole = useWorkspaceRole() ?? "none"; |
| 48 | // "Shared mode" is role-defined, not access_mode-defined: guests belong in |
| 49 | // the authenticated shell (so they can reach Leave Workspace), but still |
| 50 | // need the sidebar to hide writer-only affordances. Anything outside the |
| 51 | // owner/admin/member/guest set is shared-surface. |
| 52 | const isSharedMode = workspaceRole === "none"; |
| 53 | const sidebarAffordance = deriveSidebarBaseAffordance({ workspaceRole, online }); |
| 54 | const { searchOpen, openSearch, closeSearch } = useSidebarSearch(online); |
| 55 | |
| 56 | const showCollapsed = collapsed && !mobileOpen; |
| 57 | const sidebarMenuZIndex = mobileOpen ? 90 : undefined; |
| 58 | const expandSidebar = useCallback(() => onCollapsedChange(false), [onCollapsedChange]); |
| 59 | const collapseSidebar = useCallback(() => onCollapsedChange(true), [onCollapsedChange]); |
| 60 | |
| 61 | return ( |
| 62 | <MobileDrawer open={!!mobileOpen} onClose={onMobileClose ?? NOOP}> |
| 63 | <SidebarFrame collapsed={showCollapsed}> |
| 64 | {showCollapsed ? ( |
| 65 | <CollapsedSidebarRail |
| 66 | createPageAction={sidebarAffordance.createPage} |
| 67 | createPage={createPage} |
| 68 | openSearch={openSearch} |
| 69 | onExpand={expandSidebar} |
| 70 | /> |
| 71 | ) : ( |
| 72 | <ExpandedSidebarContent |
| 73 | currentWorkspace={currentWorkspace} |
| 74 | isSharedMode={isSharedMode} |
| 75 | workspaceRole={workspaceRole} |
| 76 | createPageAction={sidebarAffordance.createPage} |
| 77 | createPage={createPage} |
| 78 | isCreating={isCreating} |
| 79 | openSearch={openSearch} |
| 80 | menuZIndex={sidebarMenuZIndex} |
| 81 | mobileOpen={!!mobileOpen} |
| 82 | onCollapse={collapseSidebar} |
| 83 | /> |
| 84 | )} |
| 85 | {searchOpen && ( |
| 86 | <Suspense fallback={null}> |
| 87 | <SearchDialog open onClose={closeSearch} /> |
| 88 | </Suspense> |
| 89 | )} |
| 90 | </SidebarFrame> |
| 91 | </MobileDrawer> |
| 92 | ); |
| 93 | } |
| 94 | |
| 95 | function useSidebarSearch(online: boolean) { |
| 96 | const [searchOpen, setSearchOpen] = useState(false); |
| 97 | |
| 98 | const openSearch = useCallback(() => { |
| 99 | if (!online) { |
| 100 | toast.info("Search requires a connection"); |
| 101 | return; |
| 102 | } |
| 103 | setSearchOpen(true); |
| 104 | }, [online]); |
| 105 | |
| 106 | const closeSearch = useCallback(() => { |
| 107 | setSearchOpen(false); |
| 108 | }, []); |
| 109 | |
| 110 | useEffect(() => { |
| 111 | const handleKeydown = (event: KeyboardEvent) => { |
| 112 | if ((event.metaKey || event.ctrlKey) && event.key === "k") { |
| 113 | event.preventDefault(); |
| 114 | if (!online) { |
| 115 | toast.info("Search requires a connection"); |
| 116 | return; |
| 117 | } |
| 118 | setSearchOpen((open) => !open); |
| 119 | } |
| 120 | }; |
| 121 | document.addEventListener("keydown", handleKeydown); |
| 122 | return () => document.removeEventListener("keydown", handleKeydown); |
| 123 | }, [online]); |
| 124 | |
| 125 | return { searchOpen, openSearch, closeSearch }; |
| 126 | } |
| 127 | |
| 128 | function SidebarFrame({ collapsed, children }: { collapsed: boolean; children: ReactNode }) { |
| 129 | return ( |
| 130 | <aside |
| 131 | className={`relative flex h-full shrink-0 flex-col border-r border-zinc-800/60 bg-zinc-900 transition-[width] duration-200 ${ |
| 132 | collapsed ? "w-12" : "w-[260px]" |
| 133 | }`} |
| 134 | > |
| 135 | {children} |
| 136 | </aside> |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | interface CollapsedSidebarRailProps { |
| 141 | createPageAction: CreatePageAction; |
| 142 | createPage: CreatePageFn; |
| 143 | openSearch: () => void; |
| 144 | onExpand: () => void; |
| 145 | } |
| 146 | |
| 147 | function CollapsedSidebarRail({ createPageAction, createPage, openSearch, onExpand }: CollapsedSidebarRailProps) { |
| 148 | return ( |
| 149 | <div className="flex flex-1 flex-col items-center gap-2 pt-2"> |
| 150 | {isActionVisible(createPageAction) && ( |
| 151 | <button |
| 152 | onClick={() => createPage()} |
| 153 | disabled={!isActionEnabled(createPageAction)} |
| 154 | className="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-50" |
| 155 | aria-label="New page" |
| 156 | title={createPageAction.kind === "disabled" ? createPageAction.reason : undefined} |
| 157 | > |
| 158 | <Plus className="h-4 w-4" /> |
| 159 | </button> |
| 160 | )} |
| 161 | <button |
| 162 | onClick={openSearch} |
| 163 | className="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300" |
| 164 | aria-label="Search" |
| 165 | > |
| 166 | <Search className="h-4 w-4" /> |
| 167 | </button> |
| 168 | <div className="flex-1" /> |
| 169 | <button |
| 170 | onClick={onExpand} |
| 171 | className="mb-3 flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300" |
| 172 | aria-label="Expand sidebar" |
| 173 | > |
| 174 | <ChevronsRight className="h-4 w-4" /> |
| 175 | </button> |
| 176 | </div> |
| 177 | ); |
| 178 | } |
| 179 | |
| 180 | interface ExpandedSidebarContentProps { |
| 181 | currentWorkspace: ReturnType<typeof useCurrentWorkspace>; |
| 182 | isSharedMode: boolean; |
| 183 | workspaceRole: SidebarWorkspaceRole; |
| 184 | createPageAction: CreatePageAction; |
| 185 | createPage: CreatePageFn; |
| 186 | isCreating: boolean; |
| 187 | openSearch: () => void; |
| 188 | menuZIndex?: number; |
| 189 | mobileOpen: boolean; |
| 190 | onCollapse: () => void; |
| 191 | } |
| 192 | |
| 193 | function ExpandedSidebarContent({ |
| 194 | currentWorkspace, |
| 195 | isSharedMode, |
| 196 | workspaceRole, |
| 197 | createPageAction, |
| 198 | createPage, |
| 199 | isCreating, |
| 200 | openSearch, |
| 201 | menuZIndex, |
| 202 | mobileOpen, |
| 203 | onCollapse, |
| 204 | }: ExpandedSidebarContentProps) { |
| 205 | return ( |
| 206 | <> |
| 207 | {isSharedMode ? <SharedWorkspaceHeader currentWorkspace={currentWorkspace} /> : <WorkspaceSwitcher />} |
| 208 | |
| 209 | <SidebarActionBar |
| 210 | createPageAction={createPageAction} |
| 211 | createPage={createPage} |
| 212 | isCreating={isCreating} |
| 213 | openSearch={openSearch} |
| 214 | menuZIndex={menuZIndex} |
| 215 | /> |
| 216 | |
| 217 | <nav className="flex-1 overflow-y-auto px-1"> |
| 218 | {isSharedMode && <div className="px-2 py-1 text-xs text-zinc-400">Shared with you</div>} |
| 219 | <PageTree alwaysShowActions={mobileOpen} menuZIndex={menuZIndex} /> |
| 220 | </nav> |
| 221 | |
| 222 | <SidebarFooter |
| 223 | currentWorkspace={currentWorkspace} |
| 224 | isSharedMode={isSharedMode} |
| 225 | workspaceRole={workspaceRole} |
| 226 | mobileOpen={mobileOpen} |
| 227 | onCollapse={onCollapse} |
| 228 | /> |
| 229 | </> |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | function SharedWorkspaceHeader({ currentWorkspace }: { currentWorkspace: ReturnType<typeof useCurrentWorkspace> }) { |
| 234 | return ( |
| 235 | <div className="flex h-10 items-center gap-2 border-b border-zinc-800/60 px-3"> |
| 236 | <Link |
| 237 | to="/" |
| 238 | className="flex shrink-0 items-center justify-center rounded-md p-0.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300" |
| 239 | aria-label="Back to your workspaces" |
| 240 | title="Back to your workspaces" |
| 241 | > |
| 242 | <ArrowLeft className="h-3.5 w-3.5" /> |
| 243 | </Link> |
| 244 | <span className="truncate text-sm font-medium text-zinc-300"> |
| 245 | {currentWorkspace?.icon && ( |
| 246 | <span className="mr-1.5 inline-flex items-center align-middle"> |
| 247 | <EmojiIcon emoji={currentWorkspace.icon} size={14} /> |
| 248 | </span> |
| 249 | )} |
| 250 | {currentWorkspace?.name ?? "Workspace"} |
| 251 | </span> |
| 252 | </div> |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | interface SidebarActionBarProps { |
| 257 | createPageAction: CreatePageAction; |
| 258 | createPage: CreatePageFn; |
| 259 | isCreating: boolean; |
| 260 | openSearch: () => void; |
| 261 | menuZIndex?: number; |
| 262 | } |
| 263 | |
| 264 | function SidebarActionBar({ createPageAction, createPage, isCreating, openSearch, menuZIndex }: SidebarActionBarProps) { |
| 265 | return ( |
| 266 | <div className="h-[46px] px-2 py-2"> |
| 267 | <div className="flex h-full items-center gap-1"> |
| 268 | <CreatePageControls |
| 269 | createPageAction={createPageAction} |
| 270 | createPage={createPage} |
| 271 | isCreating={isCreating} |
| 272 | menuZIndex={menuZIndex} |
| 273 | /> |
| 274 | <button |
| 275 | onClick={openSearch} |
| 276 | className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-zinc-300" |
| 277 | aria-label="Search pages" |
| 278 | > |
| 279 | <Search className="h-3.5 w-3.5" /> |
| 280 | <kbd className="hidden rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px] text-zinc-400 sm:inline"> |
| 281 | {searchShortcutLabel} |
| 282 | </kbd> |
| 283 | </button> |
| 284 | </div> |
| 285 | </div> |
| 286 | ); |
| 287 | } |
| 288 | |
| 289 | interface CreatePageControlsProps { |
| 290 | createPageAction: CreatePageAction; |
| 291 | createPage: CreatePageFn; |
| 292 | isCreating: boolean; |
| 293 | menuZIndex?: number; |
| 294 | } |
| 295 | |
| 296 | function CreatePageControls({ createPageAction, createPage, isCreating, menuZIndex }: CreatePageControlsProps) { |
| 297 | const [menuOpen, setMenuOpen] = useState(false); |
| 298 | const triggerRef = useRef<HTMLButtonElement>(null); |
| 299 | |
| 300 | if (!isActionVisible(createPageAction)) return null; |
| 301 | |
| 302 | const disabled = isCreating || !isActionEnabled(createPageAction); |
| 303 | const disabledReason = createPageAction.kind === "disabled" ? createPageAction.reason : undefined; |
| 304 | |
| 305 | return ( |
| 306 | <div className="relative flex flex-1 items-stretch"> |
| 307 | <button |
| 308 | onClick={() => createPage()} |
| 309 | disabled={disabled} |
| 310 | className="flex flex-1 items-center gap-1.5 rounded-l-md border border-r-0 border-zinc-700/50 bg-zinc-800/30 px-2 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600/50 hover:bg-zinc-800/60 hover:text-zinc-100 disabled:opacity-50" |
| 311 | title={disabledReason} |
| 312 | > |
| 313 | {isCreating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />} |
| 314 | New page |
| 315 | </button> |
| 316 | <button |
| 317 | ref={triggerRef} |
| 318 | onClick={() => setMenuOpen((open) => !open)} |
| 319 | disabled={disabled} |
| 320 | aria-label="New page options" |
| 321 | aria-expanded={menuOpen} |
| 322 | className="flex w-6 items-center justify-center rounded-r-md border border-zinc-700/50 bg-zinc-800/30 text-zinc-400 transition-colors hover:border-zinc-600/50 hover:bg-zinc-800/60 hover:text-zinc-100 disabled:opacity-50" |
| 323 | > |
| 324 | <ChevronDown className="h-3 w-3" /> |
| 325 | </button> |
| 326 | {menuOpen && ( |
| 327 | <DropdownPortal |
| 328 | triggerRef={triggerRef} |
| 329 | align="left" |
| 330 | width={180} |
| 331 | zIndex={menuZIndex} |
| 332 | onClose={() => setMenuOpen(false)} |
| 333 | > |
| 334 | <div className="py-1 text-sm"> |
| 335 | <button |
| 336 | className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-zinc-200 hover:bg-zinc-700/70" |
| 337 | onClick={() => { |
| 338 | setMenuOpen(false); |
| 339 | createPage({ kind: "doc" }); |
| 340 | }} |
| 341 | > |
| 342 | <FileText className="h-3.5 w-3.5 text-zinc-400" /> |
| 343 | New doc page |
| 344 | </button> |
| 345 | <button |
| 346 | className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-zinc-200 hover:bg-zinc-700/70" |
| 347 | onClick={() => { |
| 348 | setMenuOpen(false); |
| 349 | createPage({ kind: "canvas" }); |
| 350 | }} |
| 351 | > |
| 352 | <PenTool className="h-3.5 w-3.5 text-zinc-400" /> |
| 353 | New canvas |
| 354 | </button> |
| 355 | </div> |
| 356 | </DropdownPortal> |
| 357 | )} |
| 358 | </div> |
| 359 | ); |
| 360 | } |
| 361 | |
| 362 | interface SidebarFooterProps { |
| 363 | currentWorkspace: ReturnType<typeof useCurrentWorkspace>; |
| 364 | isSharedMode: boolean; |
| 365 | workspaceRole: SidebarWorkspaceRole; |
| 366 | mobileOpen: boolean; |
| 367 | onCollapse: () => void; |
| 368 | } |
| 369 | |
| 370 | function SidebarFooter({ currentWorkspace, isSharedMode, workspaceRole, mobileOpen, onCollapse }: SidebarFooterProps) { |
| 371 | // Settings link is available to any membership (including guest, so they |
| 372 | // can reach Leave Workspace). Shared-surface viewers do not see it because |
| 373 | // they cannot act on workspace settings. |
| 374 | const showSettings = !!currentWorkspace && !isSharedMode; |
| 375 | // Archive is writer-surface only: guests get Settings (for Leave Workspace) |
| 376 | // but cannot manage archived pages. |
| 377 | const showArchive = !!currentWorkspace && !isSharedMode && workspaceRole !== "guest"; |
| 378 | return ( |
| 379 | <div className="flex items-center gap-1 border-t border-zinc-800/60 px-2 py-2"> |
| 380 | {showSettings && ( |
| 381 | <Link |
| 382 | to="/$workspaceSlug/settings" |
| 383 | params={{ workspaceSlug: currentWorkspace.slug }} |
| 384 | className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-300" |
| 385 | aria-label="Settings" |
| 386 | title="Settings" |
| 387 | > |
| 388 | <Settings className="h-4 w-4" /> |
| 389 | </Link> |
| 390 | )} |
| 391 | {showArchive && ( |
| 392 | <Link |
| 393 | to="/$workspaceSlug/archive" |
| 394 | params={{ workspaceSlug: currentWorkspace.slug }} |
| 395 | className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-300" |
| 396 | aria-label="Archive" |
| 397 | title="Archive" |
| 398 | > |
| 399 | <Archive className="h-4 w-4" /> |
| 400 | </Link> |
| 401 | )} |
| 402 | <div className="flex-1" /> |
| 403 | {!mobileOpen && ( |
| 404 | <button |
| 405 | onClick={onCollapse} |
| 406 | className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-300" |
| 407 | aria-label="Collapse sidebar" |
| 408 | title="Collapse sidebar" |
| 409 | > |
| 410 | <ChevronsLeft className="h-4 w-4" /> |
| 411 | </button> |
| 412 | )} |
| 413 | </div> |
| 414 | ); |
| 415 | } |
| 416 | |
| 417 | function NOOP() {} |