"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useParams, useRouter } from "next/navigation"; import { Activity, Archive, ArchiveRestore, Bookmark, CheckSquare, ChevronDown, File as FileIcon, FilePlus2, FolderPlus, LayoutGrid, LogOut, Search, Settings, Star, StarOff, Trash2, UserPlus, X } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useWorkspaces, useWorkspace } from "@/hooks/use-workspace"; import { useCreatePage, useDuplicatePage, usePageTree } from "@/hooks/use-pages"; import { pageApi } from "@/lib/api"; import { toast, useUiStore } from "@/stores/ui-store"; import { FactoryLogo } from "./factory-logo"; import { PageTreeList } from "./page-tree-node"; import { cn } from "@/lib/utils"; /** * Workspace sidebar (PRODUCT-SPEC §27): switcher, primary nav, private page * tree (recursive + lazy), shared section, user/settings footer. */ export function Sidebar() { const { user } = useAuth(); const pathname = usePathname(); const params = useParams<{ workspace: string }>(); const workspaceSlug = params?.workspace ?? "default"; const { data: workspace } = useWorkspace(workspaceSlug); const { data: allWorkspaces } = useWorkspaces(); const [switcherOpen, setSwitcherOpen] = useState(false); const collapsed = useUiStore((s) => s.sidebarCollapsed); // Mobile (<640px) — the sidebar is an OVERLAY drawer (docs/product/ // ux-requirements.md: single-column everything + the z-index tokens' // "sidebar drawer (mobile) 90"), closed by default so the editor is // full-width. Desktop keeps the in-flow column toggled by `collapsed`. const mobileNavOpen = useUiStore((s) => s.mobileNavOpen); const setMobileNav = useUiStore((s) => s.setMobileNav); // Close the drawer on navigation (the destination page starts full-width, // not covered by the drawer). const prevPathRef = useRef(pathname); useEffect(() => { if (prevPathRef.current !== pathname) { prevPathRef.current = pathname; if (useUiStore.getState().mobileNavOpen) setMobileNav(false); } }, [pathname, setMobileNav]); // Private tree: top-level pages only; children lazy-load per node. const tree = usePageTree(workspace?.id ?? "", null); const createPage = useCreatePage(workspace?.id ?? ""); const duplicatePage = useDuplicatePage(workspace?.id ?? ""); const router = useRouter(); // T-192: the sidebar tree is lazy + collapsed-by-default, so a page whose // PARENT is collapsed is invisible — after "Add a sub-page" navigates into // the new child, its row never renders (the nested E2E). On a page route, // walk the active page's ancestor chain (page → parent → … → root) and // auto-expand those nodes so the active page is always visible. const activePageId = pathname.includes("/page/") ? pathname.split("/page/")[1]?.split("/")[0] ?? null : null; const [autoExpandIds, setAutoExpandIds] = useState>(new Set()); useEffect(() => { if (!activePageId) { setAutoExpandIds(new Set()); return; } let cancelled = false; const chain: string[] = []; void (async () => { let id: string | null = activePageId; const seen = new Set(); while (id && !seen.has(id)) { seen.add(id); chain.push(id); try { const p = await pageApi.get(id); id = p.parentId ?? null; } catch { break; // 404 / forbidden — stop at the top of what we can see } } if (!cancelled) setAutoExpandIds(new Set(chain)); })(); return () => { cancelled = true; }; }, [activePageId]); // Tree rows request page creation via a DOM event (keeps the recursive // node component free of router/mutation coupling). useEffect(() => { if (!workspace?.id) return; const onNewPage = (e: Event) => { const { parentId, title } = (e as CustomEvent<{ parentId: string | null; title?: string }>).detail ?? {}; // `title` (optional) lets "New folder" pre-name the page; the page is // opened for an immediate in-place rename. A folder is just a page — // it becomes a folder the moment the first sub-page lands in it. void createPage.mutateAsync({ parentId: parentId ?? null, title: title ?? "" }).then((page) => { router.push(`/w/${workspaceSlug}/page/${page.id}`); }); }; window.addEventListener("factory:new-page", onNewPage); return () => window.removeEventListener("factory:new-page", onNewPage); }, [workspace?.id, createPage, router, workspaceSlug]); // T-138: tree rows request a duplicate via a DOM event too (same // decoupling as `factory:new-page`). The server defaults the copy's title // to ` (copy)`; we land ON the copy (like a create) so the user // sees the recursive result immediately. useEffect(() => { if (!workspace?.id) return; const onPageAction = (e: Event) => { const { action, pageId, parentId } = ( e as CustomEvent<{ action: string; pageId: string; parentId?: string | null }> ).detail ?? {}; if (action !== "duplicate" || !pageId) return; void duplicatePage .mutateAsync({ id: pageId, parentId: parentId ?? undefined }) .then((page) => { toast("success", "Page duplicated", page.title || "Untitled (copy)"); router.push(`/w/${workspaceSlug}/page/${page.id}`); }) .catch((e: unknown) => toast("error", "Duplicate failed", e instanceof Error ? e.message : String(e)) ); }; window.addEventListener("factory:page-action", onPageAction); return () => window.removeEventListener("factory:page-action", onPageAction); }, [workspace?.id, duplicatePage, router, workspaceSlug]); const nav = useMemo( () => [ { href: `/w/${workspaceSlug}`, label: "Home", icon: LayoutGrid, exact: true }, { href: `/w/${workspaceSlug}/inbox`, label: "Inbox", icon: CheckSquare }, { href: `/w/${workspaceSlug}/favorites`, label: "Favorites", icon: Bookmark }, { href: `/w/${workspaceSlug}/files`, label: "Files", icon: FileIcon }, { href: `/w/${workspaceSlug}/search`, label: "Search", icon: Search }, { href: `/w/${workspaceSlug}/activity`, label: "Activity", icon: Activity }, // `trash-section` (tests/README.md): the sidebar Trash node — the // navigation target the E2E clicks (the trash LIST lives on the page). { href: `/w/${workspaceSlug}/trash`, label: "Trash", icon: Trash2, testId: "trash-section" }, { href: `/w/${workspaceSlug}/templates`, label: "Templates", icon: FilePlus2 } ], [workspaceSlug] ); // Desktop (≥640px): the in-flow column follows `collapsed`. Mobile // (<640px): the sidebar is an OVERLAY drawer (docs/product/ // ux-requirements.md — "single-column everything", z-index tokens' // "sidebar drawer (mobile) 90"), closed by default so the editor is // full-width single-column. Each breakpoint renders exactly one mode // (the column is `hidden` below md:, the drawer is `md:hidden`). if (collapsed && !mobileNavOpen) return null; const content = ( <> {/* Mobile: close the drawer (the topbar hamburger re-opens it). */} <button type="button" onClick={() => setMobileNav(false)} className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-foreground/60 hover:bg-accent hover:text-foreground md:hidden" aria-label="Close sidebar" > <X className="size-4" aria-hidden /> </button> {/* Workspace switcher */} <div className="relative border-b border-border p-2"> <button type="button" onClick={() => setSwitcherOpen((v) => !v)} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" aria-expanded={switcherOpen} aria-haspopup="menu" > <FactoryLogo brand className="size-6 shrink-0 rounded" /> <span className="min-w-0 flex-1 truncate text-left">{workspace?.name ?? workspaceSlug}</span> <ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden /> </button> {switcherOpen ? ( <div className="absolute left-2 right-2 z-40 mt-1 rounded-md border border-border bg-popover p-1 shadow-lg" role="menu"> {(allWorkspaces ?? []).map((ws) => ( <Link key={ws.id} href={`/w/${ws.slug || ws.id}`} onClick={() => setSwitcherOpen(false)} className={cn( "flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent", ws.id === workspace?.id && "bg-accent/60" )} role="menuitem" > <span className="size-5 rounded bg-muted" aria-hidden /> <span className="min-w-0 flex-1 truncate">{ws.name}</span> </Link> ))} <Link href={`/w/${workspaceSlug}/settings/members`} onClick={() => setSwitcherOpen(false)} 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" role="menuitem" > <UserPlus className="size-4" aria-hidden /> Invite people </Link> </div> ) : null} </div> {/* Primary nav */} <nav className="space-y-0.5 p-2" aria-label="Primary"> {nav.map((item) => { const active = item.exact ? pathname === item.href : pathname.startsWith(item.href) && pathname !== `/w/${workspaceSlug}`; return ( <Link key={item.label} href={item.href} data-testid={item.testId} className={cn( "flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-foreground/80 hover:bg-accent hover:text-foreground", active && "bg-accent font-medium text-foreground" )} > <item.icon className="size-4 shrink-0" aria-hidden /> {item.label} </Link> ); })} {/* T-158: the USER activity feed is cross-workspace, so it links to the top-level /activity (outside the [workspace] segment). */} <Link href="/activity" className={cn( "flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-foreground/80 hover:bg-accent hover:text-foreground", pathname.startsWith("/activity") && "bg-accent font-medium text-foreground" )} > <Activity className="size-4 shrink-0" aria-hidden /> My activity </Link> </nav> {/* Private pages */} <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2"> <div className="flex items-center justify-between px-2 py-1"> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Private</span> <div className="flex items-center gap-0.5"> <button type="button" title="New folder" aria-label="New folder" onClick={() => window.dispatchEvent( new CustomEvent("factory:new-page", { detail: { parentId: null, title: "New folder" } }) ) } className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" > <FolderPlus className="size-3.5" aria-hidden /> </button> <button type="button" title="New page" aria-label="New page" data-testid="sidebar-new-page" onClick={() => window.dispatchEvent(new CustomEvent("factory:new-page", { detail: { parentId: null } }))} className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" > <FilePlus2 className="size-3.5" aria-hidden /> </button> </div> </div> {tree.isLoading ? ( <p className="px-2 py-1 text-xs text-muted-foreground">Loading…</p> ) : ( <PageTreeList entries={tree.data ?? []} depth={0} workspaceSlug={workspaceSlug} workspaceId={workspace?.id ?? ""} autoExpand={autoExpandIds} /> )} </div> {/* T-139: bulk multi-select action bar. Visible while pages are selected (in THIS workspace); opens the confirm dialog for a chosen action. */} <BulkActionBar workspaceId={workspace?.id ?? ""} /> {/* Shared with me */} <div className="border-t border-border px-2 py-2"> <Link href={`/w/${workspaceSlug}/shared`} 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" > <Bookmark className="size-4 text-muted-foreground" aria-hidden /> Shared with me </Link> </div> {/* Footer: settings + user */} <div className="border-t border-border p-2"> <Link href={`/w/${workspaceSlug}/settings`} 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" > <Settings className="size-4" aria-hidden /> Settings </Link> <UserCard name={user?.name ?? ""} email={user?.email ?? ""} image={user?.image ?? null} /> </div> </> ); return ( <> {/* Mobile drawer (overlay, z-90 per the ux z-index tokens): mounted only while open — closed by default so the editor is full-width. */} {mobileNavOpen ? ( <div className="fixed inset-0 z-[90] flex md:hidden" role="dialog" aria-label="Navigation"> <button type="button" aria-label="Close sidebar" onClick={() => setMobileNav(false)} className="min-w-0 flex-1 cursor-default bg-black/40" /> <aside className="relative flex h-full w-64 shrink-0 flex-col border-r border-border bg-background" aria-label="Workspace sidebar" data-testid="sidebar" > {content} </aside> </div> ) : null} {/* Desktop in-flow column (hidden below md: — the drawer is the only mobile mode, and it is off-canvas). */} {!collapsed ? ( <aside className="relative hidden h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30 md:flex" aria-label="Workspace sidebar" data-testid="sidebar" > {content} </aside> ) : null} </> ); } function UserCard({ name, email, image }: { name: string; email: string; image: string | null }) { const router = useRouter(); const logout = async () => { // Non-hook logout path (hook form lives in use-auth.ts). const { authApi } = await import("@/lib/api"); const { authStore } = await import("@/lib/auth-store"); try { await authApi.logout(); } catch { /* best effort */ } authStore.clear(); router.replace("/login"); }; return ( <div data-testid="user-menu" className="mt-1 flex items-center gap-2 rounded-md px-2 py-1.5"> <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> {image ? ( // eslint-disable-next-line @next/next/no-img-element <img src={image} alt="" className="size-full object-cover" /> ) : name ? ( name.slice(0, 1).toUpperCase() ) : ( "?" )} </span> <span className="min-w-0 flex-1"> <span className="block truncate text-sm font-medium">{name || "You"}</span> <span className="block truncate text-xs text-muted-foreground">{email}</span> </span> <button type="button" data-testid="user-menu-logout" onClick={() => void logout()} title="Log out" aria-label="Log out" className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" > <LogOut className="size-4" aria-hidden /> </button> </div> ); } /** * T-139 — the bulk multi-select action bar. Shown while pages are selected in * the CURRENT workspace (the selection is workspace-scoped, so switching * workspaces hides it). Each button opens the confirm dialog (workspace shell) * for that action; destructive ones (trash) confirm, the rest confirm too — * bulk ops are batch + audited, so the dialog always asks before firing. */ function BulkActionBar({ workspaceId }: { workspaceId: string }) { const pageSelection = useUiStore((s) => s.pageSelection); const setBulk = useUiStore((s) => s.setBulk); const clearPageSelection = useUiStore((s) => s.clearPageSelection); const active = workspaceId !== "" && pageSelection.workspaceId === workspaceId && pageSelection.ids.length > 0; if (!active) return null; const n = pageSelection.ids.length; const act = (action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite") => setBulk(true, action); const btn = "rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"; return ( <div className="sticky bottom-0 z-10 flex items-center gap-1 border-t border-border bg-background px-2 py-1.5" data-testid="bulk-action-bar" > <span className="mr-auto text-xs font-medium text-foreground/80" data-testid="bulk-count"> {n} selected </span> <button type="button" title="Favorite" aria-label="Favorite selected" data-testid="bulk-favorite" onClick={() => act("favorite")} className={btn}> <Star className="size-3.5" aria-hidden /> </button> <button type="button" title="Unfavorite" aria-label="Unfavorite selected" data-testid="bulk-unfavorite" onClick={() => act("unfavorite")} className={btn}> <StarOff className="size-3.5" aria-hidden /> </button> <button type="button" title="Archive" aria-label="Archive selected" data-testid="bulk-archive" onClick={() => act("archive")} className={btn}> <Archive className="size-3.5" aria-hidden /> </button> <button type="button" title="Unarchive" aria-label="Unarchive selected" data-testid="bulk-unarchive" onClick={() => act("unarchive")} className={btn}> <ArchiveRestore className="size-3.5" aria-hidden /> </button> <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"> <Trash2 className="size-3.5" aria-hidden /> </button> <button type="button" title="Clear selection" aria-label="Clear selection" onClick={clearPageSelection} className={btn}> <X className="size-3.5" aria-hidden /> </button> </div> ); }