1"use client";
2
3import { forwardRef, useEffect, useRef, useState } from "react";
4import Link from "next/link";
5import { useParams, useRouter } from "next/navigation";
6import {
7 Bell,
8 ChevronRight,
9 Code2,
10 Command,
11 Eye,
12 History,
13 Menu,
14 MoreHorizontal,
15 Search,
16 Share2,
17 Star
18} from "lucide-react";
19import { useWorkspace } from "@/hooks/use-workspace";
20import { useDuplicatePage, usePage, usePageVersions, useToggleFavorite, usePageWatch, useToggleWatch } from "@/hooks/use-pages";
21import { useNotifications } from "@/hooks/use-notifications";
22import { HistoryPopover } from "@/components/editor/history-popover";
23import { VersionCompareDialog } from "@/components/editor/version-compare-dialog";
24import type { PageVersion } from "@/lib/types";
25import { StatusChip } from "@/components/collaboration/StatusChip";
26import { toast, useUiStore } from "@/stores/ui-store";
27import { cn } from "@/lib/utils";
28import { pageApi, isApiError } from "@/lib/api";
29
30
33async function downloadPageMarkdown(pageId: string) {
34 try {
35 const res = await pageApi.exportMarkdown(pageId);
36 const text = await res.text();
37 const cd = res.headers.get("content-disposition") ?? "";
38 const m = /filename="?([^";]+)"?/.exec(cd);
39 const filename = m?.[1] || `${pageId}.md`;
40 const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
41 const url = URL.createObjectURL(blob);
42 const a = document.createElement("a");
43 a.href = url;
44 a.download = filename;
45 document.body.appendChild(a);
46 a.click();
47 a.remove();
48 URL.revokeObjectURL(url);
49 toast("success", "Exported as Markdown", filename);
50 } catch (e) {
51 toast("error", "Export failed", isApiError(e) ? e.message : e instanceof Error ? e.message : String(e));
52 }
53}
54
55
59export function Topbar() {
60 const params = useParams<{ workspace: string; id?: string }>();
61 const workspaceSlug = params?.workspace ?? "default";
62 const pageId = params?.id ?? null;
63 const router = useRouter();
64
65 const { data: workspace } = useWorkspace(workspaceSlug);
66 const { data: page } = usePage(pageId ?? "");
67 const versions = usePageVersions(pageId ?? "");
68 const favorite = useToggleFavorite();
69
70 const duplicatePage = useDuplicatePage(workspace?.id ?? "");
71 const watchQuery = usePageWatch(pageId ?? "");
72 const toggleWatch = useToggleWatch();
73 const watching = watchQuery.data?.watching ?? false;
74 const { unreadCount, isLoading: notificationsLoading } = useNotifications();
75
76 const [historyOpen, setHistoryOpen] = useState(false);
77 const [compareVersion, setCompareVersion] = useState<PageVersion | null>(null);
78 const [moreOpen, setMoreOpen] = useState(false);
79 const moreRef = useRef<HTMLDivElement>(null);
80
81
82 const moreTriggerRef = useRef<HTMLButtonElement>(null);
83 const moreMenuRef = useRef<HTMLDivElement>(null);
84 const toggleSidebar = useUiStore((s) => s.toggleSidebar);
85 const setMobileNav = useUiStore((s) => s.setMobileNav);
86 const setSearchDialog = useUiStore((s) => s.setSearchDialog);
87 const setCommandPalette = useUiStore((s) => s.setCommandPalette);
88 const setMove = useUiStore((s) => s.setMove);
89
90
91
92 const onToggleSidebar = () => {
93 if (typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches) {
94 toggleSidebar();
95 } else {
96 setMobileNav(!useUiStore.getState().mobileNavOpen);
97 }
98 };
99
100
101
102
103
104
105 const closeMoreMenu = (restoreFocus = true) => {
106 setMoreOpen(false);
107 if (restoreFocus) moreTriggerRef.current?.focus();
108 };
109
110 useEffect(() => {
111 if (!moreOpen) return;
112 const first = moreMenuRef.current?.querySelector<HTMLButtonElement>("[role='menuitem']");
113 first?.focus();
114 const onClick = (e: MouseEvent) => {
115 if (moreRef.current && !moreRef.current.contains(e.target as Node)) setMoreOpen(false);
116 };
117 const onKeyDown = (e: KeyboardEvent) => {
118 if (e.key === "Escape") {
119 e.stopPropagation();
120 closeMoreMenu();
121 return;
122 }
123 if (e.key === "Tab") {
124
125 e.preventDefault();
126 return;
127 }
128 const items = Array.from(
129 moreMenuRef.current?.querySelectorAll<HTMLButtonElement>("[role='menuitem']") ?? []
130 );
131 const idx = items.indexOf(document.activeElement as HTMLButtonElement);
132 if (e.key === "ArrowDown") {
133 e.preventDefault();
134 items[(idx + 1) % items.length]?.focus();
135 } else if (e.key === "ArrowUp") {
136 e.preventDefault();
137 items[(idx - 1 + items.length) % items.length]?.focus();
138 } else if (e.key === "Home") {
139 e.preventDefault();
140 items[0]?.focus();
141 } else if (e.key === "End") {
142 e.preventDefault();
143 items[items.length - 1]?.focus();
144 }
145 };
146 document.addEventListener("mousedown", onClick);
147 document.addEventListener("keydown", onKeyDown);
148 return () => {
149 document.removeEventListener("mousedown", onClick);
150 document.removeEventListener("keydown", onKeyDown);
151 };
152
153
154
155 }, [moreOpen]);
156
157 const crumbs: { label: string; href?: string }[] = [{ label: workspace?.name ?? workspaceSlug }];
158 if (page) crumbs.push({ label: page.title || "Untitled", href: `/w/${workspaceSlug}/page/${page.id}` });
159
160 return (
161 <header className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-border bg-background/80 px-3 backdrop-blur">
162 {}
163 <button
164 type="button"
165 onClick={onToggleSidebar}
166 className="shrink-0 rounded-md p-1.5 text-foreground/70 hover:bg-accent hover:text-foreground"
167 aria-label="Toggle sidebar"
168 >
169 <Menu className="size-4" aria-hidden />
170 </button>
171
172 {}
173 <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-sm">
174 {crumbs.map((crumb, i) => (
175 <span key={i} className="flex min-w-0 items-center gap-1">
176 {i > 0 ? <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" aria-hidden /> : null}
177 {crumb.href ? (
178 <Link href={crumb.href} className="truncate hover:text-foreground text-foreground/80">
179 {crumb.label}
180 </Link>
181 ) : (
182 <span className="truncate text-foreground/80">{crumb.label}</span>
183 )}
184 </span>
185 ))}
186 </nav>
187
188 {}
189 <div className="flex shrink-0 items-center gap-1">
190 {}
194 <a
195 href="/source/"
196 target="_blank"
197 rel="noopener noreferrer"
198 data-testid="source-link"
199 className="flex h-7 items-center gap-1.5 rounded-md px-2 text-sm text-foreground/80 hover:bg-accent hover:text-foreground"
200 aria-label="Browse source (read-only)"
201 >
202 <Code2 className="size-4" aria-hidden />
203 <span className="hidden sm:inline">Source</span>
204 </a>
205 {}
206 <StatusChip />
207 {}
209 <button
210 type="button"
211 data-testid="search-button"
212 className="flex h-7 items-center gap-1.5 rounded-md px-2 text-sm text-foreground/80 hover:bg-accent hover:text-foreground"
213 aria-label="Search (Command K)"
214 onClick={() => setSearchDialog(true)}
215 >
216 <Search className="size-4" aria-hidden />
217 <span className="hidden md:inline">Search</span>
218 <kbd className="hidden md:inline rounded border border-border bg-background px-1 py-0.5 text-[10px] text-muted-foreground" aria-hidden>
219 ⌘K
220 </kbd>
221 </button>
222 {}
227 <button
228 type="button"
229 data-testid="command-palette-button"
230 className="flex h-7 items-center gap-1.5 rounded-md px-2 text-sm text-foreground/80 hover:bg-accent hover:text-foreground"
231 aria-label="Command palette"
232 onClick={() => setCommandPalette(true)}
233 >
234 <Command className="size-4" aria-hidden />
235 <span className="hidden md:inline">Commands</span>
236 </button>
237 {}
240 <button
241 type="button"
242 data-testid="notification-bell"
243 className="relative flex h-7 items-center gap-1.5 rounded-md px-2 text-sm text-foreground/80 hover:bg-accent hover:text-foreground"
244 aria-label={
245 unreadCount > 0
246 ? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`
247 : "Notifications"
248 }
249 onClick={() => router.push(`/w/${workspaceSlug}/notifications`)}
250 >
251 <Bell className="size-4" aria-hidden />
252 {unreadCount > 0 ? (
253 <span
254 className="inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground"
255 aria-hidden
256 >
257 {unreadCount > 99 ? "99+" : unreadCount}
258 </span>
259 ) : null}
260 {notificationsLoading ? (
261 <span className="sr-only">loading notification count</span>
262 ) : null}
263 </button>
264
265 {page ? (
266 <>
267 <TopbarButton
268 label="Share page"
269 onClick={() => window.open(`/w/${workspaceSlug}/page/${page.id}/share`, "_self")}
270 >
271 <Share2 className="size-4" aria-hidden />
272 <span className="hidden sm:inline">Share</span>
273 </TopbarButton>
274
275 {}
277 <TopbarButton
278 data-testid="page-watch-button"
279 label={watching ? "Stop watching this page" : "Watch this page"}
280 onClick={() => void toggleWatch.mutate({ id: page.id, watching })}
281 active={watching}
282 aria-pressed={watching}
283 >
284 <Eye className={cn("size-4", watching && "fill-sky-400 text-sky-500")} aria-hidden />
285 </TopbarButton>
286
287 <TopbarButton
288 label={page.favorite ? "Remove from favorites" : "Add to favorites"}
289 onClick={() => void favorite.mutate(page.id)}
290 active={page.favorite}
291 >
292 <Star className={cn("size-4", page.favorite && "fill-amber-400 text-amber-500")} aria-hidden />
293 </TopbarButton>
294
295 <div className="relative">
296 <TopbarButton label="Page history" onClick={() => setHistoryOpen((v) => !v)} active={historyOpen}>
297 <History className="size-4" aria-hidden />
298 </TopbarButton>
299 {historyOpen ? (
300 <HistoryPopover
301 versions={versions.data ?? []}
302 isLoading={versions.isLoading}
303 pageId={page.id}
304 onClose={() => setHistoryOpen(false)}
305 onCompare={(v) => {
306 setHistoryOpen(false);
307 setCompareVersion(v);
308 }}
309
310
311 canEdit={(workspace?.role ?? "").toLowerCase() !== "guest"}
312 />
313 ) : null}
314 </div>
315
316 <div className="relative" ref={moreRef}>
317 <TopbarButton
318 ref={moreTriggerRef}
319 label="More actions"
320 data-testid="page-more-menu"
321 aria-haspopup="menu"
322 aria-expanded={moreOpen}
323 onClick={() => (moreOpen ? closeMoreMenu(false) : setMoreOpen(true))}
324 active={moreOpen}
325 >
326 <MoreHorizontal className="size-4" aria-hidden />
327 </TopbarButton>
328 {moreOpen ? (
329 <div
330 ref={moreMenuRef}
331 className="absolute right-0 z-40 mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
332 role="menu"
333 >
334 <MenuItem
335 label="Copy link"
336 onClick={() => {
337 void navigator.clipboard.writeText(window.location.href).catch(() => undefined);
338 setMoreOpen(false);
339 }}
340 />
341 <MenuItem
342 label="Rename…"
343 onClick={() => setMoreOpen(false)}
344 shortcut="—"
345 />
346 <MenuItem
347 label="Move to…"
348 onClick={() => {
349 setMoreOpen(false);
350 if (page && workspace) {
351 setMove(true, { pageId: page.id, workspaceId: workspace.id, title: page.title || "Untitled" });
352 }
353 }}
354 />
355 <MenuItem
356 label="Duplicate"
357 onClick={() => {
358 setMoreOpen(false);
359 if (page) {
360
361
362
363 void duplicatePage
364 .mutateAsync({ id: page.id, parentId: page.parentId ?? null })
365 .then((copy) => {
366 toast("success", "Page duplicated", copy.title || "Untitled (copy)");
367 router.push(`/w/${workspaceSlug}/page/${copy.id}`);
368 })
369 .catch((e: unknown) =>
370 toast("error", "Duplicate failed", e instanceof Error ? e.message : String(e))
371 );
372 }
373 }}
374 />
375 <MenuItem
376 label="Export as Markdown…"
377 onClick={() => {
378 setMoreOpen(false);
379 if (page) void downloadPageMarkdown(page.id);
380 }}
381 />
382 {}
385 <MenuItem
386 label="History"
387 testId="page-more-history"
388 onClick={() => {
389 setMoreOpen(false);
390 setHistoryOpen(true);
391 }}
392 />
393 <MenuItem
394 label="Delete page"
395 destructive
396 onClick={() => setMoreOpen(false)}
397 />
398 </div>
399 ) : null}
400 </div>
401 </>
402 ) : null}
403 </div>
404 {compareVersion && page ? (
405 <VersionCompareDialog
406 pageId={page.id}
407 toVersion={compareVersion}
408 versions={versions.data ?? []}
409 onClose={() => setCompareVersion(null)}
410 />
411 ) : null}
412 </header>
413 );
414}
415
416const TopbarButton = forwardRef<
417 HTMLButtonElement,
418 React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string; active?: boolean }
419>(({ label, active, children, ...props }, ref) => (
420 <button
421 ref={ref}
422 type="button"
423 title={label}
424 aria-label={label}
425 className={cn(
426 "flex h-7 items-center gap-1.5 rounded-md px-2 text-sm text-foreground/80 hover:bg-accent hover:text-foreground",
427 active && "bg-accent text-foreground"
428 )}
429 {...props}
430 >
431 {children}
432 </button>
433));
434TopbarButton.displayName = "TopbarButton";
435
436function MenuItem({
437 label,
438 destructive,
439 shortcut,
440 testId,
441 onClick
442}: {
443 label: string;
444 destructive?: boolean;
445 shortcut?: string;
446 testId?: string;
447 onClick: () => void;
448}) {
449 return (
450 <button
451 type="button"
452 role="menuitem"
453 data-testid={testId}
454 onClick={onClick}
455 className={cn(
456 "flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
457 destructive ? "text-red-600" : "text-foreground/90"
458 )}
459 >
460 <span>{label}</span>
461 {shortcut ? <span className="text-xs text-muted-foreground">{shortcut}</span> : null}
462 </button>
463 );
464}
465