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:
@@ -9,7 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from openai import OpenAI, BadRequestError
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,6 +107,29 @@ def filter_idea(content: str) -> dict:
|
|||||||
return {"accepted": False, "reason": "Service temporairement indisponible"}
|
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:
|
def synthesize_ideas(ideas: list[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Synthétise une liste d'idées acceptées en un texte collectif
|
Synthétise une liste d'idées acceptées en un texte collectif
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ from database import (
|
|||||||
get_consultation_contributions, delete_consultation,
|
get_consultation_contributions, delete_consultation,
|
||||||
check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist,
|
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 ────────────────────────────────────────────────────────────────
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1216,6 +1216,25 @@ def admin_delete_consultation(slug: str):
|
|||||||
return jsonify({"ok": True})
|
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")
|
@app.get("/api/admin/ip-blacklist")
|
||||||
@require_admin
|
@require_admin
|
||||||
def admin_list_ip_blacklist():
|
def admin_list_ip_blacklist():
|
||||||
|
|||||||
@@ -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"}
|
{"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 = """
|
SYNTHESIS_PROMPT = """
|
||||||
Tu es un outil de résumé de contributions citoyennes. Tu n'exprimes aucune opinion.
|
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.
|
Tu restitues fidèlement ce qui a été soumis, sans l'édulcorer ni l'amplifier.
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="180" height="180" rx="36" fill="#FF3C00"/>
|
<!-- 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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 163 B After Width: | Height: | Size: 494 B |
@@ -30,17 +30,63 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Navbar() {
|
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 (
|
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">
|
<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">
|
<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
|
La Voix du Peuple
|
||||||
</Link>
|
</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="/" 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="/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="/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>
|
<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>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<AccessibilityPanel />
|
<AccessibilityPanel />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import {
|
import {
|
||||||
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
|
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
|
||||||
Share2, Printer, Copy, Flag,
|
Share2, Printer, Copy, Flag, BookOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
@@ -143,6 +143,8 @@ export default function Home() {
|
|||||||
}, [countdown]);
|
}, [countdown]);
|
||||||
|
|
||||||
const [mobileTab, setMobileTab] = React.useState<"proposer" | "synthese">("proposer");
|
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)
|
// Soumission effective après confirmation du consentement (ou si déjà consenti)
|
||||||
const doActualSubmit = (data: SubmitIdeaValues) => {
|
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 = () => {
|
const handleShare = () => {
|
||||||
if (!synthesis?.text) return;
|
if (!synthesis?.text) return;
|
||||||
const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr });
|
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"
|
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"
|
data-testid="input-idea-content"
|
||||||
{...field}
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
if (lawCheckResult) setLawCheckResult(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<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">
|
<div className="flex items-start gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
Reference in New Issue
Block a user