fbc1fad8b9
Backend : - Nouvelle table `consultations` (slug unique, fenêtre temporelle, webhook, logo) - `ideas.consultation_id` FK nullable (NULL = contexte global home) - `synthesis.consultation_id` FK nullable (synthèse par contexte) - Boucle auto-fermeture (thread daemon, 60 s) — ferme + webhook à l'échéance - Webhook de clôture : POST JSON (synthèse + métadonnées) via urllib.request - Routes publiques : GET/POST /api/consultations/<slug>, synthèse, contributions, export/print - Routes admin : list, create, close (+ webhook), delete (cascade explicite) - CSP ajustée sur /export/print pour autoriser window.print() Frontend : - Nouvelle page /consultation/:slug — formulaire, synthèse live, contributions paginées, PDF - Admin panel : onglet Consultations — liste, formulaire création, fermeture, suppression Docs : DAT.md v1.5, DEX.md v1.7 (section P5, tables, routes, webhook) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
146 lines
6.2 KiB
TypeScript
146 lines
6.2 KiB
TypeScript
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
|
import React from "react";
|
|
import { Switch, Route, Router as WouterRouter, Link, useLocation } from "wouter";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { Toaster } from "@/components/ui/toaster";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import NotFound from "@/pages/not-found";
|
|
import Home from "@/pages/home";
|
|
import About from "@/pages/about";
|
|
import Transparence from "@/pages/transparence";
|
|
import Flyer from "@/pages/flyer";
|
|
import Admin from "@/pages/admin";
|
|
import LegalNotice from "@/pages/legal-notice";
|
|
import PrivacyPolicy from "@/pages/privacy-policy";
|
|
import ContributionsBrutes from "@/pages/contributions-brutes";
|
|
import ConsultationPage from "@/pages/consultation";
|
|
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
|
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
|
import { setVisitorId } from "@workspace/api-client-react";
|
|
import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
refetchOnWindowFocus: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
function Navbar() {
|
|
return (
|
|
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<div className="container mx-auto max-w-7xl flex h-16 items-center px-4 md:px-8">
|
|
<Link href="/" className="flex items-center gap-2 mr-6 font-serif text-xl font-bold tracking-tight text-primary" data-testid="nav-home-link">
|
|
La Voix du Peuple
|
|
</Link>
|
|
<nav className="flex items-center gap-6 text-sm font-medium" aria-label="Navigation principale">
|
|
<Link href="/" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-manifesto-link">Manifeste</Link>
|
|
<Link href="/about" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-about-link">À propos</Link>
|
|
<Link href="/transparence" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-transparence-link">Fonctionnement</Link>
|
|
<Link href="/flyer" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-flyer-link">Flyer QR</Link>
|
|
</nav>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<AccessibilityPanel />
|
|
<span
|
|
title="République française"
|
|
aria-label="Drapeau français"
|
|
className="inline-flex overflow-hidden rounded-sm opacity-70"
|
|
style={{ width: 22, height: 15, boxShadow: "0 0 0 1px rgba(0,0,0,0.12)" }}
|
|
>
|
|
<span style={{ flex: 1, background: "#002395" }} />
|
|
<span style={{ flex: 1, background: "#EDEDED" }} />
|
|
<span style={{ flex: 1, background: "#ED2939" }} />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function Footer() {
|
|
return (
|
|
<footer className="border-t border-border/40 bg-background py-5">
|
|
<div className="container mx-auto max-w-7xl px-4 md:px-8">
|
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-6 items-start sm:items-center justify-between text-xs text-muted-foreground font-mono">
|
|
<span className="font-serif font-semibold text-foreground/70 text-sm">La Voix du Peuple</span>
|
|
<nav className="flex flex-wrap gap-4" aria-label="Liens légaux et transparence">
|
|
<Link href="/mentions-legales" className="hover:text-foreground transition-colors">
|
|
Mentions légales
|
|
</Link>
|
|
<Link href="/politique-confidentialite" className="hover:text-foreground transition-colors">
|
|
Confidentialité
|
|
</Link>
|
|
<Link href="/contributions-brutes" className="hover:text-foreground transition-colors">
|
|
Contributions brutes
|
|
</Link>
|
|
</nav>
|
|
<span className="opacity-50">EUPL-1.2 · billisdead</span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
);
|
|
}
|
|
|
|
function Router() {
|
|
const [location] = useLocation();
|
|
return (
|
|
<div className="min-h-screen flex flex-col font-sans">
|
|
{/* Lien d'évitement pour lecteurs d'écran */}
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-primary focus:text-primary-foreground focus:px-4 focus:py-2 focus:rounded-sm focus:text-sm focus:font-semibold"
|
|
>
|
|
Aller au contenu principal
|
|
</a>
|
|
<Navbar />
|
|
<main id="main-content" className="flex-1 flex flex-col" tabIndex={-1}>
|
|
<Switch>
|
|
<Route path="/" component={Home} />
|
|
<Route path="/about" component={About} />
|
|
<Route path="/transparence" component={Transparence} />
|
|
<Route path="/flyer" component={Flyer} />
|
|
<Route path="/admin" component={Admin} />
|
|
<Route path="/mentions-legales" component={LegalNotice} />
|
|
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
|
|
<Route path="/contributions-brutes" component={ContributionsBrutes} />
|
|
<Route path="/consultation/:slug" component={ConsultationPage} />
|
|
<Route component={NotFound} />
|
|
</Switch>
|
|
</main>
|
|
{/* Pied de page masqué sur la page d'accueil (layout plein-écran) */}
|
|
{location !== "/" && <Footer />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
// Initialise FingerprintJS une seule fois au chargement
|
|
// L'identifiant de visite est envoyé sur chaque appel API (header X-Visitor-Id)
|
|
// Il est hashé côté serveur avant stockage — aucune donnée PII conservée
|
|
React.useEffect(() => {
|
|
FingerprintJS.load()
|
|
.then((fp) => fp.get())
|
|
.then((result) => setVisitorId(result.visitorId))
|
|
.catch(() => {
|
|
// Dégradation silencieuse si FingerprintJS indisponible
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<AccessibilityProvider>
|
|
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
|
<Router />
|
|
</WouterRouter>
|
|
<Toaster />
|
|
</AccessibilityProvider>
|
|
</TooltipProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
export default App;
|