1"use client";
2
3import { useEffect, useMemo, useRef, useState } from "react";
4import Link from "next/link";
5import { usePathname, useParams, useRouter } from "next/navigation";
6import {
7 Activity,
8 Archive,
9 ArchiveRestore,
10 Bookmark,
11 CheckSquare,
12 ChevronDown,
13 File as FileIcon,
14 FilePlus2,
15 FolderPlus,
16 LayoutGrid,
17 LogOut,
18 Search,
19 Settings,
20 Star,
21 StarOff,
22 Trash2,
23 UserPlus,
24 X
25} from "lucide-react";
26import { useAuth } from "@/hooks/use-auth";
27import { useWorkspaces, useWorkspace } from "@/hooks/use-workspace";
28import { useCreatePage, useDuplicatePage, usePageTree } from "@/hooks/use-pages";
29import { pageApi } from "@/lib/api";
30import { toast, useUiStore } from "@/stores/ui-store";
31import { FactoryLogo } from "./factory-logo";
32import { PageTreeList } from "./page-tree-node";
33import { cn } from "@/lib/utils";
34
35
39export function Sidebar() {
40 const { user } = useAuth();
41 const pathname = usePathname();
42 const params = useParams<{ workspace: string }>();
43 const workspaceSlug = params?.workspace ?? "default";
44 const { data: workspace } = useWorkspace(workspaceSlug);
45 const { data: allWorkspaces } = useWorkspaces();
46 const [switcherOpen, setSwitcherOpen] = useState(false);
47 const collapsed = useUiStore((s) => s.sidebarCollapsed);
48
49
50
51
52 const mobileNavOpen = useUiStore((s) => s.mobileNavOpen);
53 const setMobileNav = useUiStore((s) => s.setMobileNav);
54
55
56 const prevPathRef = useRef(pathname);
57 useEffect(() => {
58 if (prevPathRef.current !== pathname) {
59 prevPathRef.current = pathname;
60 if (useUiStore.getState().mobileNavOpen) setMobileNav(false);
61 }
62 }, [pathname, setMobileNav]);
63
64
65 const tree = usePageTree(workspace?.id ?? "", null);
66 const createPage = useCreatePage(workspace?.id ?? "");
67 const duplicatePage = useDuplicatePage(workspace?.id ?? "");
68 const router = useRouter();
69
70
71
72
73
74
75 const activePageId = pathname.includes("/page/") ? pathname.split("/page/")[1]?.split("/")[0] ?? null : null;
76 const [autoExpandIds, setAutoExpandIds] = useState<Set<string>>(new Set());
77 useEffect(() => {
78 if (!activePageId) {
79 setAutoExpandIds(new Set());
80 return;
81 }
82 let cancelled = false;
83 const chain: string[] = [];
84 void (async () => {
85 let id: string | null = activePageId;
86 const seen = new Set<string>();
87 while (id && !seen.has(id)) {
88 seen.add(id);
89 chain.push(id);
90 try {
91 const p = await pageApi.get(id);
92 id = p.parentId ?? null;
93 } catch {
94 break;
95 }
96 }
97 if (!cancelled) setAutoExpandIds(new Set(chain));
98 })();
99 return () => {
100 cancelled = true;
101 };
102 }, [activePageId]);
103
104
105
106 useEffect(() => {
107 if (!workspace?.id) return;
108 const onNewPage = (e: Event) => {
109 const { parentId, title } = (e as CustomEvent<{ parentId: string | null; title?: string }>).detail ?? {};
110
111
112
113 void createPage.mutateAsync({ parentId: parentId ?? null, title: title ?? "" }).then((page) => {
114 router.push(`/w/${workspaceSlug}/page/${page.id}`);
115 });
116 };
117 window.addEventListener("factory:new-page", onNewPage);
118 return () => window.removeEventListener("factory:new-page", onNewPage);
119 }, [workspace?.id, createPage, router, workspaceSlug]);
120
121
122
123
124
125 useEffect(() => {
126 if (!workspace?.id) return;
127 const onPageAction = (e: Event) => {
128 const { action, pageId, parentId } = (
129 e as CustomEvent<{ action: string; pageId: string; parentId?: string | null }>
130 ).detail ?? {};
131 if (action !== "duplicate" || !pageId) return;
132 void duplicatePage
133 .mutateAsync({ id: pageId, parentId: parentId ?? undefined })
134 .then((page) => {
135 toast("success", "Page duplicated", page.title || "Untitled (copy)");
136 router.push(`/w/${workspaceSlug}/page/${page.id}`);
137 })
138 .catch((e: unknown) =>
139 toast("error", "Duplicate failed", e instanceof Error ? e.message : String(e))
140 );
141 };
142 window.addEventListener("factory:page-action", onPageAction);
143 return () => window.removeEventListener("factory:page-action", onPageAction);
144 }, [workspace?.id, duplicatePage, router, workspaceSlug]);
145
146 const nav = useMemo(
147 () => [
148 { href: `/w/${workspaceSlug}`, label: "Home", icon: LayoutGrid, exact: true },
149 { href: `/w/${workspaceSlug}/inbox`, label: "Inbox", icon: CheckSquare },
150 { href: `/w/${workspaceSlug}/favorites`, label: "Favorites", icon: Bookmark },
151 { href: `/w/${workspaceSlug}/files`, label: "Files", icon: FileIcon },
152 { href: `/w/${workspaceSlug}/search`, label: "Search", icon: Search },
153 { href: `/w/${workspaceSlug}/activity`, label: "Activity", icon: Activity },
154
155
156 { href: `/w/${workspaceSlug}/trash`, label: "Trash", icon: Trash2, testId: "trash-section" },
157 { href: `/w/${workspaceSlug}/templates`, label: "Templates", icon: FilePlus2 }
158 ],
159 [workspaceSlug]
160 );
161
162
163
164
165
166
167
168 if (collapsed && !mobileNavOpen) return null;
169
170 const content = (
171 <>
172 {}
173 <button
174 type="button"
175 onClick={() => setMobileNav(false)}
176 className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-foreground/60 hover:bg-accent hover:text-foreground md:hidden"
177 aria-label="Close sidebar"
178 >
179 <X className="size-4" aria-hidden />
180 </button>
181 {}
182 <div className="relative border-b border-border p-2">
183 <button
184 type="button"
185 onClick={() => setSwitcherOpen((v) => !v)}
186 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent"
187 aria-expanded={switcherOpen}
188 aria-haspopup="menu"
189 >
190 <FactoryLogo brand className="size-6 shrink-0 rounded" />
191 <span className="min-w-0 flex-1 truncate text-left">{workspace?.name ?? workspaceSlug}</span>
192 <ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
193 </button>
194
195 {switcherOpen ? (
196 <div className="absolute left-2 right-2 z-40 mt-1 rounded-md border border-border bg-popover p-1 shadow-lg" role="menu">
197 {(allWorkspaces ?? []).map((ws) => (
198 <Link
199 key={ws.id}
200 href={`/w/${ws.slug || ws.id}`}
201 onClick={() => setSwitcherOpen(false)}
202 className={cn(
203 "flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent",
204 ws.id === workspace?.id && "bg-accent/60"
205 )}
206 role="menuitem"
207 >
208 <span className="size-5 rounded bg-muted" aria-hidden />
209 <span className="min-w-0 flex-1 truncate">{ws.name}</span>
210 </Link>
211 ))}
212 <Link
213 href={`/w/${workspaceSlug}/settings/members`}
214 onClick={() => setSwitcherOpen(false)}
215 className="mt-1 flex items-center gap-2 rounded border-t border-border px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
216 role="menuitem"
217 >
218 <UserPlus className="size-4" aria-hidden />
219 Invite people
220 </Link>
221 </div>
222 ) : null}
223 </div>
224
225 {}
226 <nav className="space-y-0.5 p-2" aria-label="Primary">
227 {nav.map((item) => {
228 const active = item.exact ? pathname === item.href : pathname.startsWith(item.href) && pathname !== `/w/${workspaceSlug}`;
229 return (
230 <Link
231 key={item.label}
232 href={item.href}
233 data-testid={item.testId}
234 className={cn(
235 "flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-foreground/80 hover:bg-accent hover:text-foreground",
236 active && "bg-accent font-medium text-foreground"
237 )}
238 >
239 <item.icon className="size-4 shrink-0" aria-hidden />
240 {item.label}
241 </Link>
242 );
243 })}
244 {}
246 <Link
247 href="/activity"
248 className={cn(
249 "flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-foreground/80 hover:bg-accent hover:text-foreground",
250 pathname.startsWith("/activity") && "bg-accent font-medium text-foreground"
251 )}
252 >
253 <Activity className="size-4 shrink-0" aria-hidden />
254 My activity
255 </Link>
256 </nav>
257
258 {}
259 <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
260 <div className="flex items-center justify-between px-2 py-1">
261 <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Private</span>
262 <div className="flex items-center gap-0.5">
263 <button
264 type="button"
265 title="New folder"
266 aria-label="New folder"
267 onClick={() =>
268 window.dispatchEvent(
269 new CustomEvent("factory:new-page", { detail: { parentId: null, title: "New folder" } })
270 )
271 }
272 className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
273 >
274 <FolderPlus className="size-3.5" aria-hidden />
275 </button>
276 <button
277 type="button"
278 title="New page"
279 aria-label="New page"
280 data-testid="sidebar-new-page"
281 onClick={() => window.dispatchEvent(new CustomEvent("factory:new-page", { detail: { parentId: null } }))}
282 className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
283 >
284 <FilePlus2 className="size-3.5" aria-hidden />
285 </button>
286 </div>
287 </div>
288 {tree.isLoading ? (
289 <p className="px-2 py-1 text-xs text-muted-foreground">Loading…</p>
290 ) : (
291 <PageTreeList
292 entries={tree.data ?? []}
293 depth={0}
294 workspaceSlug={workspaceSlug}
295 workspaceId={workspace?.id ?? ""}
296 autoExpand={autoExpandIds}
297 />
298 )}
299 </div>
300
301 {}
303 <BulkActionBar workspaceId={workspace?.id ?? ""} />
304
305 {}
306 <div className="border-t border-border px-2 py-2">
307 <Link
308 href={`/w/${workspaceSlug}/shared`}
309 className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-foreground/80 hover:bg-accent hover:text-foreground"
310 >
311 <Bookmark className="size-4 text-muted-foreground" aria-hidden />
312 Shared with me
313 </Link>
314 </div>
315
316 {}
317 <div className="border-t border-border p-2">
318 <Link
319 href={`/w/${workspaceSlug}/settings`}
320 className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-foreground/80 hover:bg-accent hover:text-foreground"
321 >
322 <Settings className="size-4" aria-hidden />
323 Settings
324 </Link>
325 <UserCard
326 name={user?.name ?? ""}
327 email={user?.email ?? ""}
328 image={user?.image ?? null}
329 />
330 </div>
331 </>
332 );
333
334 return (
335 <>
336 {}
338 {mobileNavOpen ? (
339 <div className="fixed inset-0 z-[90] flex md:hidden" role="dialog" aria-label="Navigation">
340 <button
341 type="button"
342 aria-label="Close sidebar"
343 onClick={() => setMobileNav(false)}
344 className="min-w-0 flex-1 cursor-default bg-black/40"
345 />
346 <aside
347 className="relative flex h-full w-64 shrink-0 flex-col border-r border-border bg-background"
348 aria-label="Workspace sidebar"
349 data-testid="sidebar"
350 >
351 {content}
352 </aside>
353 </div>
354 ) : null}
355 {}
357 {!collapsed ? (
358 <aside
359 className="relative hidden h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30 md:flex"
360 aria-label="Workspace sidebar"
361 data-testid="sidebar"
362 >
363 {content}
364 </aside>
365 ) : null}
366 </>
367 );
368}
369
370function UserCard({ name, email, image }: { name: string; email: string; image: string | null }) {
371 const router = useRouter();
372 const logout = async () => {
373
374 const { authApi } = await import("@/lib/api");
375 const { authStore } = await import("@/lib/auth-store");
376 try {
377 await authApi.logout();
378 } catch {
379
380 }
381 authStore.clear();
382 router.replace("/login");
383 };
384
385 return (
386 <div data-testid="user-menu" className="mt-1 flex items-center gap-2 rounded-md px-2 py-1.5">
387 <span className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-xs font-semibold text-primary-foreground" aria-hidden>
388 {image ? (
389
390 <img src={image} alt="" className="size-full object-cover" />
391 ) : name ? (
392 name.slice(0, 1).toUpperCase()
393 ) : (
394 "?"
395 )}
396 </span>
397 <span className="min-w-0 flex-1">
398 <span className="block truncate text-sm font-medium">{name || "You"}</span>
399 <span className="block truncate text-xs text-muted-foreground">{email}</span>
400 </span>
401 <button
402 type="button"
403 data-testid="user-menu-logout"
404 onClick={() => void logout()}
405 title="Log out"
406 aria-label="Log out"
407 className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
408 >
409 <LogOut className="size-4" aria-hidden />
410 </button>
411 </div>
412 );
413}
414
415
422function BulkActionBar({ workspaceId }: { workspaceId: string }) {
423 const pageSelection = useUiStore((s) => s.pageSelection);
424 const setBulk = useUiStore((s) => s.setBulk);
425 const clearPageSelection = useUiStore((s) => s.clearPageSelection);
426 const active = workspaceId !== "" && pageSelection.workspaceId === workspaceId && pageSelection.ids.length > 0;
427
428 if (!active) return null;
429 const n = pageSelection.ids.length;
430
431 const act = (action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite") =>
432 setBulk(true, action);
433
434 const btn = "rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground";
435
436 return (
437 <div
438 className="sticky bottom-0 z-10 flex items-center gap-1 border-t border-border bg-background px-2 py-1.5"
439 data-testid="bulk-action-bar"
440 >
441 <span className="mr-auto text-xs font-medium text-foreground/80" data-testid="bulk-count">
442 {n} selected
443 </span>
444 <button type="button" title="Favorite" aria-label="Favorite selected" data-testid="bulk-favorite" onClick={() => act("favorite")} className={btn}>
445 <Star className="size-3.5" aria-hidden />
446 </button>
447 <button type="button" title="Unfavorite" aria-label="Unfavorite selected" data-testid="bulk-unfavorite" onClick={() => act("unfavorite")} className={btn}>
448 <StarOff className="size-3.5" aria-hidden />
449 </button>
450 <button type="button" title="Archive" aria-label="Archive selected" data-testid="bulk-archive" onClick={() => act("archive")} className={btn}>
451 <Archive className="size-3.5" aria-hidden />
452 </button>
453 <button type="button" title="Unarchive" aria-label="Unarchive selected" data-testid="bulk-unarchive" onClick={() => act("unarchive")} className={btn}>
454 <ArchiveRestore className="size-3.5" aria-hidden />
455 </button>
456 <button type="button" title="Move to trash" aria-label="Move selected to trash" data-testid="bulk-trash" onClick={() => act("trash")} className="rounded p-1 text-red-600 hover:bg-accent">
457 <Trash2 className="size-3.5" aria-hidden />
458 </button>
459 <button type="button" title="Clear selection" aria-label="Clear selection" onClick={clearPageSelection} className={btn}>
460 <X className="size-3.5" aria-hidden />
461 </button>
462 </div>
463 );
464}
465