41556d3ea2
- home.tsx: add SynthesisText component — detects "Sur X : / Concernant X :" thematic prefixes and renders them in serif/teal, replaces raw whitespace-pre-line - home.tsx: redesign client-side PDF — site color palette (#F9F7F1, #1b5f6a), Georgia serif typography, thematic prefix highlighting, tricolor stripe - app.py: redesign consultation print export — same visual identity, server-side paragraph parsing with theme span injection before HTML-escaping - transparence.tsx: rewrite priority explanation to be explicit about what "critique/haute/moyenne" means in practice (injection vs monitoring, why) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
776 lines
34 KiB
TypeScript
776 lines
34 KiB
TypeScript
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
|
import React from "react";
|
|
import { Link } from "wouter";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import { format } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
|
import {
|
|
useSubmitIdea,
|
|
useListIdeas,
|
|
useGetIdeaStats,
|
|
useGetSynthesis,
|
|
getListIdeasQueryKey,
|
|
getGetIdeaStatsQueryKey,
|
|
addExtraHeader,
|
|
removeExtraHeader,
|
|
getVisitorId,
|
|
ApiError,
|
|
} from "@workspace/api-client-react";
|
|
import { ConsentDialog } from "@/components/consent-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@/components/ui/accordion";
|
|
import {
|
|
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
|
|
Share2, Printer, Copy, Flag, BookOpen,
|
|
} from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
const submitIdeaSchema = z.object({
|
|
content: z.string()
|
|
.min(10, "Votre contribution doit faire au moins 10 caractères.")
|
|
.max(1000, "Merci de rester sous 1 000 caractères."),
|
|
author: z.string().max(100).optional(),
|
|
});
|
|
|
|
type SubmitIdeaValues = z.infer<typeof submitIdeaSchema>;
|
|
|
|
const VALEURS = [
|
|
{
|
|
source: "DUDH — Art. 1 (ONU, 1948)",
|
|
texte: "Tous les êtres humains naissent libres et égaux en dignité et en droits.",
|
|
},
|
|
{
|
|
source: "DUDH — Art. 19",
|
|
texte: "Tout individu a droit à la liberté d'opinion et d'expression.",
|
|
},
|
|
{
|
|
source: "DUDH — Art. 20",
|
|
texte: "Tout appel à la haine nationale, raciale ou religieuse constituant une incitation à la discrimination, à l'hostilité ou à la violence est interdit par la loi.",
|
|
},
|
|
{
|
|
source: "PIDCP — Art. 20 (ONU, 1966)",
|
|
texte: "Tout appel à la haine nationale, raciale ou religieuse qui constitue une incitation à la discrimination, à l'hostilité ou à la violence est interdit par la loi.",
|
|
},
|
|
{
|
|
source: "CEDH — Art. 10 (Conseil de l'Europe, 1950)",
|
|
texte: "Toute personne a droit à la liberté d'expression, sous réserve des restrictions nécessaires à la protection des droits d'autrui.",
|
|
},
|
|
{
|
|
source: "CEDH — Art. 17",
|
|
texte: "Aucune disposition de la Convention ne peut être interprétée comme impliquant le droit de se livrer à une activité visant à la destruction des droits reconnus.",
|
|
},
|
|
{
|
|
source: "Charte des droits fondamentaux de l'UE — Art. 1 (2000)",
|
|
texte: "La dignité humaine est inviolable. Elle doit être respectée et protégée.",
|
|
},
|
|
];
|
|
|
|
// Clé hCaptcha — activée si la variable d'environnement est définie
|
|
const HCAPTCHA_SITE_KEY = import.meta.env.VITE_HCAPTCHA_SITE_KEY as string | undefined;
|
|
|
|
// Détecte "Sur X :" / "Concernant Y :" en début de bloc et met le préfixe en valeur
|
|
function SynthesisText({ text }: { text: string }) {
|
|
const THEME_RE = /^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$/i;
|
|
const blocks = text.split(/\n{2,}/);
|
|
return (
|
|
<div className="space-y-5">
|
|
{blocks.map((block, i) => {
|
|
const trimmed = block.trim();
|
|
if (!trimmed) return null;
|
|
const m = trimmed.match(THEME_RE);
|
|
if (m) {
|
|
return (
|
|
<p key={i} className="text-sm md:text-base leading-[1.85] text-foreground">
|
|
<span className="font-serif font-semibold text-primary">{m[1]}</span>
|
|
{m[2]}
|
|
</p>
|
|
);
|
|
}
|
|
return (
|
|
<p key={i} className="text-sm md:text-base leading-[1.85] text-foreground">
|
|
{trimmed}
|
|
</p>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
|
|
|
export default function Home() {
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
const [submitResult, setSubmitResult] = React.useState<{
|
|
success: boolean;
|
|
message: string;
|
|
reason?: string;
|
|
} | null>(null);
|
|
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
|
|
const [flaggingId, setFlaggingId] = React.useState<number | null>(null);
|
|
|
|
// Ref pour le champ leurre honeypot — invisible, non relié à react-hook-form
|
|
const honeypotRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
// hCaptcha — widget et token
|
|
const captchaRef = React.useRef<HCaptcha>(null);
|
|
const [captchaToken, setCaptchaToken] = React.useState<string | null>(null);
|
|
|
|
// Consentement RGPD — persisté dans localStorage (art. 9.2.a)
|
|
const [consentGiven, setConsentGiven] = React.useState(
|
|
() => !!localStorage.getItem("consent_v1")
|
|
);
|
|
const [showConsentDialog, setShowConsentDialog] = React.useState(false);
|
|
const pendingSubmitData = React.useRef<SubmitIdeaValues | null>(null);
|
|
|
|
const handleFlag = async (ideaId: number) => {
|
|
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
|
setFlaggingId(ideaId);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/ideas/${ideaId}/flag`, { method: "POST" });
|
|
if (res.ok) {
|
|
setFlaggedIds((prev) => new Set(prev).add(ideaId));
|
|
toast({ title: "Signalement envoyé", description: "Cette contribution a été signalée à l'administrateur." });
|
|
} else {
|
|
toast({ title: "Erreur", description: "Impossible d'envoyer le signalement.", variant: "destructive" });
|
|
}
|
|
} catch {
|
|
toast({ title: "Erreur réseau", description: "Vérifiez votre connexion.", variant: "destructive" });
|
|
} finally {
|
|
setFlaggingId(null);
|
|
}
|
|
};
|
|
|
|
const submitIdea = useSubmitIdea();
|
|
const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas();
|
|
const { data: stats } = useGetIdeaStats();
|
|
const { data: synthesis, isLoading: isLoadingSynthesis } = useGetSynthesis({
|
|
query: { refetchInterval: 15000 },
|
|
});
|
|
|
|
const [countdown, setCountdown] = React.useState(15);
|
|
React.useEffect(() => { setCountdown(15); }, [synthesis]);
|
|
React.useEffect(() => {
|
|
if (countdown <= 0) return;
|
|
const t = setTimeout(() => setCountdown(c => c - 1), 1000);
|
|
return () => clearTimeout(t);
|
|
}, [countdown]);
|
|
|
|
const [mobileTab, setMobileTab] = React.useState<"proposer" | "synthese">("proposer");
|
|
const [lawCheckResult, setLawCheckResult] = React.useState<string | null>(null);
|
|
const [lawCheckLoading, setLawCheckLoading] = React.useState(false);
|
|
|
|
// Soumission effective après confirmation du consentement (ou si déjà consenti)
|
|
const doActualSubmit = (data: SubmitIdeaValues) => {
|
|
if (captchaToken) {
|
|
addExtraHeader("x-hcaptcha-token", captchaToken);
|
|
}
|
|
setSubmitResult(null);
|
|
submitIdea.mutate({ data }, {
|
|
onSuccess: (result) => {
|
|
if (result.accepted) {
|
|
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
|
|
form.reset();
|
|
} else {
|
|
setSubmitResult({
|
|
success: false,
|
|
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
|
|
reason: result.reason ?? undefined,
|
|
});
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
|
|
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
|
|
},
|
|
onError: (error) => {
|
|
const apiMessage = error instanceof ApiError
|
|
? (error.data as { message?: string } | null)?.message
|
|
: undefined;
|
|
setSubmitResult({
|
|
success: false,
|
|
message: apiMessage ?? "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
|
|
});
|
|
},
|
|
onSettled: () => {
|
|
removeExtraHeader("x-hcaptcha-token");
|
|
captchaRef.current?.resetCaptcha();
|
|
setCaptchaToken(null);
|
|
},
|
|
});
|
|
};
|
|
|
|
const postConsent = async () => {
|
|
const visitorId = getVisitorId();
|
|
await fetch(`${API_BASE}/api/consent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(visitorId ? { "X-Visitor-Id": visitorId } : {}),
|
|
},
|
|
body: JSON.stringify({ consent_version: "1.0" }),
|
|
}).catch(() => {});
|
|
};
|
|
|
|
// Confirme le consentement, l'enregistre en DB, puis exécute la soumission en attente
|
|
const handleConsentConfirm = async () => {
|
|
localStorage.setItem("consent_v1", new Date().toISOString());
|
|
setConsentGiven(true);
|
|
setShowConsentDialog(false);
|
|
await postConsent();
|
|
if (pendingSubmitData.current) {
|
|
doActualSubmit(pendingSubmitData.current);
|
|
pendingSubmitData.current = null;
|
|
}
|
|
};
|
|
|
|
const checkLaw = async () => {
|
|
const content = form.getValues("content")?.trim();
|
|
if (!content || content.length < 10) return;
|
|
setLawCheckLoading(true);
|
|
setLawCheckResult(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/check-law`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
const data = await res.json();
|
|
setLawCheckResult(data.note || null);
|
|
} catch {
|
|
setLawCheckResult(null);
|
|
} finally {
|
|
setLawCheckLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleShare = () => {
|
|
if (!synthesis?.text) return;
|
|
const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr });
|
|
const nb = synthesis.ideaCount ?? 0;
|
|
const shareText = `La Voix du Peuple — Synthèse citoyenne\nGénérée le ${date}\n\n${synthesis.text}\n\n(${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""})\n\nhttps://lavoixdupeuple.fr`;
|
|
if (navigator.share) {
|
|
navigator.share({ title: "La Voix du Peuple — Synthèse", text: shareText });
|
|
} else {
|
|
navigator.clipboard.writeText(shareText).then(() => {
|
|
toast({ description: "Texte copié dans le presse-papier ✓" });
|
|
});
|
|
}
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
if (!synthesis?.text) return;
|
|
const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr });
|
|
const nb = synthesis.ideaCount ?? 0;
|
|
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
const THEME_RE = /^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$/i;
|
|
const formattedText = synthesis.text
|
|
.split(/\n{2,}/)
|
|
.filter(Boolean)
|
|
.map((block) => {
|
|
const t = block.trim();
|
|
const m = t.match(THEME_RE);
|
|
if (m) return `<p><span class="theme">${esc(m[1])}</span>${esc(m[2])}</p>`;
|
|
return `<p>${esc(t)}</p>`;
|
|
})
|
|
.join("\n");
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>La Voix du Peuple — Synthèse</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: Georgia, 'Times New Roman', serif; background: #F9F7F1; color: #1a1a1a; }
|
|
.page { max-width: 680px; margin: 0 auto; background: #fff; min-height: 100vh; padding: 52px 56px; }
|
|
.tricolor { display: flex; height: 3px; margin-bottom: 40px; }
|
|
.tricolor span:nth-child(1) { flex: 1; background: #002395; }
|
|
.tricolor span:nth-child(2) { flex: 1; background: #E5E5E5; }
|
|
.tricolor span:nth-child(3) { flex: 1; background: #ED2939; }
|
|
.label { font-family: 'Courier New', monospace; font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.14em; color: #1b5f6a; margin-bottom: 10px; }
|
|
h1 { font-size: 1.55rem; font-weight: 700; color: #111; line-height: 1.2; margin-bottom: 6px; }
|
|
.meta { font-family: 'Courier New', monospace; font-size: 0.68rem; color: #999; margin-bottom: 36px; padding-bottom: 24px; border-bottom: 1px solid #E8E4DC; }
|
|
.body { font-size: 0.95rem; line-height: 1.85; color: #1a1a1a; }
|
|
.body p { margin-bottom: 1.1em; }
|
|
.theme { font-family: Georgia, serif; font-weight: 600; color: #1b5f6a; }
|
|
.footer { margin-top: 52px; padding-top: 14px; border-top: 1px solid #E8E4DC; font-family: 'Courier New', monospace; font-size: 0.62rem; color: #bbb; display: flex; justify-content: space-between; gap: 16px; }
|
|
@media print { body { background: white; } .page { box-shadow: none; padding: 32px 40px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<div class="tricolor"><span></span><span></span><span></span></div>
|
|
<p class="label">La Voix du Peuple · Synthèse citoyenne</p>
|
|
<h1>Synthèse des contributions</h1>
|
|
<div class="meta">Générée le ${esc(date)} · ${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""}</div>
|
|
<div class="body">${formattedText}</div>
|
|
<div class="footer">
|
|
<span>lavoixdupeuple.fr</span>
|
|
<span>Modération DUDH · PIDCP · CEDH · droit pénal français</span>
|
|
</div>
|
|
</div>
|
|
<script>window.print();</script>
|
|
</body>
|
|
</html>`;
|
|
const w = window.open("", "_blank");
|
|
if (w) {
|
|
w.document.write(html);
|
|
w.document.close();
|
|
}
|
|
};
|
|
|
|
const form = useForm<SubmitIdeaValues>({
|
|
resolver: zodResolver(submitIdeaSchema),
|
|
defaultValues: { content: "", author: "" },
|
|
});
|
|
|
|
const onSubmit = async (data: SubmitIdeaValues) => {
|
|
// Honeypot — si le champ leurre est rempli, c'est un bot
|
|
if (honeypotRef.current?.value) {
|
|
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
|
|
form.reset();
|
|
return;
|
|
}
|
|
// hCaptcha — obligatoire si la clé de site est configurée
|
|
if (HCAPTCHA_SITE_KEY && !captchaToken) {
|
|
toast({ title: "Vérification requise", description: "Veuillez valider le CAPTCHA avant de soumettre.", variant: "destructive" });
|
|
return;
|
|
}
|
|
// Consentement RGPD — obligatoire avant la première contribution
|
|
if (!consentGiven) {
|
|
pendingSubmitData.current = data;
|
|
setShowConsentDialog(true);
|
|
return;
|
|
}
|
|
await postConsent();
|
|
doActualSubmit(data);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ConsentDialog
|
|
open={showConsentDialog}
|
|
onConsent={handleConsentConfirm}
|
|
onCancel={() => { setShowConsentDialog(false); pendingSubmitData.current = null; }}
|
|
/>
|
|
|
|
{/* Bandeau d'introduction */}
|
|
<div className="border-b border-border/40 bg-muted/30 px-6 md:px-10 py-5">
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 max-w-5xl">
|
|
<p className="text-sm text-foreground/75 leading-relaxed">
|
|
<span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie.
|
|
Exprimez-vous librement — chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite.
|
|
<span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span>
|
|
</p>
|
|
{stats && stats.total > 0 && (
|
|
<div className="flex items-center gap-5 text-xs font-mono text-muted-foreground flex-shrink-0 sm:text-right">
|
|
<div>
|
|
<div className="text-xl font-bold text-primary leading-none">{stats.accepted}</div>
|
|
<div className="mt-0.5">acceptées</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xl font-bold text-foreground/40 leading-none">{stats.total}</div>
|
|
<div className="mt-0.5">soumises</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Onglets mobile — masqués sur md+ */}
|
|
<div className="md:hidden flex flex-shrink-0 border-b border-border/40 text-xs font-mono uppercase tracking-widest">
|
|
<button
|
|
className={`flex-1 py-3 transition-colors ${mobileTab === "proposer" ? "border-b-2 border-primary text-primary bg-card font-bold" : "text-muted-foreground bg-background"}`}
|
|
onClick={() => setMobileTab("proposer")}
|
|
>
|
|
Proposer
|
|
</button>
|
|
<button
|
|
className={`flex-1 py-3 transition-colors flex items-center justify-center gap-1.5 ${mobileTab === "synthese" ? "border-b-2 border-primary text-primary bg-[#F9F7F1] dark:bg-card font-bold" : "text-muted-foreground bg-background"}`}
|
|
onClick={() => setMobileTab("synthese")}
|
|
>
|
|
Synthèse <span className="font-mono opacity-60">{countdown}s</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 grid min-h-0 md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] md:h-[calc(100vh-9rem)]">
|
|
{/* Colonne gauche : formulaire + fil des idées */}
|
|
<div className={`flex flex-col border-r border-border/40 bg-card md:flex ${mobileTab === "proposer" ? "" : "hidden"}`}>
|
|
<div className="p-6 md:p-8 flex flex-col gap-6 flex-shrink-0 border-b border-border/40">
|
|
<div className="space-y-2">
|
|
<h1 className="text-3xl font-serif font-bold text-primary tracking-tight">
|
|
Vos propositions
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
Quelle mesure souhaiteriez-vous voir portée par vos représentants ?
|
|
</p>
|
|
</div>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
{/* Champ leurre anti-bot (honeypot) — invisible, ne jamais supprimer */}
|
|
<input
|
|
ref={honeypotRef}
|
|
type="text"
|
|
name="_hp"
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
style={{ display: "none", position: "absolute", left: "-9999px" }}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="content"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="sr-only">Votre idée</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Quelle proposition souhaitez-vous faire remonter ?"
|
|
className="min-h-[90px] sm:min-h-[120px] resize-none font-serif text-lg bg-background border-primary/20 focus-visible:ring-primary placeholder:text-muted-foreground/50"
|
|
data-testid="input-idea-content"
|
|
{...field}
|
|
onChange={(e) => {
|
|
field.onChange(e);
|
|
if (lawCheckResult) setLawCheckResult(null);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{/* Vérification cadre légal existant */}
|
|
<div className="flex items-start gap-2 flex-wrap">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
|
|
disabled={lawCheckLoading || (form.getValues("content")?.trim().length ?? 0) < 10}
|
|
onClick={checkLaw}
|
|
title="Vérifier si cette proposition est déjà inscrite dans la loi française ou européenne"
|
|
>
|
|
{lawCheckLoading
|
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
|
: <BookOpen className="h-3 w-3" />}
|
|
{lawCheckLoading ? "Vérification…" : "Vérifier le cadre légal existant"}
|
|
</Button>
|
|
</div>
|
|
|
|
{lawCheckResult && (
|
|
<div className="text-xs border border-primary/20 bg-primary/5 rounded-md px-3 py-2.5 text-foreground/80 leading-relaxed flex gap-2">
|
|
<Scale className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
|
|
<span>{lawCheckResult}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-start gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="author"
|
|
render={({ field }) => (
|
|
<FormItem className="flex-1">
|
|
<FormLabel className="sr-only">Pseudonyme (optionnel)</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Pseudonyme (optionnel)"
|
|
className="bg-background border-primary/20 focus-visible:ring-primary font-mono text-sm"
|
|
data-testid="input-idea-author"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
className="font-bold tracking-wide flex-shrink-0"
|
|
disabled={submitIdea.isPending || (HCAPTCHA_SITE_KEY ? !captchaToken : false)}
|
|
data-testid="button-submit-idea"
|
|
>
|
|
{submitIdea.isPending ? (
|
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Envoi…</>
|
|
) : (
|
|
<><PenTool className="mr-2 h-4 w-4" /> Contribuer</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* hCaptcha — activé uniquement si VITE_HCAPTCHA_SITE_KEY est défini */}
|
|
{HCAPTCHA_SITE_KEY && (
|
|
<div className="flex justify-start">
|
|
<HCaptcha
|
|
ref={captchaRef}
|
|
sitekey={HCAPTCHA_SITE_KEY}
|
|
onVerify={(token) => setCaptchaToken(token)}
|
|
onExpire={() => setCaptchaToken(null)}
|
|
onError={() => setCaptchaToken(null)}
|
|
size="compact"
|
|
languageOverride="fr"
|
|
/>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
|
|
{submitResult && (
|
|
<Alert
|
|
variant={submitResult.success ? "default" : "default"}
|
|
className={`mt-2 ${submitResult.success ? "" : "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800/50 dark:bg-amber-950/40 dark:text-amber-200"}`}
|
|
data-testid={`alert-submit-${submitResult.success ? "success" : "info"}`}
|
|
>
|
|
{submitResult.success
|
|
? <CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
: <Info className="h-4 w-4 text-amber-600" />
|
|
}
|
|
<AlertTitle>
|
|
{submitResult.success
|
|
? "Contribution enregistrée"
|
|
: submitResult.reason
|
|
? "Contribution non retenue"
|
|
: "Envoi impossible"}
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
{submitResult.message}
|
|
{submitResult.reason && !submitResult.success && (
|
|
<div className="mt-2 text-sm italic border-l-2 pl-2 py-1 border-amber-400 opacity-90">
|
|
{submitResult.reason}
|
|
</div>
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Encart valeurs */}
|
|
<Accordion type="single" collapsible className="border border-border/40 rounded-sm px-3">
|
|
<AccordionItem value="valeurs" className="border-none">
|
|
<AccordionTrigger className="text-xs font-mono uppercase tracking-widest text-muted-foreground hover:no-underline py-3">
|
|
<span className="flex items-center gap-2">
|
|
<Scale className="h-3 w-3" /> Cadre de modération
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
|
|
Les contributions sont modérées selon les textes fondamentaux du droit
|
|
international des droits humains et du droit français. Les contenus contraires
|
|
à ces principes ne sont pas intégrés.
|
|
</p>
|
|
<div className="space-y-3">
|
|
{VALEURS.map((v) => (
|
|
<div key={v.source} className="border-l-2 border-primary/30 pl-3">
|
|
<p className="text-xs font-mono font-semibold text-primary/80 mb-0.5">{v.source}</p>
|
|
<p className="text-xs font-serif text-foreground/80 leading-relaxed italic">« {v.texte} »</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground font-mono mt-4">
|
|
Sources : DUDH (ONU 1948) · PIDCP (ONU 1966) · CEDH (1950) ·
|
|
Charte UE (2000) · Statut de Rome / CPI (1998) · CERD (ONU 1965) ·
|
|
Code pénal français (Art. 222-32, 227-24) · Loi du 29 juillet 1881
|
|
</p>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
</div>
|
|
|
|
{/* Fil des proclamations acceptées */}
|
|
<ScrollArea className="flex-1 bg-muted/30">
|
|
<div className="p-6 md:p-8">
|
|
<h2 className="text-xs font-mono font-bold uppercase tracking-widest text-muted-foreground mb-6 flex items-center gap-2">
|
|
<TrendingUp className="h-3 w-3" /> Contributions récentes
|
|
</h2>
|
|
|
|
<div className="space-y-8">
|
|
{isLoadingIdeas ? (
|
|
<div className="flex justify-center p-8 text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : ideas && ideas.length > 0 ? (
|
|
ideas.slice(0, 20).map((idea) => (
|
|
<div
|
|
key={idea.id}
|
|
className="group relative pl-4 border-l border-border/60 hover:border-primary/50 transition-colors"
|
|
data-testid={`card-idea-${idea.id}`}
|
|
>
|
|
<p className="text-foreground/90 font-serif leading-relaxed">
|
|
{idea.content}
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-3 text-xs font-mono text-muted-foreground">
|
|
<span className="font-semibold text-primary/70">
|
|
{idea.author || "Citoyen anonyme"}
|
|
</span>
|
|
<span>•</span>
|
|
<span>
|
|
{idea.createdAt ? format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr }) : "—"}
|
|
</span>
|
|
<button
|
|
onClick={() => handleFlag(idea.id)}
|
|
disabled={flaggedIds.has(idea.id) || flaggingId === idea.id}
|
|
className={`ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity text-xs ${
|
|
flaggedIds.has(idea.id)
|
|
? "text-orange-500 opacity-100"
|
|
: "text-muted-foreground/50 hover:text-orange-500"
|
|
}`}
|
|
title={flaggedIds.has(idea.id) ? "Déjà signalé" : "Signaler cette contribution"}
|
|
aria-label="Signaler cette contribution"
|
|
>
|
|
{flaggingId === idea.id ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Flag className="h-3 w-3" />
|
|
)}
|
|
{flaggedIds.has(idea.id) ? "Signalé" : "Signaler"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center p-8 text-muted-foreground text-sm border border-dashed border-border/60">
|
|
Aucune contribution enregistrée pour l'instant.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Colonne droite : synthèse vivante */}
|
|
<div className={`flex flex-col bg-[#F9F7F1] dark:bg-card relative overflow-hidden md:flex ${mobileTab === "synthese" ? "" : "hidden"}`}>
|
|
<div
|
|
className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-multiply dark:mix-blend-overlay"
|
|
style={{
|
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
|
}}
|
|
/>
|
|
|
|
{/* En-tête fixe */}
|
|
<div className="flex justify-between items-start px-6 md:px-10 py-5 border-b border-border/30 relative z-10 flex-shrink-0 gap-3">
|
|
<div className="min-w-0">
|
|
<h2 className="text-xs font-mono font-bold uppercase tracking-widest text-primary flex items-center gap-2">
|
|
<Users className="h-3.5 w-3.5" /> Synthèse des contributions
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Mise à jour à chaque nouvelle contribution
|
|
<span className="hidden md:inline font-mono"> · {countdown}s</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{stats && (
|
|
<div className="text-xs font-mono text-right mr-2" data-testid="text-stats">
|
|
<span className="text-muted-foreground">Intégrées </span>
|
|
<span className="font-bold text-primary">{stats.accepted}</span>
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
|
|
onClick={handleShare}
|
|
disabled={!synthesis?.text}
|
|
title="Partager ou copier la synthèse"
|
|
>
|
|
{navigator.share ? (
|
|
<><Share2 className="h-3.5 w-3.5" /> Partager</>
|
|
) : (
|
|
<><Copy className="h-3.5 w-3.5" /> Copier</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
|
|
onClick={handlePrint}
|
|
disabled={!synthesis?.text}
|
|
title="Exporter en PDF / imprimer"
|
|
>
|
|
<Printer className="h-3.5 w-3.5" /> PDF
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Avertissement éditorial — transparence sur la nature de la synthèse IA */}
|
|
<div className="px-6 md:px-10 pt-4 pb-1 relative z-10 flex-shrink-0">
|
|
<Alert className="border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40 py-3">
|
|
<AlertDescription className="text-xs text-foreground/70 leading-relaxed">
|
|
Synthèse générée par IA — peut regrouper, omettre ou reformuler des contributions.{" "}
|
|
<Link href="/contributions-brutes" className="underline text-primary">
|
|
Voir les contributions brutes
|
|
</Link>{" "}
|
|
pour vérification indépendante.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
|
|
{/* Texte défilable */}
|
|
<ScrollArea className="flex-1 relative z-10">
|
|
<div className="px-6 md:px-10 py-8">
|
|
{isLoadingSynthesis ? (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
<span className="font-mono text-xs uppercase tracking-widest">Chargement…</span>
|
|
</div>
|
|
) : synthesis?.text ? (
|
|
<div
|
|
className="animate-in fade-in slide-in-from-bottom-2 duration-700 ease-out"
|
|
data-testid="text-synthesis-content"
|
|
>
|
|
<SynthesisText text={synthesis.text} />
|
|
</div>
|
|
) : synthesis ? (
|
|
<p className="text-muted-foreground italic text-sm py-16 text-center">
|
|
Aucune contribution pour l'instant.
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
|
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
<span className="font-mono text-xs uppercase tracking-widest">Impossible de récupérer la synthèse</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Pied de page fixe */}
|
|
{synthesis?.updatedAt && (
|
|
<>
|
|
<div
|
|
className="flex justify-between items-center px-6 md:px-10 py-3 border-t border-border/30 text-xs font-mono text-muted-foreground relative z-10 flex-shrink-0"
|
|
data-testid="text-synthesis-meta"
|
|
>
|
|
<span>Basé sur {synthesis.ideaCount} contribution{synthesis.ideaCount !== 1 ? "s" : ""}</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="relative flex h-1.5 w-1.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-primary" />
|
|
</span>
|
|
{format(new Date(synthesis.updatedAt), "d MMM à HH:mm", { locale: fr })}
|
|
</span>
|
|
</div>
|
|
<div className="px-6 md:px-10 py-2 border-t border-border/20 relative z-10 flex-shrink-0">
|
|
<p className="text-[10px] text-muted-foreground/60 italic leading-relaxed">
|
|
Ce document reflète des expressions citoyennes, pas des faits vérifiés ni un consensus officiel. La démarche est portée par un auteur attaché à l'expertise et au dialogue fondé sur les preuves.
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|