feat(web): implement full sidebar and header components (MS15-FE-003, MS15-FE-004)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
@@ -6,34 +6,39 @@ import { useAuth } from "@/lib/auth/auth-context";
|
|||||||
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||||
import { AppHeader } from "@/components/layout/AppHeader";
|
import { AppHeader } from "@/components/layout/AppHeader";
|
||||||
import { AppSidebar } from "@/components/layout/AppSidebar";
|
import { AppSidebar } from "@/components/layout/AppSidebar";
|
||||||
|
import { SidebarProvider, useSidebar } from "@/components/layout/SidebarContext";
|
||||||
import { ChatOverlay } from "@/components/chat";
|
import { ChatOverlay } from "@/components/chat";
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import type { ReactNode } from "react";
|
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;
|
children: ReactNode;
|
||||||
}): React.JSX.Element | null {
|
}
|
||||||
const router = useRouter();
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
function AppShell({ children }: AppShellProps): React.JSX.Element {
|
||||||
if (!isLoading && !isAuthenticated) {
|
const { collapsed } = useSidebar();
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, router]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <MosaicSpinner size={48} fullPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div
|
||||||
|
className="app-shell"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
||||||
|
transition: "grid-template-columns 0.2s var(--ease, ease)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
|
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
@@ -62,3 +67,36 @@ export default function AuthenticatedLayout({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 <MosaicSpinner size={48} fullPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width application header (topbar).
|
* Full-width application header (topbar).
|
||||||
* Logo/brand MUST live here — not in the sidebar — per MS15 design spec.
|
* Logo/brand MUST live here — not in the sidebar — per MS15 design spec.
|
||||||
* Spans grid-column 1 / -1 in the app shell grid layout.
|
* 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 {
|
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<HTMLDivElement>(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 (
|
return (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
{/* Brand / Logo — full-width header, left-anchored */}
|
{/* ── Brand / Logo ── */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2 flex-shrink-0"
|
className="flex items-center gap-2 flex-shrink-0"
|
||||||
@@ -29,23 +74,11 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="absolute rounded-sm"
|
className="absolute rounded-sm"
|
||||||
style={{
|
style={{ top: 0, left: 0, width: 11, height: 11, background: "var(--ms-blue-500)" }}
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 11,
|
|
||||||
height: 11,
|
|
||||||
background: "var(--ms-blue-500)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="absolute rounded-sm"
|
className="absolute rounded-sm"
|
||||||
style={{
|
style={{ top: 0, right: 0, width: 11, height: 11, background: "var(--ms-purple-500)" }}
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
width: 11,
|
|
||||||
height: 11,
|
|
||||||
background: "var(--ms-purple-500)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="absolute rounded-sm"
|
className="absolute rounded-sm"
|
||||||
@@ -94,21 +127,468 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* ── Breadcrumb ── */}
|
||||||
<div className="flex-1" />
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className="hidden sm:flex items-center"
|
||||||
|
style={{ fontSize: "0.8rem", color: "var(--text-2)", marginLeft: 4 }}
|
||||||
|
>
|
||||||
|
{breadcrumbSegments.length === 0 ? (
|
||||||
|
<span>Dashboard</span>
|
||||||
|
) : (
|
||||||
|
breadcrumbSegments.map((seg, idx) => (
|
||||||
|
<span key={idx} className="flex items-center gap-1">
|
||||||
|
{idx > 0 && <span style={{ color: "var(--muted)", margin: "0 2px" }}>/</span>}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: idx === breadcrumbSegments.length - 1 ? "var(--text-2)" : "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seg}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Right side controls */}
|
{/* ── Search Bar ── */}
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="hidden md:flex items-center"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: 340,
|
||||||
|
marginLeft: 16,
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${searchFocused ? "var(--primary)" : "var(--border)"}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "7px 12px",
|
||||||
|
transition: "border-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search icon */}
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ color: "var(--muted)", flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="7" cy="7" r="5" />
|
||||||
|
<path d="M11 11l3 3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search projects, agents, tasks… (⌘K)"
|
||||||
|
onFocus={() => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Spacer ── */}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* ── Right side controls ── */}
|
||||||
|
<div className="flex items-center" style={{ gap: 8 }}>
|
||||||
|
{/* System Status */}
|
||||||
|
<div
|
||||||
|
className="hidden lg:flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: 7,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
aria-label="System status: All Systems Operational"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--success)",
|
||||||
|
boxShadow: "0 0 5px var(--success)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--muted)" }}>All Systems</span>
|
||||||
|
<span style={{ color: "var(--success)" }}>Operational</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Toggle */}
|
||||||
|
<TerminalToggleButton />
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<button
|
||||||
|
title="Notifications"
|
||||||
|
aria-label="Notifications (1 unread)"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
position: "relative",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M8 1a5 5 0 0 1 5 5v2l1 2H2l1-2V6a5 5 0 0 1 5-5z" />
|
||||||
|
<path d="M6 13a2 2 0 0 0 4 0" />
|
||||||
|
</svg>
|
||||||
|
{/* Notification badge */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--danger)",
|
||||||
|
border: "2px solid var(--bg-deep)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
{user && (
|
{/* User Avatar + Dropdown */}
|
||||||
<span className="hidden sm:block text-xs px-2" style={{ color: "var(--muted)" }}>
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||||
{user.name || user.email}
|
<button
|
||||||
</span>
|
onClick={() => {
|
||||||
)}
|
setDropdownOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
aria-label="Open user menu"
|
||||||
|
aria-expanded={dropdownOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: user?.image
|
||||||
|
? "none"
|
||||||
|
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.image ? (
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || user.email || "User avatar"}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#fff",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<LogoutButton variant="secondary" />
|
{/* Dropdown Menu */}
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label="User menu"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% + 8px)",
|
||||||
|
right: 0,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 6,
|
||||||
|
minWidth: 200,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* User info header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.name ?? "User"}
|
||||||
|
</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Profile link */}
|
||||||
|
<DropdownItem
|
||||||
|
href="/profile"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="5" r="3" />
|
||||||
|
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||||
|
</svg>
|
||||||
|
Profile
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
{/* Account Settings link */}
|
||||||
|
<DropdownItem
|
||||||
|
href="/settings"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.5" />
|
||||||
|
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
|
||||||
|
</svg>
|
||||||
|
Account Settings
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sign Out */}
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
void signOut();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--danger)",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface-2)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
|
||||||
|
</svg>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Terminal toggle button — visual only; no panel wired yet. */
|
||||||
|
function TerminalToggleButton(): React.JSX.Element {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
title="Toggle terminal"
|
||||||
|
aria-label="Toggle terminal panel"
|
||||||
|
className="hidden lg:flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: 6,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${hovered ? "var(--success)" : "var(--border)"}`,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: hovered ? "var(--success)" : "var(--text-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 0.15s, color 0.15s",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||||
|
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||||
|
</svg>
|
||||||
|
Terminal
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
textDecoration: "none",
|
||||||
|
background: hovered ? "var(--surface-2)" : "none",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,159 +2,709 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.JSX.Element;
|
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 (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconProjects(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="4" width="14" height="10" rx="1.5" />
|
||||||
|
<path d="M1 7h14" />
|
||||||
|
<path d="M5 4V2.5A.5.5 0 0 1 5.5 2h5a.5.5 0 0 1 .5.5V4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconProjectWorkspace(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="4" r="2" />
|
||||||
|
<circle cx="3" cy="12" r="2" />
|
||||||
|
<circle cx="13" cy="12" r="2" />
|
||||||
|
<path d="M8 6v2M5 12h6M6 8l-2 2M10 8l2 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconKanban(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="4" height="12" rx="1" />
|
||||||
|
<rect x="6" y="2" width="4" height="12" rx="1" />
|
||||||
|
<rect x="11" y="2" width="4" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFileManager(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h4l2 2h3A1.5 1.5 0 0 1 14 5.5v7A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconLogs(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 4h12M2 8h8M2 12h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTerminal(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||||
|
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSettings(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.5" />
|
||||||
|
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevronLeft(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10 3L5 8l5 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevronRight(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 3l5 5-5 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Nav groups definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
href: "/",
|
label: "Overview",
|
||||||
label: "Dashboard",
|
items: [
|
||||||
icon: (
|
{
|
||||||
<svg
|
href: "/",
|
||||||
width="16"
|
label: "Dashboard",
|
||||||
height="16"
|
icon: <IconDashboard />,
|
||||||
viewBox="0 0 24 24"
|
badge: { label: "live", pulse: true },
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
],
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/tasks",
|
label: "Workspace",
|
||||||
label: "Tasks",
|
items: [
|
||||||
icon: (
|
{
|
||||||
<svg
|
href: "/projects",
|
||||||
width="16"
|
label: "Projects",
|
||||||
height="16"
|
icon: <IconProjects />,
|
||||||
viewBox="0 0 24 24"
|
},
|
||||||
fill="none"
|
{
|
||||||
stroke="currentColor"
|
href: "/workspace",
|
||||||
strokeWidth="1.5"
|
label: "Project Workspace",
|
||||||
aria-hidden="true"
|
icon: <IconProjectWorkspace />,
|
||||||
>
|
},
|
||||||
<path d="M9 11l3 3L22 4" />
|
{
|
||||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
href: "/kanban",
|
||||||
</svg>
|
label: "Kanban",
|
||||||
),
|
icon: <IconKanban />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/files",
|
||||||
|
label: "File Manager",
|
||||||
|
icon: <IconFileManager />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/calendar",
|
label: "Operations",
|
||||||
label: "Calendar",
|
items: [
|
||||||
icon: (
|
{
|
||||||
<svg
|
href: "/logs",
|
||||||
width="16"
|
label: "Logs & Telemetry",
|
||||||
height="16"
|
icon: <IconLogs />,
|
||||||
viewBox="0 0 24 24"
|
badge: { label: "live", pulse: true },
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
{
|
||||||
strokeWidth="1.5"
|
href: "#terminal",
|
||||||
aria-hidden="true"
|
label: "Terminal",
|
||||||
>
|
icon: <IconTerminal />,
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
},
|
||||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
],
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/knowledge",
|
label: "System",
|
||||||
label: "Knowledge",
|
items: [
|
||||||
icon: (
|
{
|
||||||
<svg
|
href: "/settings",
|
||||||
width="16"
|
label: "Settings",
|
||||||
height="16"
|
icon: <IconSettings />,
|
||||||
viewBox="0 0 24 24"
|
},
|
||||||
fill="none"
|
],
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
|
||||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/usage",
|
|
||||||
label: "Usage",
|
|
||||||
icon: (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M18 20V10M12 20V4M6 20v-6" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
flexShrink: 0,
|
||||||
|
...pulseStyle,
|
||||||
|
}}
|
||||||
|
aria-label={badge.pulse ? `${badge.label} indicator` : badge.label}
|
||||||
|
>
|
||||||
|
{badge.pulse && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--primary-l)",
|
||||||
|
boxShadow: "0 0 4px var(--primary)",
|
||||||
|
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: "6px",
|
||||||
|
bottom: "6px",
|
||||||
|
width: "3px",
|
||||||
|
background: "var(--primary)",
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<span style={iconStyle}>{item.icon}</span>
|
||||||
|
|
||||||
|
{/* Label and badge — hidden when collapsed */}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.badge !== undefined && <NavBadgeChip badge={item.badge} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedProps = {
|
||||||
|
style: baseStyle,
|
||||||
|
"aria-current": isActive ? ("page" as const) : undefined,
|
||||||
|
title: collapsed ? item.label : undefined,
|
||||||
|
onMouseEnter: (e: React.MouseEvent<HTMLElement>): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||||
|
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||||
|
"[data-nav-icon]"
|
||||||
|
);
|
||||||
|
if (iconEl) iconEl.style.opacity = "1";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMouseLeave: (e: React.MouseEvent<HTMLElement>): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
|
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||||
|
"[data-nav-icon]"
|
||||||
|
);
|
||||||
|
if (iconEl) iconEl.style.opacity = "0.7";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.href.startsWith("#")) {
|
||||||
|
return (
|
||||||
|
<a href={item.href} {...sharedProps}>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={item.href} {...sharedProps}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UserCard component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const displayName = user?.name ?? "User";
|
||||||
|
const initials = getInitials(displayName);
|
||||||
|
const role = user?.workspaceRole ?? "Member";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: "12px 10px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.12s ease",
|
||||||
|
justifyContent: collapsed ? "center" : undefined,
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title={collapsed ? `${displayName} — ${role}` : undefined}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
onKeyDown={(e): void => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`User: ${displayName}, Role: ${role}`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "30px",
|
||||||
|
height: "30px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.image ? (
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={`${displayName} avatar`}
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true">{initials}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and role — hidden when collapsed */}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Online status dot */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
width: "7px",
|
||||||
|
height: "7px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--success)",
|
||||||
|
boxShadow: "0 0 6px var(--success)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-label="Online"
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CollapseToggle component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CollapseToggleProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px 8px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: collapsed ? "center" : "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "26px",
|
||||||
|
height: "26px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--muted)",
|
||||||
|
transition: "background 0.12s ease, color 0.12s ease",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main AppSidebar component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application sidebar — navigation only, no brand/logo.
|
* Application sidebar — navigation groups, collapse toggle, and user card.
|
||||||
* Logo lives in AppHeader per MS15 design spec.
|
* Logo lives in AppHeader per MS15 design spec.
|
||||||
*/
|
*/
|
||||||
export function AppSidebar(): React.JSX.Element {
|
export function AppSidebar(): React.JSX.Element {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { collapsed, toggleCollapsed } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="app-sidebar">
|
<aside
|
||||||
{/* Navigation body */}
|
className="app-sidebar"
|
||||||
<nav className="flex-1 overflow-y-auto p-2" aria-label="Main navigation">
|
data-collapsed={collapsed ? "true" : undefined}
|
||||||
<div className="mb-4 mt-2">
|
aria-label="Application navigation"
|
||||||
<p
|
>
|
||||||
className="px-2 mb-1 text-xs font-semibold uppercase tracking-widest"
|
{/* Sidebar body — scrollable nav area */}
|
||||||
style={{ color: "var(--muted)" }}
|
<nav
|
||||||
>
|
style={{
|
||||||
Navigation
|
flex: 1,
|
||||||
</p>
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
padding: "10px 10px",
|
||||||
|
}}
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
{NAV_GROUPS.map((group) => (
|
||||||
|
<div key={group.label} style={{ marginBottom: "18px" }}>
|
||||||
|
{/* Group label — hidden when collapsed */}
|
||||||
|
{!collapsed && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.67rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.09em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
padding: "0 10px",
|
||||||
|
marginBottom: "4px",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul>
|
{/* Nav items */}
|
||||||
{navItems.map((item) => {
|
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
const isActive = pathname === item.href;
|
{group.items.map((item) => {
|
||||||
return (
|
const isActive =
|
||||||
<li key={item.href}>
|
item.href === "/"
|
||||||
<Link
|
? pathname === "/"
|
||||||
href={item.href}
|
: item.href.startsWith("#")
|
||||||
className="flex items-center gap-3 px-2 py-2 rounded text-sm font-medium transition-colors relative"
|
? false
|
||||||
style={
|
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
isActive
|
|
||||||
? { color: "var(--text)", background: "var(--surface)" }
|
return (
|
||||||
: { color: "var(--muted)" }
|
<li key={item.href}>
|
||||||
}
|
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
|
||||||
aria-current={isActive ? "page" : undefined}
|
</li>
|
||||||
>
|
);
|
||||||
{isActive && (
|
})}
|
||||||
<span
|
</ul>
|
||||||
className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r"
|
</div>
|
||||||
style={{ background: "var(--primary)" }}
|
))}
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
{/* Collapse toggle — anchored at bottom of nav */}
|
||||||
)}
|
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||||
<span className="flex-shrink-0" style={{ opacity: isActive ? 1 : 0.7 }}>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* User card footer */}
|
||||||
|
<UserCard collapsed={collapsed} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
apps/web/src/components/layout/SidebarContext.tsx
Normal file
30
apps/web/src/components/layout/SidebarContext.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface SidebarContextValue {
|
||||||
|
collapsed: boolean;
|
||||||
|
toggleCollapsed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const toggleCollapsed = useCallback((): void => {
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: SidebarContextValue = { collapsed, toggleCollapsed };
|
||||||
|
|
||||||
|
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar(): SidebarContextValue {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useSidebar must be used within SidebarProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user