All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
648 lines
19 KiB
TypeScript
648 lines
19 KiB
TypeScript
"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 { ThemeToggle } from "./ThemeToggle";
|
|
import { useSidebar } from "./SidebarContext";
|
|
|
|
/**
|
|
* 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, signOut } = useAuth();
|
|
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
|
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()
|
|
: "?";
|
|
|
|
const handleHamburgerClick = useCallback((): void => {
|
|
if (isMobile) {
|
|
setMobileOpen(!mobileOpen);
|
|
} else {
|
|
toggleCollapsed();
|
|
}
|
|
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
|
|
|
|
return (
|
|
<header className="app-header">
|
|
{/* ── Hamburger — visible below lg ── */}
|
|
<button
|
|
type="button"
|
|
className="lg:hidden"
|
|
onClick={handleHamburgerClick}
|
|
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="app-sidebar"
|
|
style={{
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 6,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "var(--muted)",
|
|
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="M2 4h12M2 8h12M2 12h12" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* ── Brand / Logo ── */}
|
|
<Link
|
|
href="/"
|
|
className="flex items-center gap-2 flex-shrink-0"
|
|
aria-label="Mosaic Stack home"
|
|
>
|
|
{/* Mosaic logo mark: four colored squares + center dot */}
|
|
<div
|
|
className="relative flex-shrink-0"
|
|
style={{ width: 28, height: 28 }}
|
|
aria-hidden="true"
|
|
>
|
|
<span
|
|
className="absolute rounded-sm"
|
|
style={{ top: 0, left: 0, width: 11, height: 11, background: "var(--ms-blue-500)" }}
|
|
/>
|
|
<span
|
|
className="absolute rounded-sm"
|
|
style={{ top: 0, right: 0, width: 11, height: 11, background: "var(--ms-purple-500)" }}
|
|
/>
|
|
<span
|
|
className="absolute rounded-sm"
|
|
style={{
|
|
bottom: 0,
|
|
right: 0,
|
|
width: 11,
|
|
height: 11,
|
|
background: "var(--ms-teal-500)",
|
|
}}
|
|
/>
|
|
<span
|
|
className="absolute rounded-sm"
|
|
style={{
|
|
bottom: 0,
|
|
left: 0,
|
|
width: 11,
|
|
height: 11,
|
|
background: "var(--ms-amber-500)",
|
|
}}
|
|
/>
|
|
<span
|
|
className="absolute rounded-full"
|
|
style={{
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
width: 8,
|
|
height: 8,
|
|
background: "var(--ms-pink-500)",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<span
|
|
className="text-sm font-bold"
|
|
style={{
|
|
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
|
|
backgroundClip: "text",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
letterSpacing: "-0.02em",
|
|
}}
|
|
>
|
|
Mosaic Stack
|
|
</span>
|
|
</Link>
|
|
|
|
{/* ── Breadcrumb ── */}
|
|
<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>
|
|
|
|
{/* ── Search Bar ── */}
|
|
<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 />
|
|
|
|
{/* User Avatar + Dropdown */}
|
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
|
<button
|
|
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>
|
|
|
|
{/* 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>
|
|
</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>
|
|
);
|
|
}
|