SPB Git

Repository Public

Header.tsx 5.2 KB 96 lines tsx

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
Files main
Menu

Theme