From 04f99183f9fca66fb11a60dc949b5a2a8adb9b0d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 22 Feb 2026 14:48:46 -0600 Subject: [PATCH] feat(web): implement full sidebar and header components (MS15-FE-003, MS15-FE-004) Sidebar: collapsible with icon-only mode, 4 nav groups (Overview, Workspace, Operations, System), SVG icons matching reference, badges, user card footer with avatar/initials, SidebarContext for collapse state. Header: search bar, system status indicator, terminal toggle, notification bell with badge, user avatar dropdown with Profile/Settings/Sign Out, breadcrumb navigation. Removes standalone LogoutButton. Layout updated to wrap with SidebarProvider, dynamic --sidebar-w on collapse. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/(authenticated)/layout.tsx | 78 +- apps/web/src/components/layout/AppHeader.tsx | 534 +++++++++++- apps/web/src/components/layout/AppSidebar.tsx | 806 +++++++++++++++--- .../src/components/layout/SidebarContext.tsx | 30 + 4 files changed, 1273 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/components/layout/SidebarContext.tsx diff --git a/apps/web/src/app/(authenticated)/layout.tsx b/apps/web/src/app/(authenticated)/layout.tsx index 0be3a61..68cc7a3 100644 --- a/apps/web/src/app/(authenticated)/layout.tsx +++ b/apps/web/src/app/(authenticated)/layout.tsx @@ -6,34 +6,39 @@ import { useAuth } from "@/lib/auth/auth-context"; import { IS_MOCK_AUTH_MODE } from "@/lib/config"; import { AppHeader } from "@/components/layout/AppHeader"; import { AppSidebar } from "@/components/layout/AppSidebar"; +import { SidebarProvider, useSidebar } from "@/components/layout/SidebarContext"; import { ChatOverlay } from "@/components/chat"; import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; import type { ReactNode } from "react"; -export default function AuthenticatedLayout({ - children, -}: { +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SIDEBAR_EXPANDED_WIDTH = "240px"; +const SIDEBAR_COLLAPSED_WIDTH = "60px"; + +// --------------------------------------------------------------------------- +// Inner shell — must be a child of SidebarProvider to use useSidebar +// --------------------------------------------------------------------------- + +interface AppShellProps { children: ReactNode; -}): React.JSX.Element | null { - const router = useRouter(); - const { isAuthenticated, isLoading } = useAuth(); +} - useEffect(() => { - if (!isLoading && !isAuthenticated) { - router.push("/login"); - } - }, [isAuthenticated, isLoading, router]); - - if (isLoading) { - return ; - } - - if (!isAuthenticated) { - return null; - } +function AppShell({ children }: AppShellProps): React.JSX.Element { + const { collapsed } = useSidebar(); return ( -
+
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */} @@ -62,3 +67,36 @@ export default function AuthenticatedLayout({
); } + +// --------------------------------------------------------------------------- +// Authenticated layout — handles auth guard + provides sidebar context +// --------------------------------------------------------------------------- + +export default function AuthenticatedLayout({ + children, +}: { + children: ReactNode; +}): React.JSX.Element | null { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuth(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/login"); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ; + } + + if (!isAuthenticated) { + return null; + } + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/layout/AppHeader.tsx b/apps/web/src/components/layout/AppHeader.tsx index 6164bca..1002bd7 100644 --- a/apps/web/src/components/layout/AppHeader.tsx +++ b/apps/web/src/components/layout/AppHeader.tsx @@ -1,21 +1,66 @@ "use client"; +import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useAuth } from "@/lib/auth/auth-context"; -import { LogoutButton } from "@/components/auth/LogoutButton"; import { ThemeToggle } from "./ThemeToggle"; /** * Full-width application header (topbar). * Logo/brand MUST live here — not in the sidebar — per MS15 design spec. * Spans grid-column 1 / -1 in the app shell grid layout. + * + * Layout (left → right): + * [Logo/Brand] [Breadcrumb] [Search] [spacer] + * [System Status] [Terminal Toggle] [Notifications] [Theme Toggle] [User Avatar+Dropdown] */ export function AppHeader(): React.JSX.Element { - const { user } = useAuth(); + const { user, signOut } = useAuth(); + const pathname = usePathname(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [searchFocused, setSearchFocused] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown on outside click + const handleOutsideClick = useCallback((event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }, []); + + useEffect(() => { + if (dropdownOpen) { + document.addEventListener("mousedown", handleOutsideClick); + } else { + document.removeEventListener("mousedown", handleOutsideClick); + } + return (): void => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, [dropdownOpen, handleOutsideClick]); + + // Derive breadcrumb segments from pathname + const breadcrumbSegments = pathname + .split("/") + .filter(Boolean) + .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, " ")); + + // User initials for avatar fallback + const initials = user?.name + ? user.name + .split(" ") + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() + : user?.email + ? (user.email[0] ?? "?").toUpperCase() + : "?"; return (
- {/* Brand / Logo — full-width header, left-anchored */} + {/* ── Brand / Logo ── */} - {/* Spacer */} -
+ {/* ── Breadcrumb ── */} + - {/* Right side controls */} -
+ {/* ── Search Bar ── */} +
+ {/* Search icon */} + + { + setSearchFocused(true); + }} + onBlur={() => { + setSearchFocused(false); + }} + style={{ + flex: 1, + background: "none", + border: "none", + outline: "none", + color: "var(--text)", + fontSize: "0.83rem", + fontFamily: "inherit", + }} + aria-label="Search projects, agents, tasks" + /> +
+ + {/* ── Spacer ── */} +
+ + {/* ── Right side controls ── */} +
+ {/* System Status */} +
+ + + {/* Terminal Toggle */} + + + {/* Notifications */} + + + {/* Theme Toggle */} - {user && ( - - {user.name || user.email} - - )} + {/* User Avatar + Dropdown */} +
+ - + {/* Dropdown Menu */} + {dropdownOpen && ( +
+ {/* User info header */} +
+
+ {user?.name ?? "User"} +
+ {user?.email && ( +
+ {user.email} +
+ )} +
+ + {/* Divider */} +
); } + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Terminal toggle button — visual only; no panel wired yet. */ +function TerminalToggleButton(): React.JSX.Element { + const [hovered, setHovered] = useState(false); + + return ( + + ); +} + +interface DropdownItemProps { + href: string; + onClick: () => void; + children: React.ReactNode; +} + +/** A navigation link styled as a dropdown menu item. */ +function DropdownItem({ href, onClick, children }: DropdownItemProps): React.JSX.Element { + const [hovered, setHovered] = useState(false); + + return ( + { + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + > + {children} + + ); +} diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx index 6f27c8e..ef3ecb3 100644 --- a/apps/web/src/components/layout/AppSidebar.tsx +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -2,159 +2,709 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import Image from "next/image"; +import { useAuth } from "@/lib/auth/auth-context"; +import { useSidebar } from "./SidebarContext"; -interface NavItem { +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface NavBadge { + label: string; + pulse?: boolean; +} + +interface NavItemConfig { href: string; label: string; icon: React.JSX.Element; + badge?: NavBadge; } -const navItems: NavItem[] = [ +interface NavGroup { + label: string; + items: NavItemConfig[]; +} + +// --------------------------------------------------------------------------- +// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5") +// --------------------------------------------------------------------------- + +function IconDashboard(): React.JSX.Element { + return ( + + ); +} + +function IconProjects(): React.JSX.Element { + return ( + + ); +} + +function IconProjectWorkspace(): React.JSX.Element { + return ( + + ); +} + +function IconKanban(): React.JSX.Element { + return ( + + ); +} + +function IconFileManager(): React.JSX.Element { + return ( + + ); +} + +function IconLogs(): React.JSX.Element { + return ( + + ); +} + +function IconTerminal(): React.JSX.Element { + return ( + + ); +} + +function IconSettings(): React.JSX.Element { + return ( + + ); +} + +function IconChevronLeft(): React.JSX.Element { + return ( + + ); +} + +function IconChevronRight(): React.JSX.Element { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Nav groups definition +// --------------------------------------------------------------------------- + +const NAV_GROUPS: NavGroup[] = [ { - href: "/", - label: "Dashboard", - icon: ( - - ), + label: "Overview", + items: [ + { + href: "/", + label: "Dashboard", + icon: , + badge: { label: "live", pulse: true }, + }, + ], }, { - href: "/tasks", - label: "Tasks", - icon: ( - - ), + label: "Workspace", + items: [ + { + href: "/projects", + label: "Projects", + icon: , + }, + { + href: "/workspace", + label: "Project Workspace", + icon: , + }, + { + href: "/kanban", + label: "Kanban", + icon: , + }, + { + href: "/files", + label: "File Manager", + icon: , + }, + ], }, { - href: "/calendar", - label: "Calendar", - icon: ( - - ), + label: "Operations", + items: [ + { + href: "/logs", + label: "Logs & Telemetry", + icon: , + badge: { label: "live", pulse: true }, + }, + { + href: "#terminal", + label: "Terminal", + icon: , + }, + ], }, { - href: "/knowledge", - label: "Knowledge", - icon: ( - - ), - }, - { - href: "/usage", - label: "Usage", - icon: ( - - ), + label: "System", + items: [ + { + href: "/settings", + label: "Settings", + icon: , + }, + ], }, ]; +// --------------------------------------------------------------------------- +// Helper: derive initials from display name +// --------------------------------------------------------------------------- + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + const first = parts[0] ?? ""; + if (parts.length === 1) { + return first.slice(0, 2).toUpperCase(); + } + const last = parts[parts.length - 1] ?? ""; + return ((first[0] ?? "") + (last[0] ?? "")).toUpperCase(); +} + +// --------------------------------------------------------------------------- +// NavBadge component +// --------------------------------------------------------------------------- + +interface NavBadgeProps { + badge: NavBadge; +} + +function NavBadgeChip({ badge }: NavBadgeProps): React.JSX.Element { + const pulseStyle: React.CSSProperties = badge.pulse + ? { + background: "rgba(47,128,255,0.15)", + color: "var(--primary-l)", + } + : { + background: "var(--surface-2)", + color: "var(--muted)", + }; + + return ( + + {badge.pulse && ( + + ); +} + +// --------------------------------------------------------------------------- +// NavItem component +// --------------------------------------------------------------------------- + +interface NavItemProps { + item: NavItemConfig; + isActive: boolean; + collapsed: boolean; +} + +function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element { + const baseStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "11px", + padding: "9px 10px", + borderRadius: "6px", + fontSize: "0.875rem", + fontWeight: 500, + color: isActive ? "var(--text)" : "var(--muted)", + background: isActive ? "var(--surface)" : "transparent", + position: "relative", + transition: "background 0.12s ease, color 0.12s ease", + textDecoration: "none", + justifyContent: collapsed ? "center" : undefined, + whiteSpace: "nowrap", + overflow: "hidden", + }; + + const iconStyle: React.CSSProperties = { + width: "16px", + height: "16px", + flexShrink: 0, + opacity: isActive ? 1 : 0.7, + transition: "opacity 0.12s ease", + }; + + const content = ( + <> + {/* Active left accent bar */} + {isActive && ( +