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:
+36
-12
@@ -1,22 +1,46 @@
|
||||
# La Voix du Peuple — Variables d'environnement
|
||||
# Copiez ce fichier en .env et remplissez les valeurs.
|
||||
# Sécurisez le fichier : chmod 600 .env
|
||||
|
||||
# ─── Base de données PostgreSQL ──────────────────────────────────────────────
|
||||
# Format : postgresql://utilisateur:motdepasse@hote:port/nomdb
|
||||
DATABASE_URL=postgresql://voixdupeuple:CHANGEME@localhost:5432/voixdupeuple
|
||||
|
||||
# ─── Clé API OpenAI ──────────────────────────────────────────────────────────
|
||||
# Obtenez votre clé sur https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=sk-...
|
||||
# ─── IA — Mistral (recommandé — souveraineté européenne) ─────────────────────
|
||||
# Obtenez votre clé sur https://console.mistral.ai
|
||||
MISTRAL_API_KEY=sk-...
|
||||
|
||||
# ─── (Optionnel) Proxy OpenAI compatible ─────────────────────────────────────
|
||||
# Décommentez si vous utilisez un proxy (Ollama avec OpenAI compat, Azure, etc.)
|
||||
# OPENAI_BASE_URL=https://votre-proxy.example.com/v1
|
||||
# ─── IA — Alternative OpenAI-compatible (si Mistral non disponible) ──────────
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# OPENAI_BASE_URL=https://votre-proxy.example.com/v1 # Optionnel : proxy/Ollama
|
||||
|
||||
# ─── Modèles IA (optionnel — valeurs par défaut recommandées) ────────────────
|
||||
# OPENAI_FILTER_MODEL=gpt-4o-mini # Modèle de filtrage (rapide, économique)
|
||||
# OPENAI_SYNTHESIS_MODEL=gpt-4o # Modèle de synthèse (haute qualité)
|
||||
# ─── Modèles IA (optionnel — valeurs par défaut ci-dessous) ──────────────────
|
||||
# FILTER_MODEL=mistral-small-latest # Modèle de filtrage (rapide, économique)
|
||||
# SYNTHESIS_MODEL=mistral-large-latest # Modèle de synthèse (haute qualité)
|
||||
|
||||
# ─── Sécurité Flask ───────────────────────────────────────────────────────────
|
||||
# Générez avec : python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=CHANGEZ_CE_SECRET_AVEC_UNE_VALEUR_ALEATOIRE_LONGUE
|
||||
|
||||
# ─── Panel d'administration ───────────────────────────────────────────────────
|
||||
# Mot de passe pour accéder à /admin
|
||||
ADMIN_SECRET=CHANGEZ_CE_MOT_DE_PASSE_ADMIN
|
||||
|
||||
# ─── Flask ────────────────────────────────────────────────────────────────────
|
||||
FLASK_ENV=production
|
||||
PORT=8000
|
||||
PORT=8080
|
||||
|
||||
# ─── Session (générez avec: python3 -c "import secrets; print(secrets.token_hex(32))") ───
|
||||
SESSION_SECRET=CHANGEZ_CE_SECRET_AVEC_UNE_VALEUR_ALEATOIRE_LONGUE
|
||||
# ─── Anti-abus (optionnel — valeurs par défaut raisonnables) ─────────────────
|
||||
# REDIS_URL=redis://localhost:6379/0 # Rate limiting persistant (recommandé en prod)
|
||||
# RATE_LIMIT_CONTRIBUTIONS=5 per minute;3 per hour # Limite de soumissions
|
||||
# CONTRIBUTION_COOLDOWN_SECONDS=3600 # Délai entre deux soumissions (même session)
|
||||
# FLOOD_THRESHOLD=10 # Alerte flood : nb soumissions / 5 min / IP
|
||||
|
||||
# ─── hCaptcha (optionnel — recommandé en production) ─────────────────────────
|
||||
# Créez un compte sur https://www.hcaptcha.com (RGPD-friendly)
|
||||
# HCAPTCHA_SECRET_KEY=votre-cle-secrete-hcaptcha
|
||||
# VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site-hcaptcha # Nécessite rebuild frontend
|
||||
|
||||
# ─── Frontend ─────────────────────────────────────────────────────────────────
|
||||
# URL publique du site (utilisée par le QR code et les exports)
|
||||
VITE_APP_URL=https://votredomaine.fr
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,8 @@ export VITE_APP_URL="${DOMAIN}"
|
||||
|
||||
echo ""
|
||||
echo "Reconstruction du frontend..."
|
||||
pnpm --filter @workspace/voix-du-peuple run build --config vite.config.selfhost.ts
|
||||
cd artifacts/voix-du-peuple
|
||||
pnpm run build
|
||||
|
||||
echo ""
|
||||
echo "Terminé. Le QR code pointe maintenant vers : ${DOMAIN}"
|
||||
|
||||
Reference in New Issue
Block a user