Conformité RGPD (P3) + transparence éditoriale (P4)

P3 — RGPD :
- Table `consents` + `POST /api/consent` (art. 7.1 — preuve du consentement)
- Dialogue de consentement explicite avant la première contribution (art. 9.2.a)
- Pages `/mentions-legales` et `/politique-confidentialite`
- `docs/RGPD.md` — registre des traitements, bases légales, sous-traitants
- `getVisitorId()` exporté depuis l'API client React

P4 — Transparence éditoriale :
- Page `/contributions-brutes` avec pagination et export JSON/CSV
- `GET /api/contributions`, `GET /api/contributions/export/{json,csv}`
- `GET /api/stats/public` — stats publiques sans données de rejet
- Label de transparence IA sur la colonne de synthèse
- Compteurs (acceptées / soumises) dans le bandeau d'intro
- `docs/PROMPTS_IA.md` — prompts intégraux publiés + analyse des biais
- Pied de page avec liens légaux et transparence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:30:30 +02:00
parent 45edc1fa77
commit a7b7684e87
12 changed files with 1354 additions and 50 deletions
@@ -0,0 +1,193 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react";
import { Link } from "wouter";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import {
ArrowLeft, Download, ChevronLeft, ChevronRight, Loader2, AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
const API_BASE = import.meta.env.VITE_API_URL ?? "";
const PER_PAGE = 20;
interface Contribution {
id: number;
content: string;
author: string | null;
createdAt: string;
}
interface ContributionsResponse {
contributions: Contribution[];
total: number;
page: number;
perPage: number;
pages: number;
}
export default function ContributionsBrutes() {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<ContributionsResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
React.useEffect(() => {
setLoading(true);
setError(false);
fetch(`${API_BASE}/api/contributions?page=${page}&per_page=${PER_PAGE}`)
.then((res) => {
if (!res.ok) throw new Error();
return res.json() as Promise<ContributionsResponse>;
})
.then((d) => {
setData(d);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
}, [page]);
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
<Link href="/">
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
</Button>
</Link>
<header className="mb-8 space-y-3">
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
Contributions brutes
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
L'intégralité des contributions acceptées, dans leur ordre de soumission, sans mise en forme éditoriale.
</p>
</header>
{/* Avertissement éditorial */}
<Alert className="mb-8 border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40">
<AlertDescription className="text-sm text-foreground/80 leading-relaxed">
La{" "}
<Link href="/" className="underline text-primary">
synthèse collective
</Link>{" "}
visible sur la page d'accueil est une interprétation thématique automatisée de ces contributions.
Elle peut regrouper, omettre ou reformuler des expressions individuelles.
Cette page est la source primaire pour vérification.
</AlertDescription>
</Alert>
{/* Boutons d'export */}
<div className="flex gap-3 mb-8">
<a
href={`${API_BASE}/api/contributions/export/json`}
download="contributions.json"
>
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
<Download className="h-3.5 w-3.5" /> Export JSON
</Button>
</a>
<a
href={`${API_BASE}/api/contributions/export/csv`}
download="contributions.csv"
>
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
<Download className="h-3.5 w-3.5" /> Export CSV
</Button>
</a>
</div>
{/* Contenu */}
{loading ? (
<div className="flex justify-center py-16 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
<AlertCircle className="h-6 w-6 text-destructive" />
<span className="text-sm">Impossible de charger les contributions.</span>
</div>
) : data && data.contributions.length > 0 ? (
<>
<p className="text-xs font-mono text-muted-foreground mb-6">
{data.total} contribution{data.total !== 1 ? "s" : ""} acceptée{data.total !== 1 ? "s" : ""}
{" "} page {data.page} / {data.pages}
</p>
<div className="space-y-6">
{data.contributions.map((c) => (
<div
key={c.id}
className="border-l-2 border-border/50 pl-4 space-y-1"
id={`contrib-${c.id}`}
>
<p className="text-sm leading-relaxed font-serif text-foreground/90">
{c.content}
</p>
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
<span className="font-semibold text-primary/70">
{c.author || "Citoyen anonyme"}
</span>
<span>&bull;</span>
<span>
{format(new Date(c.createdAt), "d MMMM yyyy, HH:mm", { locale: fr })}
</span>
<span className="ml-auto opacity-40">#{c.id}</span>
</div>
</div>
))}
</div>
{/* Pagination */}
{data.pages > 1 && (
<div className="flex items-center justify-between mt-10 pt-6 border-t border-border/40">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="gap-1.5"
>
<ChevronLeft className="h-4 w-4" /> Précédent
</Button>
<span className="text-xs font-mono text-muted-foreground">
{page} / {data.pages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="gap-1.5"
>
Suivant <ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
) : (
<div className="text-center py-16 text-muted-foreground text-sm border border-dashed border-border/60">
Aucune contribution acceptée pour l'instant.
</div>
)}
<p className="text-xs text-muted-foreground/60 font-mono pt-8 mt-8 border-t border-border/30">
Contributions modérées selon le droit international des droits humains (DUDH · PIDCP · CEDH).{" "}
<a
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
className="underline text-primary"
rel="noopener noreferrer"
>
Voir les critères de modération
</a>
.
</p>
</div>
</div>
);
}