Skip to content
File

Blob: src/client/components/header.tsx

typescript107 lines
1import { FolderPlus, Hammer, Menu, Workflow, X } from "lucide-react";
2import { useState } from "react";
3import { Link, NavLink } from "react-router-dom";
4import { useAuth } from "@/client/auth";
5 
6import { UserMenu } from "@/client/components/user-menu";
7 
8const navLinkClass = ({ isActive }: { isActive: boolean }): string =>
9 [
10 "flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
11 isActive ? "bg-accent-500/10 text-accent-400" : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
12 ].join(" ");
13 
14interface HeaderProps {
15 variant?: "app" | "public";
16}
17 
18const HeaderBrand = ({ href, subtitle }: { href: string; subtitle: string }) => (
19 <Link to={href} className="group flex items-center gap-3 transition-opacity hover:opacity-80">
20 <span className="inline-grid h-9 w-9 place-items-center">
21 <Hammer
22 className="h-6 w-6 text-accent-400 transition-transform duration-200 group-hover:-rotate-6"
23 strokeWidth={2}
24 aria-hidden="true"
25 />
26 </span>
27 <span className="hidden sm:block">
28 <strong className="block font-display text-sm font-semibold text-zinc-100">anvil</strong>
29 <small className="block text-xs text-zinc-400">{subtitle}</small>
30 </span>
31 </Link>
32);
33 
34const PublicHeaderActions = ({ isAuthenticated }: { isAuthenticated: boolean }) => (
35 <div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">{isAuthenticated ? <UserMenu /> : null}</div>
36);
37 
38export const Header = ({ variant = "app" }: HeaderProps) => {
39 const { isAuthenticated } = useAuth();
40 const [mobileNavOpen, setMobileNavOpen] = useState(false);
41 
42 if (variant === "public") {
43 return (
44 <header className="sticky top-0 z-50 w-full border-b border-zinc-800/60 bg-zinc-900/95 backdrop-blur-sm">
45 <div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
46 <HeaderBrand href="/" subtitle="Edge-native CI" />
47 <PublicHeaderActions isAuthenticated={isAuthenticated} />
48 </div>
49 </header>
50 );
51 }
52 
53 return (
54 <header className="sticky top-0 z-50 w-full border-b border-zinc-800/60 bg-zinc-900/95 backdrop-blur-sm">
55 <div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
56 <div className="flex items-center gap-5">
57 <HeaderBrand href={isAuthenticated ? "/app/projects" : "/"} subtitle="Edge-native CI" />
58
59 {isAuthenticated ? (
60 <nav aria-label="Main navigation" className="hidden items-center gap-1.5 md:flex">
61 <NavLink to="/app/projects" end className={navLinkClass}>
62 <Workflow className="h-3.5 w-3.5" />
63 Projects
64 </NavLink>
65 <NavLink to="/app/projects/new" className={navLinkClass}>
66 <FolderPlus className="h-3.5 w-3.5" />
67 New Project
68 </NavLink>
69 </nav>
70 ) : null}
71 </div>
72
73 <div className="flex items-center gap-3">
74 <UserMenu />
75 {isAuthenticated ? (
76 <button
77 type="button"
78 aria-expanded={mobileNavOpen}
79 aria-controls="mobile-nav"
80 aria-label="Toggle navigation"
81 className="rounded-lg p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100 md:hidden"
82 onClick={() => setMobileNavOpen((prev) => !prev)}
83 >
84 {mobileNavOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
85 </button>
86 ) : null}
87 </div>
88 </div>
89
90 {mobileNavOpen && isAuthenticated ? (
91 <nav id="mobile-nav" aria-label="Mobile navigation" className="border-t border-zinc-800/60 px-4 py-3 md:hidden">
92 <div className="flex flex-col gap-1">
93 <NavLink to="/app/projects" end className={navLinkClass} onClick={() => setMobileNavOpen(false)}>
94 <Workflow className="h-3.5 w-3.5" />
95 Projects
96 </NavLink>
97 <NavLink to="/app/projects/new" className={navLinkClass} onClick={() => setMobileNavOpen(false)}>
98 <FolderPlus className="h-3.5 w-3.5" />
99 New Project
100 </NavLink>
101 </div>
102 </nav>
103 ) : null}
104 </header>
105 );
106};