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:
@@ -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>•</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user