Skip to content
File

Blob: src/client/components/sidebar/sidebar.tsx

typescript418 lines
1import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import { Link } from "@tanstack/react-router";
3import {
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";
16import { DropdownPortal } from "@/client/components/ui/dropdown-portal";
17import { useCurrentWorkspace, useWorkspaceRole } from "@/client/components/workspace/use-workspace-view";
18import { useCreatePage } from "@/client/hooks/use-create-page";
19import { WorkspaceSwitcher } from "./workspace-switcher";
20import { PageTree } from "./page-tree";
21import { searchShortcutLabel } from "./search-shortcut";
22import { useOnline } from "@/client/hooks/use-online";
23import { deriveSidebarBaseAffordance, type SidebarBaseAffordance } from "@/client/lib/affordance/sidebar";
24import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state";
25import { toast } from "@/client/components/toast-store";
26import { EmojiIcon } from "@/client/components/ui/emoji-icon";
27import { MobileDrawer } from "@/client/components/ui/mobile-drawer";
28 
29const SearchDialog = lazy(() => import("./search-dialog").then((mod) => ({ default: mod.SearchDialog })));
30 
31interface SidebarProps {
32 collapsed: boolean;
33 onCollapsedChange: (next: boolean) => void;
34 mobileOpen?: boolean;
35 onMobileClose?: () => void;
36}
37 
38import type { WorkspaceRole } from "@/shared/types";
39type SidebarWorkspaceRole = WorkspaceRole | "none";
40type CreatePageAction = SidebarBaseAffordance["createPage"];
41type CreatePageFn = ReturnType<typeof useCreatePage>["createPage"];
42 
43export 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 
95function 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 
128function 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 
140interface CollapsedSidebarRailProps {
141 createPageAction: CreatePageAction;
142 createPage: CreatePageFn;
143 openSearch: () => void;
144 onExpand: () => void;
145}
146 
147function 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 
180interface 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 
193function 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 
233function 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 
256interface SidebarActionBarProps {
257 createPageAction: CreatePageAction;
258 createPage: CreatePageFn;
259 isCreating: boolean;
260 openSearch: () => void;
261 menuZIndex?: number;
262}
263 
264function 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 
289interface CreatePageControlsProps {
290 createPageAction: CreatePageAction;
291 createPage: CreatePageFn;
292 isCreating: boolean;
293 menuZIndex?: number;
294}
295 
296function 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 
362interface SidebarFooterProps {
363 currentWorkspace: ReturnType<typeof useCurrentWorkspace>;
364 isSharedMode: boolean;
365 workspaceRole: SidebarWorkspaceRole;
366 mobileOpen: boolean;
367 onCollapse: () => void;
368}
369 
370function 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 
417function NOOP() {}