SPB Git

spb/qc26

Public
10.5 KB · 163 lines tsx
1"use client";23import Link from "next/link";4import { usePathname, useRouter } from "next/navigation";5import { useCallback, useEffect, useRef, useState } from "react";6import { cls, daysLabel } from "@/lib/format";7import { partyLabel } from "@/lib/parties";89const NAV = [10  { href: "/prevision", label: "Prévision" },11  { href: "/sondages", label: "Sondages" },12  { href: "/carte", label: "Carte" },13  { href: "/circonscriptions", label: "Circonscriptions" },14  { href: "/boussole", label: "Boussole" },15  { href: "/partis", label: "Partis" },16  { href: "/enjeux", label: "Enjeux" },17  { href: "/methodologie", label: "Méthodologie" },18];1920export function Wordmark({ className, size = 22 }: { className?: string; size?: number }) {21  return (22    <span className={cls("inline-flex items-baseline font-bold tracking-tight select-none", className)} style={{ fontSize: size, letterSpacing: "-0.04em" }} aria-label="QC26">23      <span>QC</span>24      <span className="mx-[3px] inline-block w-[2px] self-stretch bg-accent rounded-sm translate-y-[1px]" style={{ height: size * 0.82 }} aria-hidden />25      <span className="text-accent">26</span>26    </span>27  );28}2930export function TopNav({ daysToElection, mode }: { daysToElection: number; mode: "forecast" | "live" | "archive" }) {31  const path = usePathname();32  const [paletteOpen, setPaletteOpen] = useState(false);33  useEffect(() => {34    const onKey = (e: KeyboardEvent) => {35      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }36      if (e.key === "Escape") setPaletteOpen(false);37    };38    window.addEventListener("keydown", onKey);39    return () => window.removeEventListener("keydown", onKey);40  }, []);41  const liveNav = mode !== "forecast" ? [{ href: "/live", label: mode === "live" ? "Direct" : "Résultats" }] : [];42  return (43    <>44      <header className="sticky top-0 z-40 bg-[rgba(247,248,250,.85)] backdrop-blur-md border-b border-border" style={{ height: "var(--header-h)" }}>45        <div className="mx-auto max-w-[1280px] h-full px-4 md:px-6 flex items-center gap-4">46          <Link href="/" className="shrink-0 flex items-center" aria-label="QC26 — accueil"><Wordmark /></Link>47          <nav className="hidden lg:flex items-center gap-0.5 ml-4" aria-label="Navigation principale">48            {[...liveNav, ...NAV].map((n) => {49              const active = path === n.href || (n.href !== "/" && path.startsWith(n.href));50              return (51                <Link key={n.href} href={n.href} className={cls("px-3 py-1.5 rounded-[7px] text-[13.5px] font-medium transition-colors", active ? "bg-brand text-white" : "text-ink-2 hover:text-ink hover:bg-surface-2", n.href === "/live" && !active && "text-live")}>52                  {n.href === "/live" && mode === "live" ? <span className="inline-flex items-center gap-1.5"><span className="live-dot" />DIRECT</span> : n.label}53                </Link>54              );55            })}56          </nav>57          <div className="ml-auto flex items-center gap-2">58            <button onClick={() => setPaletteOpen(true)} className="hidden md:inline-flex items-center gap-2 text-[12.5px] text-ink-2 border border-border rounded-[7px] px-2.5 py-1.5 hover:bg-surface-2" aria-label="Rechercher (⌘K)">59              <span aria-hidden>⌕</span> Rechercher <kbd className="mono text-[10.5px] border border-border rounded px-1 text-ink-3">⌘K</kbd>60            </button>61            <button onClick={() => setPaletteOpen(true)} className="md:hidden w-9 h-9 inline-flex items-center justify-center rounded-[7px] border border-border" aria-label="Rechercher">⌕</button>62            <ElectionChip days={daysToElection} mode={mode} />63          </div>64        </div>65      </header>66      {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}67    </>68  );69}7071function ElectionChip({ days, mode }: { days: number; mode: string }) {72  if (mode === "live") return <Link href="/live" className="inline-flex items-center gap-1.5 rounded-[7px] bg-live text-white px-2.5 py-1.5 text-[12px] font-bold tracking-wide"><span className="live-dot !bg-white" /> DIRECT</Link>;73  if (mode === "archive") return <span className="inline-flex items-center rounded-[7px] bg-brand text-white px-2.5 py-1.5 text-[11.5px] font-bold tracking-wide uppercase">Archive · 5 oct. 2026</span>;74  return (75    <span className="inline-flex items-center gap-2 rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-[11.5px] font-semibold tracking-wide uppercase text-ink-2">76      <span className="hidden sm:inline">Élection · 5 oct. 2026</span>77      <span className="num text-ink font-bold">{daysLabel(days)}</span>78    </span>79  );80}8182export function MobileNav({ mode }: { mode: "forecast" | "live" | "archive" }) {83  const path = usePathname();84  const items = [85    { href: "/", label: "Accueil", icon: "◉" },86    mode === "forecast" ? { href: "/prevision", label: "Prévision", icon: "◭" } : { href: "/live", label: mode === "live" ? "Direct" : "Résultats", icon: "●" },87    { href: "/carte", label: "Carte", icon: "◫" },88    { href: "/boussole", label: "Boussole", icon: "◎" },89    { href: "/plus", label: "Plus", icon: "≡" },90  ];91  return (92    <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 bg-[rgba(255,255,255,.92)] backdrop-blur-md border-t border-border safe-bottom" style={{ height: "calc(var(--bottom-nav-h) + env(safe-area-inset-bottom))" }} aria-label="Navigation mobile">93      <div className="grid grid-cols-5 h-[60px]">94        {items.map((it) => {95          const active = it.href === "/" ? path === "/" : path.startsWith(it.href);96          const live = it.href === "/live" && mode === "live";97          return (98            <Link key={it.href} href={it.href} className={cls("flex flex-col items-center justify-center gap-0.5 text-[10.5px] font-semibold", active ? "text-brand" : "text-ink-3", live && "text-live")}>99              <span className={cls("text-[17px] leading-none", live && "live-dot !w-[10px] !h-[10px] mb-1")} aria-hidden>{live ? "" : it.icon}</span>100              {it.label}101            </Link>102          );103        })}104      </div>105    </nav>106  );107}108109interface SearchResult { ridings: { code: number; name: string; slug: string; region: string }[]; candidates: { name: string; party: string; riding: number }[]; parties: { id: string; name: string; leader: string }[]; polls: { id: string; pollster: string; date: string }[]; documents: { id: number; title: string; party: string }[]; proposals: { id: number; party: string; topic: string; proposal: string; documentId: number }[]; topics: { id: string; label: string }[] }110111export function CommandPalette({ onClose }: { onClose: () => void }) {112  const [q, setQ] = useState("");113  const [res, setRes] = useState<SearchResult | null>(null);114  const [sel, setSel] = useState(0);115  const router = useRouter();116  const ref = useRef<HTMLInputElement>(null);117  useEffect(() => { ref.current?.focus(); }, []);118  useEffect(() => {119    if (q.trim().length < 2) { setRes(null); return; }120    const ctrl = new AbortController();121    const t = setTimeout(() => {122      fetch(`/api/search?q=${encodeURIComponent(q.trim())}`, { signal: ctrl.signal }).then((r) => r.json()).then(setRes).catch(() => {});123    }, 120);124    return () => { clearTimeout(t); ctrl.abort(); };125  }, [q]);126  const items: { label: string; sub?: string; href: string }[] = [];127  const shortcuts = [128    { label: "Prévision", href: "/prevision" }, { label: "Sondages", href: "/sondages" }, { label: "Carte électorale", href: "/carte" }, { label: "Boussole électorale", href: "/boussole" },129    { label: "Comparateur des partis", href: "/partis/comparateur" }, { label: "Méthodologie", href: "/methodologie" }, { label: "Statut des données", href: "/statut" },130  ];131  if (!res) items.push(...shortcuts.filter((s) => !q || s.label.toLowerCase().includes(q.toLowerCase())));132  else {133    res.ridings.forEach((r) => items.push({ label: r.name, sub: "Circonscription", href: `/circonscription/${r.slug}` }));134    res.parties.forEach((p) => items.push({ label: p.name, sub: `Parti · ${p.leader ?? ""}`, href: `/partis/${p.id}` }));135    res.candidates.forEach((c) => items.push({ label: c.name, sub: `Candidat·e · ${partyLabel(c.party)}`, href: `/circonscription/${c.riding}` }));136    res.polls.forEach((p) => items.push({ label: `${p.pollster} · ${p.date}`, sub: "Sondage", href: `/sondages/${p.id}` }));137    res.topics.forEach((t) => items.push({ label: t.label, sub: "Enjeu", href: `/enjeux/${t.id}` }));138    res.documents.forEach((d) => items.push({ label: d.title, sub: `Document · ${partyLabel(d.party)}`, href: `/documents/${d.id}` }));139    res.proposals.forEach((p) => items.push({ label: p.proposal, sub: `Proposition · ${partyLabel(p.party)}`, href: `/documents/${p.documentId}#p${p.id}` }));140  }141  const go = useCallback((href: string) => { onClose(); router.push(href); }, [onClose, router]);142  return (143    <div className="fixed inset-0 z-50 bg-[rgba(17,19,24,.35)] backdrop-blur-[2px] flex items-start justify-center pt-[10vh] px-4" onClick={onClose} role="dialog" aria-modal aria-label="Recherche">144      <div className="w-full max-w-[620px] card card-lg overflow-hidden shadow-md" onClick={(e) => e.stopPropagation()}>145        <input ref={ref} value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }} placeholder="Circonscription, candidat·e, firme, enjeu, parti…" className="w-full px-4 py-3.5 text-[16px] outline-none bg-transparent border-b border-border"146          onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(items.length - 1, s + 1)); } if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } if (e.key === "Enter" && items[sel]) go(items[sel].href); }} />147        <ul className="max-h-[50vh] overflow-auto py-1">148          {items.length === 0 && <li className="px-4 py-6 text-ink-3 text-sm text-center">Aucun résultat.</li>}149          {items.slice(0, 30).map((it, i) => (150            <li key={it.href + i}>151              <button onClick={() => go(it.href)} onMouseEnter={() => setSel(i)} className={cls("w-full text-left px-4 py-2.5 flex items-center justify-between gap-3", i === sel && "bg-surface-2")}>152                <span className="truncate text-[14px]">{it.label}</span>153                {it.sub && <span className="text-[11.5px] text-ink-3 shrink-0">{it.sub}</span>}154              </button>155            </li>156          ))}157        </ul>158        <div className="px-4 py-2 border-t border-border text-[11px] text-ink-3 flex gap-3"><span>↑↓ naviguer</span><span>↵ ouvrir</span><span>esc fermer</span></div>159      </div>160    </div>161  );162}163
Files · 08f5606551574077c8600a5faf68d3daef5dbc10
Menu

Theme