Python 51.6%
TypeScript 46.7%
CSS 1.7%
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