SPB Git

spb/qc26

Public
8.6 KB · 161 lines tsx
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 }: { eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode; id?: string; className?: string }) {6  return (7    <div id={id} className={cls("flex flex-wrap items-end justify-between gap-3 mb-4 scroll-mt-20", className)}>8      <div className="min-w-0">9        {eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}10        <h2 className="text-[22px] md:text-[26px] font-bold tracking-tight leading-tight">{title}</h2>11        {description && <p className="text-ink-2 text-[14px] mt-1 max-w-2xl leading-relaxed">{description}</p>}12      </div>13      {action && <div className="shrink-0">{action}</div>}14    </div>15  );16}1718export function Card({ children, className, pad = true }: { children: ReactNode; className?: string; pad?: boolean }) {19  return <div className={cls("card", pad && "p-4 md:p-5", className)}>{children}</div>;20}2122export function Metric({ label, value, sub, delta, className, size = "md", color }: { label: string; value: ReactNode; sub?: ReactNode; delta?: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl"; color?: string }) {23  const vs = { sm: "text-xl", md: "text-[28px]", lg: "text-[40px]", xl: "text-[64px] md:text-[88px]" }[size];24  return (25    <div className={cls("min-w-0", className)}>26      <div className="eyebrow">{label}</div>27      <div className={cls("num display mt-1", vs)} style={color ? { color } : undefined}>{value}</div>28      {(sub || delta) && (29        <div className="flex items-baseline gap-2 mt-1 text-[13px] text-ink-2">30          {delta}31          {sub && <span>{sub}</span>}32        </div>33      )}34    </div>35  );36}3738export function MetricDelta({ value, digits = 1, suffix = " pt", invert = false }: { value: number | null | undefined; digits?: 0 | 1; suffix?: string; invert?: boolean }) {39  if (value === null || value === undefined) return null;40  const eps = digits === 1 ? 0.05 : 0.5;41  const zero = Math.abs(value) < eps;42  const up = value > 0;43  const good = invert ? !up : up;44  const txt = zero ? "0" : (up ? "+" : "−") + Math.abs(value).toFixed(digits).replace(".", ",");45  return (46    <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12.5px] px-1.5 py-[1px] rounded-[5px]", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-[rgba(21,128,61,.09)]" : "text-live bg-[rgba(220,38,38,.08)]")} aria-label={`variation ${txt}${suffix}`}>47      {!zero && (up ? "▲" : "▼")} {txt}{suffix}48    </span>49  );50}5152export function Badge({ children, tone = "neutral", className, title }: { children: ReactNode; tone?: "neutral" | "accent" | "live" | "ok" | "warn" | "ink"; className?: string; title?: string }) {53  const t = {54    neutral: "bg-surface-2 text-ink-2 border-border",55    accent: "bg-accent-soft text-accent border-transparent",56    live: "bg-[rgba(220,38,38,.08)] text-live border-transparent",57    ok: "bg-[rgba(21,128,61,.09)] text-ok border-transparent",58    warn: "bg-[rgba(180,83,9,.1)] text-warn border-transparent",59    ink: "bg-brand text-white border-transparent",60  }[tone];61  return <span title={title} className={cls("inline-flex items-center gap-1 rounded-[6px] border px-2 py-[3px] text-[11px] font-semibold tracking-wide uppercase", t, className)}>{children}</span>;62}6364export function LiveBadge({ label = "Direct" }: { label?: string }) {65  return (66    <Badge tone="live"><span className="live-dot" /> {label}</Badge>67  );68}69export function ProjectionBadge({ children = "Projection QC26" }: { children?: ReactNode }) {70  return <Badge tone="accent">{children}</Badge>;71}72export function OfficialBadge({ children = "Officiel" }: { children?: ReactNode }) {73  return <Badge tone="ok">{children}</Badge>;74}75export function ConfidenceBadge({ value }: { value: number | null | undefined }) {76  if (value === null || value === undefined) return null;77  const tone = value >= 0.85 ? "ok" : value >= 0.6 ? "neutral" : "warn";78  return <Badge tone={tone} title="Confiance de l'extraction (validation schéma + arithmétique + concordance)">Confiance {Math.round(value * 100)} %</Badge>;79}8081export function Timestamp({ iso, prefix = "Mise à jour", className, relative = false }: { iso: string | null | undefined; prefix?: string; className?: string; relative?: boolean }) {82  if (!iso) return null;83  return (84    <time dateTime={iso} className={cls("text-[12px] text-ink-3 num", className)} title={dateTimeFr(iso)}>85      {prefix} {relative ? relTime(iso) : dateTimeFr(iso)}86    </time>87  );88}8990/** Bouton « Source » : ouvre la provenance (URL originale, archive QC26, date de récupération, page). */91export function SourceBadge({ source }: { source: { name: string; url?: string | null; archiveUrl?: string | null; retrievedAt?: string | null; page?: number | null; detail?: string | null; tier?: string | null } }) {92  return (93    <details className="relative inline-block group">94      <summary className="list-none cursor-pointer inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide text-ink-2 border border-border rounded-[6px] px-2 py-[3px] hover:bg-surface-2 select-none">95        <span aria-hidden>⌕</span> Source96      </summary>97      <div className="absolute z-30 left-0 mt-2 w-[300px] max-w-[calc(100vw-2rem)] card p-3 text-[12.5px] shadow-md text-left leading-relaxed">98        <div className="font-semibold text-ink">{source.name}</div>99        {source.detail && <div className="text-ink-2 mt-1">{source.detail}</div>}100        <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-ink-2">101          {source.tier && (<><dt>Niveau</dt><dd>{source.tier}</dd></>)}102          {source.page && (<><dt>Page</dt><dd className="num">{source.page}</dd></>)}103          {source.retrievedAt && (<><dt>Récupéré</dt><dd>{dateTimeFr(source.retrievedAt)}</dd></>)}104          {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></>)}105          {source.archiveUrl && (<><dt>Archive</dt><dd><a className="link" href={source.archiveUrl} target="_blank" rel="noopener noreferrer">copie QC26 (SHA-256)</a></dd></>)}106        </dl>107      </div>108    </details>109  );110}111112export function ProbabilityBar({ p, color, label, height = 10, showValue = true, className }: { p: number; color: string; label?: string; height?: number; showValue?: boolean; className?: string }) {113  const v = Math.max(0, Math.min(1, p));114  return (115    <div className={cls("flex items-center gap-3", className)} role="img" aria-label={`${label ?? "probabilité"} ${Math.round(v * 100)} %`}>116      <div className="flex-1 rounded-full overflow-hidden bg-surface-3" style={{ height }}>117        <div className="h-full rounded-full transition-[width] duration-500" style={{ width: `${v * 100}%`, background: color }} />118      </div>119      {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>}120    </div>121  );122}123124export function Pill({ href, children, active, className }: { href: string; children: ReactNode; active?: boolean; className?: string }) {125  return (126    <Link href={href} className={cls("inline-flex items-center rounded-full px-3 py-1.5 text-[13px] font-medium border transition-colors", active ? "bg-brand text-white border-brand" : "bg-surface border-border hover:bg-surface-2", className)}>127      {children}128    </Link>129  );130}131132export function Empty({ title, children }: { title: string; children?: ReactNode }) {133  return (134    <div className="card p-8 text-center">135      <div className="font-semibold">{title}</div>136      {children && <div className="text-ink-2 text-[14px] mt-1">{children}</div>}137    </div>138  );139}140141export function Disclaimer({ text }: { text?: string }) {142  return (143    <p className="text-[12.5px] text-ink-3 leading-relaxed max-w-3xl">144      {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."}145    </p>146  );147}148149export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {150  return (151    <dl className={cls("grid grid-cols-[minmax(90px,auto)_1fr] gap-x-4 gap-y-1.5 text-[13.5px]", className)}>152      {items.map(([k, v], i) => (153        <div key={i} className="contents">154          <dt className="text-ink-2">{k}</dt>155          <dd className="num min-w-0 break-words">{v}</dd>156        </div>157      ))}158    </dl>159  );160}161
Files · f3bb07025804f73080c3fe80ab3c20f6aeea6682
Menu

Theme