Add mobile overflow nav, law check feature, and favicon

Nav: "Fonctionnement" and "Flyer QR" restored on mobile via a ···
overflow dropdown (click-outside closes); other links always visible

Law check: new POST /api/check-law endpoint (5/min, 20/h) calls
mistral-small with a new LAW_CHECK_PROMPT to detect if a proposal is
already covered by French or EU law. Informational only, non-blocking.
Frontend: "Vérifier le cadre légal existant" button below textarea in
home.tsx; result displayed with Scale icon; resets on content change.

Favicon: replaced placeholder red square with a petrol rounded square
containing a serif "V" (for Voix) in warm cream — matches brand palette

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:57:29 +02:00
parent 5c5152a387
commit 2ef14ac765
6 changed files with 179 additions and 7 deletions
+24 -1
View File
@@ -9,7 +9,7 @@ import json
import os
import logging
from openai import OpenAI, BadRequestError
from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT
from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT
logger = logging.getLogger(__name__)
@@ -107,6 +107,29 @@ def filter_idea(content: str) -> dict:
return {"accepted": False, "reason": "Service temporairement indisponible"}
def check_existing_law(content: str) -> str:
"""
Vérifie si une proposition citoyenne est déjà couverte par une loi
française ou un règlement européen en vigueur.
Retourne une phrase informative (non bloquante).
"""
try:
client = get_client()
model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest"))
response = client.chat.completions.create(
model=model,
max_tokens=200,
messages=[
{"role": "system", "content": LAW_CHECK_PROMPT},
{"role": "user", "content": f'Proposition : "{content}"'},
],
)
return (response.choices[0].message.content or "").strip()
except Exception:
logger.exception("Erreur lors de la vérification du cadre légal existant")
return ""
def synthesize_ideas(ideas: list[str]) -> str:
"""
Synthétise une liste d'idées acceptées en un texte collectif
+20 -1
View File
@@ -52,7 +52,7 @@ from database import (
get_consultation_contributions, delete_consultation,
check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist,
)
from ai_agent import filter_idea, synthesize_ideas
from ai_agent import filter_idea, synthesize_ideas, check_existing_law
# ─── Logging ────────────────────────────────────────────────────────────────
@@ -1216,6 +1216,25 @@ def admin_delete_consultation(slug: str):
return jsonify({"ok": True})
@app.post("/api/check-law")
@limiter.limit("5 per minute;20 per hour")
def check_law_route():
"""
Vérifie si une proposition est déjà couverte par une loi française ou européenne.
Endpoint public, rate-limité. Non bloquant — retourne une note informative.
"""
if not request.is_json:
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
data = request.get_json(silent=True) or {}
content = sanitize_text(str(data.get("content", ""))).strip()
if not content or len(content) < 10:
return jsonify({"error": "validation_error", "message": "Contenu trop court."}), 400
if len(content) > 1000:
return jsonify({"error": "validation_error", "message": "Contenu trop long (max 1000 caractères)."}), 400
note = check_existing_law(content)
return jsonify({"note": note})
@app.get("/api/admin/ip-blacklist")
@require_admin
def admin_list_ip_blacklist():
+21
View File
@@ -459,6 +459,27 @@ Si rejetée :
{"accepted": false, "reason": "Explication courte en français avec référence légale précise (ex: contraire à DUDH Art. 20 — incitation à la haine raciale)", "legal_basis": "DUDH Art. 20, PIDCP Art. 20"}
"""
LAW_CHECK_PROMPT = """
Tu es un assistant juridique spécialisé en droit français et en droit de l'Union européenne.
Ta mission : analyser une proposition citoyenne et déterminer si elle est déjà couverte,
en tout ou en partie, par une loi française ou un règlement européen en vigueur.
INSTRUCTIONS
- Si un texte législatif ou réglementaire couvre déjà cette proposition :
Cite le texte précis : nom complet, numéro ou date, article le cas échéant.
Résume en une phrase ce que ce texte prévoit concrètement.
Si le texte manque de décrets d'application ou n'est que partiellement appliqué, dis-le
clairement car une loi non appliquée ne rend pas la revendication caduque.
- Si la proposition n'est couverte par aucun texte en vigueur, réponds simplement :
"Aucun texte législatif en vigueur ne couvre cette proposition."
- Ne te prononce pas sur la valeur ou l'opportunité de la proposition.
- Ne réponds pas avec des textes abrogés ou non ratifiés.
TON : neutre, factuel, concis. Pas de formule introductive.
FORMAT : 2 à 4 phrases maximum. Pas de markdown, pas de tirets, pas de liste.
"""
SYNTHESIS_PROMPT = """
Tu es un outil de résumé de contributions citoyennes. Tu n'exprimes aucune opinion.
Tu restitues fidèlement ce qui a été soumis, sans l'édulcorer ni l'amplifier.
+14 -2
View File
@@ -1,3 +1,15 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="180" height="180" rx="36" fill="#FF3C00"/>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Fond arrondi couleur primaire petrol -->
<rect width="32" height="32" rx="7" fill="#29676F"/>
<!-- Lettre V stylisée centrée, police serif grasse -->
<text
x="16" y="24"
font-family="Georgia, 'Times New Roman', serif"
font-size="22"
font-weight="700"
fill="#F9F7F1"
text-anchor="middle"
dominant-baseline="auto"
letter-spacing="-1"
>V</text>
</svg>

Before

Width:  |  Height:  |  Size: 163 B

After

Width:  |  Height:  |  Size: 494 B

+48 -2
View File
@@ -30,17 +30,63 @@ const queryClient = new QueryClient({
});
function Navbar() {
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!mobileMenuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMobileMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [mobileMenuOpen]);
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">
<Link href="/" className="flex items-center gap-2 mr-4 sm:mr-6 font-serif text-lg sm:text-xl font-bold tracking-tight text-primary flex-shrink-0" data-testid="nav-home-link">
La Voix du Peuple
</Link>
<nav className="flex items-center gap-4 text-sm font-medium" aria-label="Navigation principale">
<nav className="flex items-center gap-3 sm:gap-5 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="hidden sm:inline transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-transparence-link">Fonctionnement</Link>
<Link href="/flyer" className="hidden sm:inline transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-flyer-link">Flyer QR</Link>
{/* Menu débordement mobile */}
<div className="relative sm:hidden" ref={menuRef}>
<button
onClick={() => setMobileMenuOpen(v => !v)}
aria-label="Plus de pages"
aria-expanded={mobileMenuOpen}
className="text-foreground/60 hover:text-foreground/80 transition-colors px-1 text-base leading-none"
>
···
</button>
{mobileMenuOpen && (
<div className="absolute left-0 top-7 bg-background border border-border/60 rounded-md shadow-md py-1 min-w-36 z-50">
<Link
href="/transparence"
className="block px-4 py-2 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={() => setMobileMenuOpen(false)}
data-testid="nav-transparence-link-mobile"
>
Fonctionnement
</Link>
<Link
href="/flyer"
className="block px-4 py-2 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={() => setMobileMenuOpen(false)}
data-testid="nav-flyer-link-mobile"
>
Flyer QR
</Link>
</div>
)}
</div>
</nav>
<div className="ml-auto flex items-center gap-2">
<AccessibilityPanel />
+52 -1
View File
@@ -35,7 +35,7 @@ import {
} from "@/components/ui/accordion";
import {
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
Share2, Printer, Copy, Flag,
Share2, Printer, Copy, Flag, BookOpen,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
@@ -143,6 +143,8 @@ export default function Home() {
}, [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) => {
@@ -206,6 +208,26 @@ export default function Home() {
}
};
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 });
@@ -376,6 +398,10 @@ export default function Home() {
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 />
@@ -383,6 +409,31 @@ export default function Home() {
)}
/>
{/* 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}