SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
12.1 KB · 175 lines tsx
Raw Blame History
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";8import { ThemeToggle } from "./theme";910const NAV = [11  { href: "/prevision", label: "Prévision" },12  { href: "/signaux", label: "Signaux" },13  { href: "/sondages", label: "Sondages" },14  { href: "/carte", label: "Carte" },15  { href: "/circonscriptions", label: "Circonscriptions" },16  { href: "/partis", label: "Partis" },17  { href: "/boussole", label: "Boussole" },18  { href: "/methodologie", label: "Méthode" },19];2021export function Wordmark({ className, size = 22 }: { className?: string; size?: number }) {22  return (23    <span className={cls("inline-flex items-baseline select-none display", className)} style={{ fontSize: size, letterSpacing: "-0.03em", fontWeight: 700 }} aria-label="QC26">24      <span>QC</span>25      <span className="mx-[3px] inline-block w-[3px] self-stretch bg-accent rounded-sm translate-y-[1px]" style={{ height: size * 0.78 }} aria-hidden />26      <span className="text-accent">26</span>27    </span>28  );29}3031export function TopNav({ daysToElection, mode, modelVersion }: { daysToElection: number; mode: "forecast" | "live" | "archive"; modelVersion?: string }) {32  const path = usePathname();33  const [paletteOpen, setPaletteOpen] = useState(false);34  const [scrolled, setScrolled] = useState(false);35  useEffect(() => {36    const onKey = (e: KeyboardEvent) => {37      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }38      if (e.key === "Escape") setPaletteOpen(false);39    };40    const onScroll = () => setScrolled(window.scrollY > 8);41    window.addEventListener("keydown", onKey);42    window.addEventListener("scroll", onScroll, { passive: true });43    return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("scroll", onScroll); };44  }, []);45  const liveNav = mode !== "forecast" ? [{ href: "/live", label: mode === "live" ? "Direct" : "Résultats" }] : [];46  return (47    <>48      <header className={cls("sticky top-0 z-40 glass border-b transition-colors", scrolled ? "border-border" : "border-transparent")} style={{ height: "var(--header-h)" }}>49        <div className="mx-auto max-w-[1320px] h-full px-4 md:px-6 flex items-center gap-4">50          <Link href="/" className="shrink-0 flex items-center gap-3" aria-label="QC26 — accueil">51            <Wordmark />52            <span className="hidden xl:inline text-[11px] mono text-ink-3 uppercase tracking-[0.1em] border-l border-border pl-3">Québec 2026</span>53          </Link>54          <nav className="hidden lg:flex items-center gap-0.5 ml-2" aria-label="Navigation principale">55            {[...liveNav, ...NAV].map((n) => {56              const active = path === n.href || (n.href !== "/" && path.startsWith(n.href));57              return (58                <Link key={n.href} href={n.href} className={cls("relative px-3 py-1.5 rounded-[8px] text-[13.5px] font-medium transition-colors", active ? "text-ink" : "text-ink-2 hover:text-ink hover:bg-surface-2", n.href === "/live" && !active && "text-live")}>59                  {n.href === "/live" && mode === "live" ? <span className="inline-flex items-center gap-1.5"><span className="live-dot" />DIRECT</span> : n.label}60                  {active && <span className="absolute left-3 right-3 -bottom-[2px] h-[2px] rounded-full bg-accent" aria-hidden />}61                </Link>62              );63            })}64          </nav>65          <div className="ml-auto flex items-center gap-2">66            <button onClick={() => setPaletteOpen(true)} className="hidden md:inline-flex items-center gap-2 text-[12.5px] text-ink-2 border border-border rounded-[9px] px-2.5 py-1.5 hover:bg-surface-2 transition-colors" aria-label="Rechercher (⌘K)">67              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>68              Rechercher <kbd className="mono text-[10.5px] border border-border rounded px-1 text-ink-3">⌘K</kbd>69            </button>70            <button onClick={() => setPaletteOpen(true)} className="md:hidden w-9 h-9 inline-flex items-center justify-center rounded-[9px] border border-border" aria-label="Rechercher">71              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>72            </button>73            <ThemeToggle />74            <ElectionChip days={daysToElection} mode={mode} modelVersion={modelVersion} />75          </div>76        </div>77      </header>78      {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}79    </>80  );81}8283function ElectionChip({ days, mode, modelVersion }: { days: number; mode: string; modelVersion?: string }) {84  if (mode === "live") return <Link href="/live" className="inline-flex items-center gap-1.5 rounded-[9px] bg-live text-white px-2.5 py-1.5 text-[12px] font-bold tracking-wide"><span className="live-dot !bg-white" /> DIRECT</Link>;85  if (mode === "archive") return <span className="inline-flex items-center rounded-[9px] bg-brand text-ink-inverse px-2.5 py-1.5 text-[11.5px] font-bold tracking-wide uppercase">Archive · 5 oct. 2026</span>;86  return (87    <span className="inline-flex items-center gap-2 rounded-[9px] border border-border bg-surface px-2.5 py-1.5 text-[11px] mono uppercase tracking-[0.08em] text-ink-2" title={modelVersion ? `Modèle ${modelVersion}` : undefined}>88      <span className="hidden sm:inline">5 oct. 2026</span>89      <span className="text-ink font-bold">{daysLabel(days)}</span>90    </span>91  );92}9394export function MobileNav({ mode }: { mode: "forecast" | "live" | "archive" }) {95  const path = usePathname();96  const items = [97    { href: "/", label: "Accueil", icon: <path d="M3 11.5 12 4l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" /> },98    mode === "forecast" ? { href: "/prevision", label: "Prévision", icon: <path d="M3 20h18M5 17V9M10 17V4M15 17v-6M20 17v-3" /> } : { href: "/live", label: mode === "live" ? "Direct" : "Résultats", icon: <circle cx="12" cy="12" r="5" /> },99    { href: "/signaux", label: "Signaux", icon: <path d="M2 12h4l3-8 4 16 3-8h6" /> },100    { href: "/carte", label: "Carte", icon: <path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2zM9 4v14M15 6v14" /> },101    { href: "/plus", label: "Plus", icon: <path d="M4 7h16M4 12h16M4 17h16" /> },102  ];103  return (104    <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 border-t border-border safe-bottom" style={{ height: "calc(var(--bottom-nav-h) + env(safe-area-inset-bottom))", background: "var(--nav-bg)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)" }} aria-label="Navigation mobile">105      <div className="grid grid-cols-5 h-[62px]">106        {items.map((it) => {107          const active = it.href === "/" ? path === "/" : path.startsWith(it.href);108          const live = it.href === "/live" && mode === "live";109          return (110            <Link key={it.href} href={it.href} className={cls("flex flex-col items-center justify-center gap-1 text-[10.5px] font-semibold transition-colors", active ? "text-accent" : "text-ink-3", live && "text-live")}>111              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden>{it.icon}</svg>112              {it.label}113            </Link>114          );115        })}116      </div>117    </nav>118  );119}120121interface 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 }[] }122123export function CommandPalette({ onClose }: { onClose: () => void }) {124  const [q, setQ] = useState("");125  const [res, setRes] = useState<SearchResult | null>(null);126  const [sel, setSel] = useState(0);127  const router = useRouter();128  const ref = useRef<HTMLInputElement>(null);129  useEffect(() => { ref.current?.focus(); }, []);130  useEffect(() => {131    if (q.trim().length < 2) { setRes(null); return; }132    const ctrl = new AbortController();133    const t = setTimeout(() => {134      fetch(`/api/search?q=${encodeURIComponent(q.trim())}`, { signal: ctrl.signal }).then((r) => r.json()).then(setRes).catch(() => {});135    }, 120);136    return () => { clearTimeout(t); ctrl.abort(); };137  }, [q]);138  const items: { label: string; sub?: string; href: string }[] = [];139  const shortcuts = [140    { label: "Prévision", href: "/prevision" }, { label: "Au-delà des sondages (signaux)", href: "/signaux" }, { label: "Sondages", href: "/sondages" }, { label: "Carte électorale", href: "/carte" }, { label: "Boussole électorale", href: "/boussole" },141    { label: "Comparateur des partis", href: "/partis/comparateur" }, { label: "Méthodologie", href: "/methodologie" }, { label: "Statut des données", href: "/statut" },142  ];143  if (!res) items.push(...shortcuts.filter((s) => !q || s.label.toLowerCase().includes(q.toLowerCase())));144  else {145    res.ridings.forEach((r) => items.push({ label: r.name, sub: "Circonscription", href: `/circonscription/${r.slug}` }));146    res.parties.forEach((p) => items.push({ label: p.name, sub: `Parti · ${p.leader ?? ""}`, href: `/partis/${p.id}` }));147    res.candidates.forEach((c) => items.push({ label: c.name, sub: `Candidat·e · ${partyLabel(c.party)}`, href: `/circonscription/${c.riding}` }));148    res.polls.forEach((p) => items.push({ label: `${p.pollster} · ${p.date}`, sub: "Sondage", href: `/sondages/${p.id}` }));149    res.topics.forEach((t) => items.push({ label: t.label, sub: "Enjeu", href: `/enjeux/${t.id}` }));150    res.documents.forEach((d) => items.push({ label: d.title, sub: `Document · ${partyLabel(d.party)}`, href: `/documents/${d.id}` }));151    res.proposals.forEach((p) => items.push({ label: p.proposal, sub: `Proposition · ${partyLabel(p.party)}`, href: `/documents/${p.documentId}#p${p.id}` }));152  }153  const go = useCallback((href: string) => { onClose(); router.push(href); }, [onClose, router]);154  return (155    <div className="fixed inset-0 z-50 bg-[rgba(10,12,16,.45)] backdrop-blur-[3px] flex items-start justify-center pt-[10vh] px-4" onClick={onClose} role="dialog" aria-modal aria-label="Recherche">156      <div className="w-full max-w-[640px] card card-lg overflow-hidden shadow-lg fade-up" onClick={(e) => e.stopPropagation()}>157        <input ref={ref} value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }} placeholder="Circonscription, candidat·e, firme, enjeu, parti…" className="w-full px-5 py-4 text-[16px] outline-none bg-transparent border-b border-border"158          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); }} />159        <ul className="max-h-[50vh] overflow-auto py-1">160          {items.length === 0 && <li className="px-4 py-6 text-ink-3 text-sm text-center">Aucun résultat.</li>}161          {items.slice(0, 30).map((it, i) => (162            <li key={it.href + i}>163              <button onClick={() => go(it.href)} onMouseEnter={() => setSel(i)} className={cls("w-full text-left px-5 py-2.5 flex items-center justify-between gap-3", i === sel && "bg-surface-2")}>164                <span className="truncate text-[14px]">{it.label}</span>165                {it.sub && <span className="text-[11.5px] text-ink-3 shrink-0 mono uppercase tracking-wide">{it.sub}</span>}166              </button>167            </li>168          ))}169        </ul>170        <div className="px-5 py-2 border-t border-border text-[11px] text-ink-3 flex gap-3 mono"><span>↑↓ naviguer</span><span>↵ ouvrir</span><span>esc fermer</span></div>171      </div>172    </div>173  );174}175