Suggestions : .env.example complet, page /consultations, fix set-domain.sh

- .env.example : variables à jour (Mistral, SECRET_KEY, ADMIN_SECRET, Redis,
  hCaptcha, anti-abus) — l'ancienne version référençait encore OpenAI uniquement
- Nouveau set-domain.sh : supprime la référence à vite.config.selfhost.ts supprimé
- Nouvelle page /consultations : index public des consultations actives/clôturées,
  toggle "afficher les clôturées", lien dans le footer
- App.tsx : route /consultations + lien footer Consultations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 10:02:00 +02:00
parent fbc1fad8b9
commit cf81ffa35e
4 changed files with 190 additions and 13 deletions
+5
View File
@@ -14,6 +14,7 @@ import LegalNotice from "@/pages/legal-notice";
import PrivacyPolicy from "@/pages/privacy-policy";
import ContributionsBrutes from "@/pages/contributions-brutes";
import ConsultationPage from "@/pages/consultation";
import ConsultationsList from "@/pages/consultations-list";
import { AccessibilityProvider } from "@/hooks/use-accessibility";
import { AccessibilityPanel } from "@/components/accessibility-panel";
import { setVisitorId } from "@workspace/api-client-react";
@@ -75,6 +76,9 @@ function Footer() {
<Link href="/contributions-brutes" className="hover:text-foreground transition-colors">
Contributions brutes
</Link>
<Link href="/consultations" className="hover:text-foreground transition-colors">
Consultations
</Link>
</nav>
<span className="opacity-50">EUPL-1.2 · billisdead</span>
</div>
@@ -105,6 +109,7 @@ function Router() {
<Route path="/mentions-legales" component={LegalNotice} />
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
<Route path="/contributions-brutes" component={ContributionsBrutes} />
<Route path="/consultations" component={ConsultationsList} />
<Route path="/consultation/:slug" component={ConsultationPage} />
<Route component={NotFound} />
</Switch>
@@ -0,0 +1,147 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
// Page d'index des consultations publiques actives
import { useState, useEffect } from "react";
import { Link } from "wouter";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Clock, Users, Building2, ArrowRight, Loader2, Lock, CalendarDays,
} from "lucide-react";
import { format, formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
const API_BASE = import.meta.env.VITE_API_URL ?? "";
type Consultation = {
id: number;
slug: string;
title: string;
subject: string;
organizerName: string | null;
organizerLogoUrl: string | null;
startsAt: string | null;
endsAt: string | null;
closedAt: string | null;
isOpen: boolean;
stats: { total: number; accepted: number };
};
export default function ConsultationsList() {
const [consultations, setConsultations] = useState<Consultation[]>([]);
const [loading, setLoading] = useState(true);
const [showClosed, setShowClosed] = useState(false);
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/api/consultations?include_closed=${showClosed}`)
.then((r) => r.json())
.then(setConsultations)
.catch(() => setConsultations([]))
.finally(() => setLoading(false));
}, [showClosed]);
return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-8">
<div className="space-y-2">
<h1 className="font-serif text-3xl font-bold text-primary">Consultations citoyennes</h1>
<p className="text-muted-foreground text-sm leading-relaxed max-w-xl">
Chaque consultation est un espace thématique ciblé, ouvert par un organisateur
(collectivité, association, collectif). Contribuez aux consultations ouvertes,
consultez les synthèses des consultations clôturées.
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{loading ? "Chargement…" : `${consultations.length} consultation${consultations.length !== 1 ? "s" : ""}`}
</span>
<Button
size="sm" variant="outline"
onClick={() => setShowClosed((v) => !v)}
className="text-xs"
>
{showClosed ? "Masquer les clôturées" : "Afficher les clôturées"}
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground gap-2">
<Loader2 className="h-5 w-5 animate-spin" /> Chargement
</div>
) : consultations.length === 0 ? (
<div className="text-center py-20 text-muted-foreground space-y-3">
<CalendarDays className="h-10 w-10 mx-auto opacity-30" />
<p className="text-sm">Aucune consultation {showClosed ? "" : "active "}pour le moment.</p>
</div>
) : (
<div className="space-y-4">
{consultations.map((c) => (
<Link key={c.id} href={`/consultation/${c.slug}`}>
<div className="group rounded-xl border border-border bg-card hover:border-primary/40 hover:bg-primary/5 transition-all p-5 cursor-pointer space-y-3">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-serif font-semibold text-lg text-foreground group-hover:text-primary transition-colors leading-tight">
{c.title}
</span>
{c.closedAt ? (
<Badge variant="secondary" className="text-xs gap-1">
<Lock className="h-2.5 w-2.5" /> Clôturée
</Badge>
) : c.isOpen ? (
<Badge className="bg-green-600 text-xs gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-white/80 animate-pulse inline-block" />
En cours
</Badge>
) : (
<Badge variant="outline" className="text-xs gap-1">
<Clock className="h-2.5 w-2.5" /> À venir
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-2">{c.subject}</p>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground/40 group-hover:text-primary/60 flex-shrink-0 mt-1 transition-colors" />
</div>
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
{c.organizerName && (
<span className="flex items-center gap-1">
<Building2 className="h-3 w-3" /> {c.organizerName}
</span>
)}
{c.endsAt && !c.closedAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{c.isOpen
? `Ferme ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}`
: `Ouvre ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}`}
</span>
)}
{c.closedAt && (
<span className="flex items-center gap-1">
<Lock className="h-3 w-3" />
Clôturée le {format(new Date(c.closedAt), "d MMM yyyy", { locale: fr })}
</span>
)}
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{c.stats.accepted} contribution{c.stats.accepted !== 1 ? "s" : ""}
</span>
</div>
</div>
</Link>
))}
</div>
)}
<div className="pt-4 text-center">
<Link href="/">
<Button variant="ghost" size="sm" className="text-muted-foreground">
Retour à la plateforme principale
</Button>
</Link>
</div>
</div>
);
}