"use client"; import { forwardRef, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { Bell, ChevronRight, Code2, Command, Eye, History, Menu, MoreHorizontal, Search, Share2, Star } from "lucide-react"; import { useWorkspace } from "@/hooks/use-workspace"; import { useDuplicatePage, usePage, usePageVersions, useToggleFavorite, usePageWatch, useToggleWatch } from "@/hooks/use-pages"; import { useNotifications } from "@/hooks/use-notifications"; import { HistoryPopover } from "@/components/editor/history-popover"; import { VersionCompareDialog } from "@/components/editor/version-compare-dialog"; import type { PageVersion } from "@/lib/types"; import { StatusChip } from "@/components/collaboration/StatusChip"; import { toast, useUiStore } from "@/stores/ui-store"; import { cn } from "@/lib/utils"; import { pageApi, isApiError } from "@/lib/api"; /** T-180 — trigger a browser download for the page's markdown export. * Reads the body + filename from the raw response (the server signs the * filename into Content-Disposition) and uses the house blob/anchor pattern. */ async function downloadPageMarkdown(pageId: string) { try { const res = await pageApi.exportMarkdown(pageId); const text = await res.text(); const cd = res.headers.get("content-disposition") ?? ""; const m = /filename="?([^";]+)"?/.exec(cd); const filename = m?.[1] || `${pageId}.md`; const blob = new Blob([text], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast("success", "Exported as Markdown", filename); } catch (e) { toast("error", "Export failed", isApiError(e) ? e.message : e instanceof Error ? e.message : String(e)); } } /** * Top bar: breadcrumb (workspace / … / page), share, favorite (star), * history (version list popover) and a "more" menu. */ export function Topbar() { const params = useParams<{ workspace: string; id?: string }>(); const workspaceSlug = params?.workspace ?? "default"; const pageId = params?.id ?? null; const router = useRouter(); const { data: workspace } = useWorkspace(workspaceSlug); const { data: page } = usePage(pageId ?? ""); const versions = usePageVersions(pageId ?? ""); const favorite = useToggleFavorite(); // T-138: recursive duplicate (blocks + child pages + database snapshot). const duplicatePage = useDuplicatePage(workspace?.id ?? ""); const watchQuery = usePageWatch(pageId ?? ""); const toggleWatch = useToggleWatch(); const watching = watchQuery.data?.watching ?? false; const { unreadCount, isLoading: notificationsLoading } = useNotifications(); const [historyOpen, setHistoryOpen] = useState(false); const [compareVersion, setCompareVersion] = useState(null); const [moreOpen, setMoreOpen] = useState(false); const moreRef = useRef(null); // a11y (P8): ARIA menu-button pattern — the trigger's node ref (to restore // focus on close) and the menu's node ref (arrow-key navigation + focus). const moreTriggerRef = useRef(null); const moreMenuRef = useRef(null); const toggleSidebar = useUiStore((s) => s.toggleSidebar); const setMobileNav = useUiStore((s) => s.setMobileNav); const setSearchDialog = useUiStore((s) => s.setSearchDialog); const setCommandPalette = useUiStore((s) => s.setCommandPalette); const setMove = useUiStore((s) => s.setMove); // Breakpoint-aware toggle: mobile (<768px) opens the OVERLAY drawer // (docs/product/ux-requirements.md); desktop toggles the in-flow column. const onToggleSidebar = () => { if (typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches) { toggleSidebar(); } else { setMobileNav(!useUiStore.getState().mobileNavOpen); } }; // a11y (P8) — WAI-ARIA menu-button pattern for the "More" menu: // - while open: move focus to the first item; ArrowUp/Down wrap through // the items, Home/End jump, Escape closes and returns focus to the // trigger, and click-outside closes (existing behavior). // - the trigger carries aria-expanded + aria-haspopup="menu". const closeMoreMenu = (restoreFocus = true) => { setMoreOpen(false); if (restoreFocus) moreTriggerRef.current?.focus(); }; useEffect(() => { if (!moreOpen) return; const first = moreMenuRef.current?.querySelector("[role='menuitem']"); first?.focus(); const onClick = (e: MouseEvent) => { if (moreRef.current && !moreRef.current.contains(e.target as Node)) setMoreOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); closeMoreMenu(); return; } if (e.key === "Tab") { // keep the focus trap: Tab inside a menu re-focuses within it e.preventDefault(); return; } const items = Array.from( moreMenuRef.current?.querySelectorAll("[role='menuitem']") ?? [] ); const idx = items.indexOf(document.activeElement as HTMLButtonElement); if (e.key === "ArrowDown") { e.preventDefault(); items[(idx + 1) % items.length]?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus(); } else if (e.key === "Home") { e.preventDefault(); items[0]?.focus(); } else if (e.key === "End") { e.preventDefault(); items[items.length - 1]?.focus(); } }; document.addEventListener("mousedown", onClick); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("mousedown", onClick); document.removeEventListener("keydown", onKeyDown); }; // closeMoreMenu is stable-enough (it only calls setState + ref.focus); // re-adding the listener on `moreOpen` changes is what we want. // eslint-disable-next-line react-hooks/exhaustive-deps }, [moreOpen]); const crumbs: { label: string; href?: string }[] = [{ label: workspace?.name ?? workspaceSlug }]; if (page) crumbs.push({ label: page.title || "Untitled", href: `/w/${workspaceSlug}/page/${page.id}` }); return (
{/* Sidebar toggle (drawer on mobile, in-flow column on desktop) */} {/* Breadcrumb */} {/* Actions */}
{/* Source browser: read-only browse of the full repository (code, task specs, roadmap, changelog). Served at /source/ by a local read-only viewer service — a separate origin-free route, no app state involved. */} Source {/* Connection status chip (TASK-0095 R2): Live / Reconnecting… / Offline. */} {/* Global search (TASK-0098) — opens the ⌘K dialog; the bare meta/ctrl+k binding lives in workspace-shell.tsx. */} {/* Command palette (TASK-0122): ⌘K was reassigned to the search dialog (TASK-0098), which left the palette with NO trigger — its "Ask AI" action was unreachable. This button restores the only entry point (the palette's own ⌘K-style keyboard nav is internal once open). */} {/* Notification bell (TASK-0094 R5) — global (works outside a page too). The unread count is the API's authoritative figure, bumped live by the `notification.push` frame (useNotifications). */} {page ? ( <> window.open(`/w/${workspaceSlug}/page/${page.id}/share`, "_self")} > Share {/* T-157: watchlist — get notified when this page changes. Mirrors the favorite button (own query + active visual, no toast). */} void toggleWatch.mutate({ id: page.id, watching })} active={watching} aria-pressed={watching} > void favorite.mutate(page.id)} active={page.favorite} >
setHistoryOpen((v) => !v)} active={historyOpen}> {historyOpen ? ( setHistoryOpen(false)} onCompare={(v) => { setHistoryOpen(false); setCompareVersion(v); }} // EDIT gate for "Snapshot now" (same role check as // page-view's `canGenerate`; VIEW users see the list only). canEdit={(workspace?.role ?? "").toLowerCase() !== "guest"} /> ) : null}
(moreOpen ? closeMoreMenu(false) : setMoreOpen(true))} active={moreOpen} > {moreOpen ? (
{ void navigator.clipboard.writeText(window.location.href).catch(() => undefined); setMoreOpen(false); }} /> setMoreOpen(false)} shortcut="—" /> { setMoreOpen(false); if (page && workspace) { setMove(true, { pageId: page.id, workspaceId: workspace.id, title: page.title || "Untitled" }); } }} /> { setMoreOpen(false); if (page) { // Server defaults the copy's title to " (copy)"; // land on the copy (a fresh read — the page query is // cached, so no stale title). void duplicatePage .mutateAsync({ id: page.id, parentId: page.parentId ?? null }) .then((copy) => { toast("success", "Page duplicated", copy.title || "Untitled (copy)"); router.push(`/w/${workspaceSlug}/page/${copy.id}`); }) .catch((e: unknown) => toast("error", "Duplicate failed", e instanceof Error ? e.message : String(e)) ); } }} /> <MenuItem label="Export as Markdown…" onClick={() => { setMoreOpen(false); if (page) void downloadPageMarkdown(page.id); }} /> {/* Version history (tests/README.md `page-more-history`, the spec's "⋯ menu → History" path — opens the HistoryPopover, `page-versions` + `page-version-entry`). */} <MenuItem label="History" testId="page-more-history" onClick={() => { setMoreOpen(false); setHistoryOpen(true); }} /> <MenuItem label="Delete page" destructive onClick={() => setMoreOpen(false)} /> </div> ) : null} </div> </> ) : null} </div> {compareVersion && page ? ( <VersionCompareDialog pageId={page.id} toVersion={compareVersion} versions={versions.data ?? []} onClose={() => setCompareVersion(null)} /> ) : null} </header> ); } const TopbarButton = forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string; active?: boolean } >(({ label, active, children, ...props }, ref) => ( <button ref={ref} type="button" title={label} aria-label={label} className={cn( "flex h-7 items-center gap-1.5 rounded-md px-2 text-sm text-foreground/80 hover:bg-accent hover:text-foreground", active && "bg-accent text-foreground" )} {...props} > {children} </button> )); TopbarButton.displayName = "TopbarButton"; function MenuItem({ label, destructive, shortcut, testId, onClick }: { label: string; destructive?: boolean; shortcut?: string; testId?: string; onClick: () => void; }) { return ( <button type="button" role="menuitem" data-testid={testId} onClick={onClick} className={cn( "flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-sm hover:bg-accent", destructive ? "text-red-600" : "text-foreground/90" )} > <span>{label}</span> {shortcut ? <span className="text-xs text-muted-foreground">{shortcut}</span> : null} </button> ); }