Repository Public
1"use client";23import Link from "next/link";4import { usePathname } from "next/navigation";5import { useEffect, useState } from "react";6import { StatusDot } from "@/components/live/bits";7import { LivePill } from "@/components/live/LiveOverview";8import { Wordmark } from "@/components/ui/Logo";9import { getDict, switchLocalePath, type Locale } from "@/lib/i18n";1011export function Header({ locale }: { locale: Locale }) {12 const t = getDict(locale);13 const pathname = usePathname() ?? `/${locale}`;14 const [open, setOpen] = useState(false);15 const other: Locale = locale === "en" ? "fr" : "en";16 const items = [17 { href: `/${locale}/live`, label: t.nav.live, match: "/live", live: true },18 { href: `/${locale}/nodes`, label: t.nav.nodes, match: "/nodes" },19 { href: `/${locale}/architecture`, label: t.nav.architecture, match: "/architecture" },20 { href: `/${locale}/research`, label: t.nav.research, match: "/research" },21 { href: `/${locale}/benchmarks`, label: t.nav.benchmarks, match: "/benchmarks" },22 { href: `/${locale}/gallery`, label: t.nav.gallery, match: "/gallery" },23 { href: `/${locale}/access`, label: t.nav.access, match: "/access" },24 ];25 const isCurrent = (m: string) => pathname.startsWith(`/${locale}${m}`);2627 useEffect(() => {28 if (!open) return;29 const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);30 document.addEventListener("keydown", onKey);31 document.body.style.overflow = "hidden";32 return () => {33 document.removeEventListener("keydown", onKey);34 document.body.style.overflow = "";35 };36 }, [open]);3738 const langSwitch = (39 <span className="flex items-center rounded-full border border-line bg-surface p-0.5 font-mono text-[0.75rem] tracking-[0.06em]" role="group" aria-label="Language / Langue">40 <span aria-current="true" className="rounded-full bg-fg px-2.5 py-1.5 text-white">{locale.toUpperCase()}</span>41 <Link href={switchLocalePath(pathname, other)} hrefLang={other} lang={other} className="rounded-full px-2.5 py-1.5 text-mute hover:text-fg" aria-label={`${other.toUpperCase()} — ${t.otherLang}`} onClick={() => setOpen(false)}>42 {other.toUpperCase()}43 </Link>44 </span>45 );4647 return (48 <>49 <header className="sticky top-0 z-40 border-b border-line bg-bg/85 backdrop-blur-lg">50 <div className="shell flex h-16 items-center justify-between gap-4">51 <Link href={`/${locale}`} aria-label={t.nav.home} className="shrink-0" onClick={() => setOpen(false)}>52 <Wordmark />53 </Link>54 <nav aria-label="Primary" className="hidden lg:block">55 <ul className="flex items-center gap-1 text-[0.875rem]">56 {items.map((it) => (57 <li key={it.href}>58 <Link href={it.href} aria-current={isCurrent(it.match) ? "page" : undefined} className="flex items-center gap-2 rounded-full px-3 py-2 text-body transition-colors hover:bg-surface-2 hover:text-fg aria-[current=page]:bg-surface aria-[current=page]:font-medium aria-[current=page]:text-fg aria-[current=page]:shadow-card">59 {it.live ? <StatusDot state="on" /> : null}60 {it.label}61 </Link>62 </li>63 ))}64 </ul>65 </nav>66 <div className="flex items-center gap-2">67 {langSwitch}68 <button type="button" className="-mr-1.5 flex h-11 w-11 items-center justify-center rounded-full hover:bg-surface-2 lg:hidden" aria-expanded={open} aria-controls="mobile-nav" onClick={() => setOpen((v) => !v)}>69 <span className="sr-only">{open ? t.nav.close : t.nav.menu}</span>70 <svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true">71 {open ? <path d="M5 5l12 12M17 5 5 17" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> : <path d="M3 7h16M3 15h16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />}72 </svg>73 </button>74 </div>75 </div>76 </header>77 {/* Sibling of the header on purpose: a backdrop-filter ancestor would confine a fixed panel. */}78 <div id="mobile-nav" hidden={!open} className="fixed inset-x-0 top-16 bottom-0 z-30 overflow-y-auto bg-bg lg:hidden">79 <nav aria-label="Mobile" className="shell pb-[max(2rem,env(safe-area-inset-bottom))] pt-5">80 {open ? <LivePill locale={locale} className="mb-4 w-full" /> : null}81 <ul className="card divide-y divide-line overflow-hidden">82 {items.map((it) => (83 <li key={it.href}>84 <Link href={it.href} onClick={() => setOpen(false)} aria-current={isCurrent(it.match) ? "page" : undefined} className="flex min-h-14 items-center justify-between gap-3 px-5 py-3 text-[1.125rem] font-medium tracking-[-0.02em] aria-[current=page]:text-accent">85 <span className="flex items-center gap-2.5">{it.live ? <StatusDot state="on" /> : null}{it.label}</span>86 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" className="text-mute"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>87 </Link>88 </li>89 ))}90 </ul>91 </nav>92 </div>93 </>94 );95}96