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%
11.4 KB · 204 lines tsx
Raw Blame History
1import Link from "next/link";2import type { ReactNode } from "react";3import { cls, dateTimeFr, relTime } from "@/lib/format";45export function SectionHeader({ eyebrow, title, description, action, id, className, num }: { eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode; id?: string; className?: string; num?: string }) {6  return (7    <div id={id} className={cls("flex flex-wrap items-end justify-between gap-3 mb-5 scroll-mt-24", className)}>8      <div className="min-w-0">9        {(eyebrow || num) && <div className="flex items-center gap-2 mb-1.5">{num && <span className="section-num">{num}</span>}{eyebrow && <span className="eyebrow">{eyebrow}</span>}</div>}10        <h2 className="display text-[26px] md:text-[32px] leading-[1.05]">{title}</h2>11        {description && <p className="text-ink-2 text-[14.5px] mt-2 max-w-2xl leading-relaxed">{description}</p>}12      </div>13      {action && <div className="shrink-0">{action}</div>}14    </div>15  );16}1718export function PageTitle({ eyebrow, title, lede, action, children }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; action?: ReactNode; children?: ReactNode }) {19  return (20    <div className="flex flex-wrap items-end justify-between gap-6">21      <div className="min-w-0 max-w-3xl">22        {eyebrow && <div className="eyebrow eyebrow-accent">{eyebrow}</div>}23        <h1 className="display text-[40px] md:text-[60px] mt-3">{title}</h1>24        {lede && <p className="lede mt-4">{lede}</p>}25        {children}26      </div>27      {action && <div className="shrink-0">{action}</div>}28    </div>29  );30}3132export function Card({ children, className, pad = true }: { children: ReactNode; className?: string; pad?: boolean }) {33  return <div className={cls("card", pad && "p-4 md:p-5", className)}>{children}</div>;34}3536export function Metric({ label, value, sub, delta, className, size = "md", color, serif = true }: { label: string; value: ReactNode; sub?: ReactNode; delta?: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl"; color?: string; serif?: boolean }) {37  const vs = { sm: "text-[22px]", md: "text-[30px]", lg: "text-[44px]", xl: "text-[72px] md:text-[96px]" }[size];38  return (39    <div className={cls("min-w-0", className)}>40      <div className="eyebrow">{label}</div>41      <div className={cls(serif ? "figure" : "num font-bold tracking-tight", "mt-1.5", vs)} style={color ? { color } : undefined}>{value}</div>42      {(sub || delta) && (43        <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 mt-1.5 text-[13px] text-ink-2">44          {delta}45          {sub && <span className="whitespace-nowrap">{sub}</span>}46        </div>47      )}48    </div>49  );50}5152export function MetricDelta({ value, digits = 1, suffix = " pt", invert = false }: { value: number | null | undefined; digits?: 0 | 1; suffix?: string; invert?: boolean }) {53  if (value === null || value === undefined) return null;54  const eps = digits === 1 ? 0.05 : 0.5;55  const zero = Math.abs(value) < eps;56  const up = value > 0;57  const good = invert ? !up : up;58  const txt = zero ? "0" : (up ? "+" : "−") + Math.abs(value).toFixed(digits).replace(".", ",");59  return (60    <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12px] px-1.5 py-[2px] rounded-[6px] mono whitespace-nowrap", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-ok-soft" : "text-live bg-live-soft")} aria-label={`variation ${txt}${suffix}`}>61      {!zero && (up ? "▲" : "▼")} {txt}{suffix}62    </span>63  );64}6566export function Badge({ children, tone = "neutral", className, title }: { children: ReactNode; tone?: "neutral" | "accent" | "live" | "ok" | "warn" | "ink"; className?: string; title?: string }) {67  const t = {68    neutral: "bg-surface-2 text-ink-2 border-border",69    accent: "bg-accent-soft text-accent border-transparent",70    live: "bg-live-soft text-live border-transparent",71    ok: "bg-ok-soft text-ok border-transparent",72    warn: "bg-warn-soft text-warn border-transparent",73    ink: "bg-brand text-ink-inverse border-transparent",74  }[tone];75  return <span title={title} className={cls("inline-flex items-center gap-1 rounded-[6px] border px-2 py-[3px] text-[10.5px] font-semibold tracking-[0.06em] uppercase mono", t, className)}>{children}</span>;76}7778export function LiveBadge({ label = "Direct" }: { label?: string }) {79  return (80    <Badge tone="live"><span className="live-dot" /> {label}</Badge>81  );82}83export function ProjectionBadge({ children = "Projection QC26" }: { children?: ReactNode }) {84  return <Badge tone="accent">{children}</Badge>;85}86export function OfficialBadge({ children = "Officiel" }: { children?: ReactNode }) {87  return <Badge tone="ok">{children}</Badge>;88}89export function ConfidenceBadge({ value }: { value: number | null | undefined }) {90  if (value === null || value === undefined) return null;91  const tone = value >= 0.85 ? "ok" : value >= 0.6 ? "neutral" : "warn";92  return <Badge tone={tone} title="Confiance de l'extraction (validation schéma + arithmétique + concordance)">Confiance {Math.round(value * 100)} %</Badge>;93}9495export function Timestamp({ iso, prefix = "Mise à jour", className, relative = false }: { iso: string | null | undefined; prefix?: string; className?: string; relative?: boolean }) {96  if (!iso) return null;97  return (98    <time dateTime={iso} className={cls("text-[12px] text-ink-3 num", className)} title={dateTimeFr(iso)}>99      {prefix} {relative ? relTime(iso) : dateTimeFr(iso)}100    </time>101  );102}103104/** Bouton « Source » : ouvre la provenance (URL originale, archive QC26, date de récupération, page). */105export function SourceBadge({ source }: { source: { name: string; url?: string | null; archiveUrl?: string | null; retrievedAt?: string | null; page?: number | null; detail?: string | null; tier?: string | null } }) {106  return (107    <details className="relative inline-block group">108      <summary className="list-none cursor-pointer inline-flex items-center gap-1.5 text-[10.5px] mono font-semibold uppercase tracking-[0.06em] text-ink-2 border border-border rounded-[6px] px-2 py-[3px] hover:bg-surface-2 select-none">109        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg> Source110      </summary>111      <div className="absolute z-30 left-0 mt-2 w-[320px] max-w-[calc(100vw-2rem)] card p-3.5 text-[12.5px] shadow-md text-left leading-relaxed max-md:fixed max-md:left-3 max-md:right-3 max-md:w-auto max-md:max-w-none max-md:bottom-[calc(var(--bottom-nav-h)+env(safe-area-inset-bottom)+12px)] max-md:mt-0 max-md:shadow-lg">112        <div className="font-semibold text-ink">{source.name}</div>113        {source.detail && <div className="text-ink-2 mt-1">{source.detail}</div>}114        <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-ink-2">115          {source.tier && (<><dt>Niveau</dt><dd>{source.tier}</dd></>)}116          {source.page && (<><dt>Page</dt><dd className="num">{source.page}</dd></>)}117          {source.retrievedAt && (<><dt>Récupéré</dt><dd>{dateTimeFr(source.retrievedAt)}</dd></>)}118          {source.url && (<><dt>Original</dt><dd className="truncate"><a className="link" href={source.url} target="_blank" rel="noopener noreferrer">{source.url.replace(/^https?:\/\//, "").slice(0, 44)}…</a></dd></>)}119          {source.archiveUrl && (<><dt>Archive</dt><dd><a className="link" href={source.archiveUrl} target="_blank" rel="noopener noreferrer">copie QC26 (SHA-256)</a></dd></>)}120        </dl>121      </div>122    </details>123  );124}125126export function ProbabilityBar({ p, color, label, height = 10, showValue = true, className }: { p: number; color: string; label?: string; height?: number; showValue?: boolean; className?: string }) {127  const v = Math.max(0, Math.min(1, p));128  return (129    <div className={cls("flex items-center gap-3", className)} role="img" aria-label={`${label ?? "probabilité"} ${Math.round(v * 100)} %`}>130      <div className="flex-1 rounded-full overflow-hidden bg-surface-3" style={{ height }}>131        <div className="h-full rounded-full transition-[width] duration-500" style={{ width: `${v * 100}%`, background: color }} />132      </div>133      {showValue && <span className="num font-semibold text-[13px] w-12 text-right">{v > 0.995 ? "> 99" : v < 0.005 ? "< 1" : Math.round(v * 100)} %</span>}134    </div>135  );136}137138export function Pill({ href, children, active, className }: { href: string; children: ReactNode; active?: boolean; className?: string }) {139  return (140    <Link href={href} className={cls("chip", active && "chip-active", className)}>141      {children}142    </Link>143  );144}145146export function Empty({ title, children }: { title: string; children?: ReactNode }) {147  return (148    <div className="card p-10 text-center">149      <div className="display text-[22px]">{title}</div>150      {children && <div className="text-ink-2 text-[14px] mt-2">{children}</div>}151    </div>152  );153}154155export function Disclaimer({ text }: { text?: string }) {156  return (157    <p className="text-[12.5px] text-ink-3 leading-relaxed max-w-3xl">158      {text ?? "QC26 est une plateforme indépendante d'analyse électorale. Les projections sont des estimations probabilistes et non des prédictions certaines. QC26 n'est affilié à aucun parti politique ni à Élections Québec."}159    </p>160  );161}162163export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {164  return (165    <dl className={cls("grid grid-cols-[minmax(80px,auto)_minmax(0,1fr)] gap-x-3 sm:gap-x-4 gap-y-1.5 text-[13.5px]", className)}>166      {items.map(([k, v], i) => (167        <div key={i} className="contents">168          <dt className="text-ink-2">{k}</dt>169          <dd className="num min-w-0 break-words">{v}</dd>170        </div>171      ))}172    </dl>173  );174}175176/** Petite étiquette « signal » : nom de la source, fraîcheur, statut. */177export function SourceChip({ name, at, tone = "neutral" }: { name: string; at?: string | null; tone?: "neutral" | "ok" | "warn" }) {178  return (179    <span className={cls("inline-flex items-center gap-1.5 text-[11px] mono uppercase tracking-[0.06em] rounded-[6px] px-2 py-[3px] border", tone === "ok" ? "border-transparent bg-ok-soft text-ok" : tone === "warn" ? "border-transparent bg-warn-soft text-warn" : "border-border text-ink-2")}>180      <span className={cls("w-1.5 h-1.5 rounded-full", tone === "ok" ? "bg-ok" : tone === "warn" ? "bg-warn" : "bg-ink-3")} aria-hidden />{name}{at && <span className="text-ink-3 normal-case tracking-normal">· {relTime(at)}</span>}181    </span>182  );183}184185/** Rend une variante étroite (mobile) et une variante large (≥ md) : utile pour les SVG à viewBox fixe. */186export function Dual({ narrow, wide, className }: { narrow: ReactNode; wide: ReactNode; className?: string }) {187  return (188    <>189      <div className={cls("md:hidden", className)}>{narrow}</div>190      <div className={cls("hidden md:block", className)}>{wide}</div>191    </>192  );193}194195/** Rangée mobile « clé → valeur » compacte pour remplacer une ligne de tableau. */196export function StatRow({ label, value, sub, className }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string }) {197  return (198    <div className={cls("flex items-baseline justify-between gap-3 py-2 border-b border-border last:border-b-0", className)}>199      <div className="min-w-0"><div className="text-[13.5px] font-medium truncate">{label}</div>{sub && <div className="text-[11.5px] text-ink-3 truncate">{sub}</div>}</div>200      <div className="mono text-[13.5px] font-semibold shrink-0">{value}</div>201    </div>202  );203}204