Add secure admin panel for content moderation and contribution flagging
Adds an admin interface with authentication for manual content deletion and flagging. Implements a flagging system for user contributions and secures the admin panel with a secret token. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 7e5834b1-796d-4a9e-bbde-cd91012292de Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/nghZcOj Replit-Helium-Checkpoint-Created: true
This commit is contained in:
+191
-4
@@ -2,7 +2,8 @@
|
|||||||
La Voix du Peuple — Backend Flask
|
La Voix du Peuple — Backend Flask
|
||||||
==================================
|
==================================
|
||||||
Plateforme démocratique citoyenne.
|
Plateforme démocratique citoyenne.
|
||||||
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000)
|
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000),
|
||||||
|
Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024.
|
||||||
|
|
||||||
Sécurité :
|
Sécurité :
|
||||||
- Rate limiting (flask-limiter)
|
- Rate limiting (flask-limiter)
|
||||||
@@ -10,21 +11,28 @@ Sécurité :
|
|||||||
- CORS restreint
|
- CORS restreint
|
||||||
- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
|
- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
|
||||||
- Protection contre l'injection via requêtes paramétrées (psycopg2)
|
- Protection contre l'injection via requêtes paramétrées (psycopg2)
|
||||||
|
- Panel admin protégé par ADMIN_SECRET (Bearer token)
|
||||||
- Aucun secret exposé dans les réponses d'erreur
|
- Aucun secret exposé dans les réponses d'erreur
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from functools import wraps
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
from flask import Flask, jsonify, request, g
|
from flask import Flask, jsonify, request, Response
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
|
|
||||||
from database import init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, get_synthesis, get_all_ideas
|
from database import (
|
||||||
|
init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
|
||||||
|
get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas,
|
||||||
|
override_idea, flag_idea, unflag_idea,
|
||||||
|
)
|
||||||
from ai_agent import filter_idea, synthesize_ideas
|
from ai_agent import filter_idea, synthesize_ideas
|
||||||
|
|
||||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
@@ -106,6 +114,23 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
|
|||||||
|
|
||||||
return {"content": content, "author": author}, None
|
return {"content": content, "author": author}, None
|
||||||
|
|
||||||
|
# ─── Authentification Admin ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_admin_secret() -> str | None:
|
||||||
|
return os.environ.get("ADMIN_SECRET")
|
||||||
|
|
||||||
|
def require_admin(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
secret = _get_admin_secret()
|
||||||
|
if not secret:
|
||||||
|
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer ") or auth[7:] != secret:
|
||||||
|
return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
# ─── Gestion des erreurs ─────────────────────────────────────────────────────
|
# ─── Gestion des erreurs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
@@ -220,6 +245,160 @@ def get_synthesis_route():
|
|||||||
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ─── Route publique : signalement ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/api/ideas/<int:idea_id>/flag")
|
||||||
|
@limiter.limit("3 per minute; 10 per hour")
|
||||||
|
def flag_idea_route(idea_id: int):
|
||||||
|
"""Signale une contribution pour examen par l'administrateur."""
|
||||||
|
result = flag_idea(idea_id)
|
||||||
|
if not result:
|
||||||
|
return jsonify({"error": "not_found", "message": "Contribution introuvable ou non publiée."}), 404
|
||||||
|
return jsonify({"ok": True, "flagCount": result.get("flag_count", 1)})
|
||||||
|
|
||||||
|
# ─── Routes Admin ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/api/admin/login")
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def admin_login():
|
||||||
|
"""Vérifie le mot de passe admin. Retourne ok si correct."""
|
||||||
|
secret = _get_admin_secret()
|
||||||
|
if not secret:
|
||||||
|
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
password = data.get("password", "")
|
||||||
|
if password != secret:
|
||||||
|
logger.warning("Tentative de connexion admin échouée")
|
||||||
|
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
|
||||||
|
logger.info("Connexion admin réussie")
|
||||||
|
return jsonify({"ok": True, "token": secret})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/stats")
|
||||||
|
@require_admin
|
||||||
|
def admin_stats():
|
||||||
|
"""Statistiques détaillées pour l'administrateur."""
|
||||||
|
stats = get_stats()
|
||||||
|
return jsonify(stats)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/ideas")
|
||||||
|
@require_admin
|
||||||
|
@limiter.limit("120 per minute")
|
||||||
|
def admin_list_ideas():
|
||||||
|
"""Liste toutes les contributions avec filtres et pagination."""
|
||||||
|
status = request.args.get("status", "all")
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = min(100, max(10, int(request.args.get("per_page", 50))))
|
||||||
|
search = request.args.get("q", "").strip()
|
||||||
|
|
||||||
|
if status not in ("all", "accepted", "rejected", "flagged"):
|
||||||
|
status = "all"
|
||||||
|
|
||||||
|
ideas, total = get_ideas_admin(status=status, page=page, per_page=per_page, search=search)
|
||||||
|
return jsonify({
|
||||||
|
"ideas": [serialize_idea_admin(i) for i in ideas],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"perPage": per_page,
|
||||||
|
"pages": max(1, -(-total // per_page)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/ideas/<int:idea_id>")
|
||||||
|
@require_admin
|
||||||
|
def admin_delete_idea(idea_id: int):
|
||||||
|
"""Supprime une contribution et régénère la synthèse."""
|
||||||
|
deleted = delete_idea(idea_id)
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
|
||||||
|
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||||||
|
logger.info("Admin — contribution #%d supprimée", idea_id)
|
||||||
|
return jsonify({"ok": True, "synthesisUpdating": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/ideas/bulk-delete")
|
||||||
|
@require_admin
|
||||||
|
def admin_bulk_delete():
|
||||||
|
"""Suppression en masse de contributions."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
ids = data.get("ids", [])
|
||||||
|
if not isinstance(ids, list) or not ids:
|
||||||
|
return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400
|
||||||
|
ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()]
|
||||||
|
count = bulk_delete_ideas(ids)
|
||||||
|
if count > 0:
|
||||||
|
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||||||
|
logger.info("Admin — %d contribution(s) supprimée(s) en masse", count)
|
||||||
|
return jsonify({"ok": True, "deleted": count, "synthesisUpdating": count > 0})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/ideas/<int:idea_id>/override")
|
||||||
|
@require_admin
|
||||||
|
def admin_override_idea(idea_id: int):
|
||||||
|
"""Modifie manuellement le statut d'une contribution (accepter/rejeter)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
accepted = bool(data.get("accepted", False))
|
||||||
|
reason = sanitize_text(data.get("reason", "") or "")
|
||||||
|
note = sanitize_text(data.get("note", "") or "")
|
||||||
|
result = override_idea(idea_id, accepted, reason or None, note or None)
|
||||||
|
if not result:
|
||||||
|
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
|
||||||
|
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||||||
|
logger.info("Admin — contribution #%d override → accepted=%s", idea_id, accepted)
|
||||||
|
return jsonify({"ok": True, "idea": serialize_idea_admin(result), "synthesisUpdating": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/ideas/<int:idea_id>/unflag")
|
||||||
|
@require_admin
|
||||||
|
def admin_unflag_idea(idea_id: int):
|
||||||
|
"""Retire le signalement d'une contribution."""
|
||||||
|
result = unflag_idea(idea_id)
|
||||||
|
if not result:
|
||||||
|
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
|
||||||
|
return jsonify({"ok": True, "idea": serialize_idea_admin(result)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/synthesis/regenerate")
|
||||||
|
@require_admin
|
||||||
|
@limiter.limit("5 per minute")
|
||||||
|
def admin_regenerate_synthesis():
|
||||||
|
"""Force la régénération complète de la synthèse."""
|
||||||
|
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||||||
|
logger.info("Admin — régénération manuelle de la synthèse déclenchée")
|
||||||
|
return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/export/csv")
|
||||||
|
@require_admin
|
||||||
|
def admin_export_csv():
|
||||||
|
"""Exporte toutes les contributions en CSV."""
|
||||||
|
ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000)
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||||
|
writer.writerow(["id", "content", "author", "accepted", "flagged",
|
||||||
|
"flag_count", "rejection_reason", "legal_basis",
|
||||||
|
"admin_note", "created_at"])
|
||||||
|
for idea in ideas:
|
||||||
|
writer.writerow([
|
||||||
|
idea.get("id"),
|
||||||
|
idea.get("content", ""),
|
||||||
|
idea.get("author", ""),
|
||||||
|
"oui" if idea.get("accepted") else "non",
|
||||||
|
"oui" if idea.get("flagged") else "non",
|
||||||
|
idea.get("flag_count", 0),
|
||||||
|
idea.get("rejection_reason", ""),
|
||||||
|
idea.get("legal_basis", ""),
|
||||||
|
idea.get("admin_note", ""),
|
||||||
|
idea.get("created_at").isoformat() if idea.get("created_at") else "",
|
||||||
|
])
|
||||||
|
csv_bytes = output.getvalue().encode("utf-8-sig")
|
||||||
|
return Response(
|
||||||
|
csv_bytes,
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def serialize_idea(idea: dict) -> dict:
|
def serialize_idea(idea: dict) -> dict:
|
||||||
@@ -228,12 +407,20 @@ def serialize_idea(idea: dict) -> dict:
|
|||||||
"content": idea["content"],
|
"content": idea["content"],
|
||||||
"author": idea.get("author"),
|
"author": idea.get("author"),
|
||||||
"accepted": idea["accepted"],
|
"accepted": idea["accepted"],
|
||||||
|
"flagged": idea.get("flagged", False),
|
||||||
|
"flagCount": idea.get("flag_count", 0),
|
||||||
"rejectionReason": idea.get("rejection_reason"),
|
"rejectionReason": idea.get("rejection_reason"),
|
||||||
"legalBasis": idea.get("legal_basis"),
|
"legalBasis": idea.get("legal_basis"),
|
||||||
"createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None,
|
"createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_idea_admin(idea: dict) -> dict:
|
||||||
|
base = serialize_idea(idea)
|
||||||
|
base["adminNote"] = idea.get("admin_note")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
def _update_synthesis_background():
|
def _update_synthesis_background():
|
||||||
try:
|
try:
|
||||||
ideas = get_accepted_ideas()
|
ideas = get_accepted_ideas()
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ def init_db():
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
cur.execute("""
|
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT")
|
||||||
ALTER TABLE ideas
|
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
ADD COLUMN IF NOT EXISTS legal_basis TEXT
|
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0")
|
||||||
""")
|
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT")
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS synthesis (
|
CREATE TABLE IF NOT EXISTS synthesis (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -91,6 +91,97 @@ def get_all_ideas(limit: int = 50) -> list[dict]:
|
|||||||
return [dict(row) for row in cur.fetchall()]
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ideas_admin(status: str = "all", page: int = 1,
|
||||||
|
per_page: int = 50, search: str = "") -> tuple[list[dict], int]:
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
conditions = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if status == "accepted":
|
||||||
|
conditions.append("accepted = TRUE")
|
||||||
|
elif status == "rejected":
|
||||||
|
conditions.append("accepted = FALSE AND flagged = FALSE")
|
||||||
|
elif status == "flagged":
|
||||||
|
conditions.append("flagged = TRUE")
|
||||||
|
|
||||||
|
if search:
|
||||||
|
conditions.append("content ILIKE %s")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
|
||||||
|
with db_cursor() as cur:
|
||||||
|
cur.execute(f"SELECT COUNT(*) as total FROM ideas {where}", params)
|
||||||
|
total = cur.fetchone()["total"]
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT * FROM ideas {where} ORDER BY flagged DESC, created_at DESC LIMIT %s OFFSET %s",
|
||||||
|
params + [per_page, offset],
|
||||||
|
)
|
||||||
|
rows = [dict(row) for row in cur.fetchall()]
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
def delete_idea(idea_id: int) -> bool:
|
||||||
|
with db_cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,))
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_delete_ideas(idea_ids: list[int]) -> int:
|
||||||
|
if not idea_ids:
|
||||||
|
return 0
|
||||||
|
with db_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM ideas WHERE id = ANY(%s) RETURNING id",
|
||||||
|
(idea_ids,),
|
||||||
|
)
|
||||||
|
return len(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def override_idea(idea_id: int, accepted: bool,
|
||||||
|
reason: str | None, note: str | None) -> dict | None:
|
||||||
|
with db_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ideas
|
||||||
|
SET accepted = %s,
|
||||||
|
rejection_reason = %s,
|
||||||
|
flagged = FALSE,
|
||||||
|
admin_note = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(accepted, reason, note, idea_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def flag_idea(idea_id: int) -> dict | None:
|
||||||
|
with db_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ideas
|
||||||
|
SET flagged = TRUE, flag_count = flag_count + 1
|
||||||
|
WHERE id = %s AND accepted = TRUE
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(idea_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def unflag_idea(idea_id: int) -> dict | None:
|
||||||
|
with db_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE ideas SET flagged = FALSE WHERE id = %s RETURNING *",
|
||||||
|
(idea_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_stats() -> dict:
|
def get_stats() -> dict:
|
||||||
with db_cursor() as cur:
|
with db_cursor() as cur:
|
||||||
cur.execute("SELECT COUNT(*) as total FROM ideas")
|
cur.execute("SELECT COUNT(*) as total FROM ideas")
|
||||||
@@ -99,7 +190,9 @@ def get_stats() -> dict:
|
|||||||
accepted = cur.fetchone()["accepted"]
|
accepted = cur.fetchone()["accepted"]
|
||||||
cur.execute("SELECT COUNT(*) as rejected FROM ideas WHERE accepted = FALSE")
|
cur.execute("SELECT COUNT(*) as rejected FROM ideas WHERE accepted = FALSE")
|
||||||
rejected = cur.fetchone()["rejected"]
|
rejected = cur.fetchone()["rejected"]
|
||||||
return {"total": total, "accepted": accepted, "rejected": rejected}
|
cur.execute("SELECT COUNT(*) as flagged FROM ideas WHERE flagged = TRUE")
|
||||||
|
flagged = cur.fetchone()["flagged"]
|
||||||
|
return {"total": total, "accepted": accepted, "rejected": rejected, "flagged": flagged}
|
||||||
|
|
||||||
|
|
||||||
def upsert_synthesis(text: str, idea_count: int) -> dict:
|
def upsert_synthesis(text: str, idea_count: int) -> dict:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Home from "@/pages/home";
|
|||||||
import About from "@/pages/about";
|
import About from "@/pages/about";
|
||||||
import Transparence from "@/pages/transparence";
|
import Transparence from "@/pages/transparence";
|
||||||
import Flyer from "@/pages/flyer";
|
import Flyer from "@/pages/flyer";
|
||||||
|
import Admin from "@/pages/admin";
|
||||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ function Router() {
|
|||||||
<Route path="/about" component={About} />
|
<Route path="/about" component={About} />
|
||||||
<Route path="/transparence" component={Transparence} />
|
<Route path="/transparence" component={Transparence} />
|
||||||
<Route path="/flyer" component={Flyer} />
|
<Route path="/flyer" component={Flyer} />
|
||||||
|
<Route path="/admin" component={Admin} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Trash2, RefreshCw, Download, LogOut, Check, X, Flag,
|
||||||
|
ChevronLeft, ChevronRight, Search, ShieldCheck, Eye, Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||||
|
|
||||||
|
type Idea = {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
author: string | null;
|
||||||
|
accepted: boolean;
|
||||||
|
flagged: boolean;
|
||||||
|
flagCount: number;
|
||||||
|
rejectionReason: string | null;
|
||||||
|
legalBasis: string | null;
|
||||||
|
adminNote: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Stats = {
|
||||||
|
total: number;
|
||||||
|
accepted: number;
|
||||||
|
rejected: number;
|
||||||
|
flagged: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IdeaList = {
|
||||||
|
ideas: Idea[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useAdminAuth() {
|
||||||
|
const [token, setToken] = useState<string | null>(() =>
|
||||||
|
sessionStorage.getItem("admin_token")
|
||||||
|
);
|
||||||
|
const login = (t: string) => {
|
||||||
|
sessionStorage.setItem("admin_token", t);
|
||||||
|
setToken(t);
|
||||||
|
};
|
||||||
|
const logout = () => {
|
||||||
|
sessionStorage.removeItem("admin_token");
|
||||||
|
setToken(null);
|
||||||
|
};
|
||||||
|
const headers = token
|
||||||
|
? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
||||||
|
: { "Content-Type": "application/json" };
|
||||||
|
return { token, login, logout, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPanel({ onLogin }: { onLogin: (t: string) => void }) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.message || "Mot de passe incorrect.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onLogin(data.token);
|
||||||
|
toast({ title: "Connecté", description: "Bienvenue dans le panel admin." });
|
||||||
|
} catch {
|
||||||
|
setError("Erreur réseau. Vérifiez que l'API est accessible.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/30">
|
||||||
|
<div className="w-full max-w-sm space-y-6 p-8 bg-background border border-border rounded-xl shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif text-xl font-bold text-primary">Panel Admin</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">La Voix du Peuple</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">Mot de passe</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="ADMIN_SECRET"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Connexion
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsBadge({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border px-4 py-3 text-center ${color}`}>
|
||||||
|
<div className="text-2xl font-bold font-mono">{value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const { token, login, logout, headers } = useAdminAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [list, setList] = useState<IdeaList | null>(null);
|
||||||
|
const [status, setStatus] = useState("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
const [overrideTarget, setOverrideTarget] = useState<Idea | null>(null);
|
||||||
|
const [overrideAccepted, setOverrideAccepted] = useState(false);
|
||||||
|
const [overrideReason, setOverrideReason] = useState("");
|
||||||
|
const [overrideNote, setOverrideNote] = useState("");
|
||||||
|
const [regenLoading, setRegenLoading] = useState(false);
|
||||||
|
const [flaggedIds, setFlaggedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/stats`, { headers });
|
||||||
|
if (res.ok) setStats(await res.json());
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [token, headers]);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
status,
|
||||||
|
page: String(page),
|
||||||
|
per_page: "50",
|
||||||
|
...(search ? { q: search } : {}),
|
||||||
|
});
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ideas?${params}`, { headers });
|
||||||
|
if (res.ok) {
|
||||||
|
const data: IdeaList = await res.json();
|
||||||
|
setList(data);
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [token, headers, status, page, search]);
|
||||||
|
|
||||||
|
useEffect(() => { if (token) { fetchStats(); fetchList(); } }, [token, fetchStats, fetchList]);
|
||||||
|
|
||||||
|
const deleteOne = async (id: number) => {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}`, { method: "DELETE", headers });
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: "Contribution supprimée", description: "La synthèse est en cours de mise à jour." });
|
||||||
|
fetchStats(); fetchList();
|
||||||
|
} else {
|
||||||
|
toast({ title: "Erreur", description: "Suppression échouée.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkDelete = async () => {
|
||||||
|
const ids = Array.from(selected);
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ideas/bulk-delete`, {
|
||||||
|
method: "POST", headers, body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast({ title: `${data.deleted} contribution(s) supprimée(s)`, description: "Synthèse en cours de mise à jour." });
|
||||||
|
fetchStats(); fetchList();
|
||||||
|
} else {
|
||||||
|
toast({ title: "Erreur", description: "Suppression en masse échouée.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
setBulkDeleteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const override = async () => {
|
||||||
|
if (!overrideTarget) return;
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ideas/${overrideTarget.id}/override`, {
|
||||||
|
method: "POST", headers,
|
||||||
|
body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: "Statut modifié", description: `Contribution ${overrideAccepted ? "acceptée" : "rejetée"} manuellement.` });
|
||||||
|
fetchStats(); fetchList();
|
||||||
|
} else {
|
||||||
|
toast({ title: "Erreur", description: "Modification échouée.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
setOverrideTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unflag = async (id: number) => {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}/unflag`, { method: "POST", headers });
|
||||||
|
if (res.ok) {
|
||||||
|
setFlaggedIds((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
|
toast({ title: "Signalement retiré" });
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const regenerate = async () => {
|
||||||
|
setRegenLoading(true);
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/synthesis/regenerate`, { method: "POST", headers });
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: "Régénération lancée", description: "La synthèse sera mise à jour dans quelques secondes." });
|
||||||
|
}
|
||||||
|
setRegenLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const url = `${API_BASE}/api/admin/export/csv`;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.setAttribute("download", "contributions.csv");
|
||||||
|
const req = new XMLHttpRequest();
|
||||||
|
req.open("GET", url);
|
||||||
|
req.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||||
|
req.responseType = "blob";
|
||||||
|
req.onload = () => {
|
||||||
|
const blob = req.response;
|
||||||
|
const objUrl = URL.createObjectURL(blob);
|
||||||
|
a.href = objUrl;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(objUrl);
|
||||||
|
};
|
||||||
|
req.send();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (!list) return;
|
||||||
|
if (selected.size === list.ideas.length) {
|
||||||
|
setSelected(new Set());
|
||||||
|
} else {
|
||||||
|
setSelected(new Set(list.ideas.map((i) => i.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openOverride = (idea: Idea) => {
|
||||||
|
setOverrideTarget(idea);
|
||||||
|
setOverrideAccepted(!idea.accepted);
|
||||||
|
setOverrideReason(idea.rejectionReason || "");
|
||||||
|
setOverrideNote(idea.adminNote || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) return <LoginPanel onLogin={login} />;
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "all", label: "Toutes" },
|
||||||
|
{ key: "accepted", label: "Acceptées" },
|
||||||
|
{ key: "rejected", label: "Rejetées" },
|
||||||
|
{ key: "flagged", label: "Signalées" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/20">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-40 bg-background border-b border-border/60 px-6 py-3 flex items-center gap-4">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0" />
|
||||||
|
<span className="font-serif font-bold text-primary">Administration</span>
|
||||||
|
<span className="text-muted-foreground text-sm hidden sm:inline">La Voix du Peuple</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={regenerate} disabled={regenLoading}>
|
||||||
|
{regenLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||||
|
<span className="hidden sm:inline ml-1.5">Régénérer synthèse</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={exportCsv}>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline ml-1.5">CSV</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={logout}>
|
||||||
|
<LogOut className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline ml-1.5">Déconnexion</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<StatsBadge label="Total" value={stats.total} color="border-border bg-background" />
|
||||||
|
<StatsBadge label="Acceptées" value={stats.accepted} color="border-green-200 bg-green-50 text-green-800" />
|
||||||
|
<StatsBadge label="Rejetées" value={stats.rejected} color="border-red-200 bg-red-50 text-red-800" />
|
||||||
|
<StatsBadge label="Signalées" value={stats.flagged} color="border-orange-200 bg-orange-50 text-orange-800" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-muted/50 rounded-lg p-1">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => { setStatus(t.key); setPage(1); setSelected(new Set()); }}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
status === t.key
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2 sm:ml-auto">
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
className="h-8 text-sm w-48"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" variant="outline" className="h-8 px-2">
|
||||||
|
<Search className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
{search && (
|
||||||
|
<Button size="sm" variant="ghost" className="h-8 px-2" onClick={() => { setSearch(""); setSearchInput(""); setPage(1); }}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex items-center gap-3 bg-primary/5 border border-primary/20 rounded-lg px-4 py-2">
|
||||||
|
<span className="text-sm font-medium text-primary">{selected.size} sélectionnée(s)</span>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => setBulkDeleteOpen(true)}>
|
||||||
|
<Trash2 className="h-3 w-3 mr-1" /> Supprimer la sélection
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-background border border-border rounded-xl overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Chargement...
|
||||||
|
</div>
|
||||||
|
) : !list || list.ideas.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground text-sm">
|
||||||
|
Aucune contribution dans cette catégorie.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/50 bg-muted/30 text-xs text-muted-foreground font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={selected.size === list.ideas.length && list.ideas.length > 0}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">Contribution</span>
|
||||||
|
<span className="w-20 text-right">Statut</span>
|
||||||
|
<span className="w-32 text-right hidden sm:block">Date</span>
|
||||||
|
<span className="w-28 text-right">Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{list.ideas.map((idea) => (
|
||||||
|
<div
|
||||||
|
key={idea.id}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors ${
|
||||||
|
idea.flagged ? "bg-orange-50/50" : ""
|
||||||
|
} ${selected.has(idea.id) ? "bg-primary/5" : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded mt-1 flex-shrink-0"
|
||||||
|
checked={selected.has(idea.id)}
|
||||||
|
onChange={() => toggleSelect(idea.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<p className="text-sm leading-snug line-clamp-3">{idea.content}</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{idea.author && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{idea.author}</span>
|
||||||
|
)}
|
||||||
|
{idea.flagged && (
|
||||||
|
<span className="text-xs text-orange-600 font-medium flex items-center gap-0.5">
|
||||||
|
<Flag className="h-3 w-3" /> {idea.flagCount}×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{idea.rejectionReason && (
|
||||||
|
<span className="text-xs text-red-600 truncate max-w-xs" title={idea.rejectionReason}>
|
||||||
|
✗ {idea.rejectionReason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{idea.adminNote && (
|
||||||
|
<span className="text-xs text-blue-600 italic truncate max-w-xs" title={idea.adminNote}>
|
||||||
|
Note: {idea.adminNote}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-20 flex-shrink-0 text-right pt-0.5">
|
||||||
|
<Badge
|
||||||
|
variant={idea.accepted ? "default" : "destructive"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{idea.accepted ? "Acceptée" : "Rejetée"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-32 flex-shrink-0 text-right pt-1 hidden sm:block">
|
||||||
|
{idea.createdAt ? (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{new Date(idea.createdAt).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-28 flex-shrink-0 flex items-center gap-1 justify-end pt-0.5">
|
||||||
|
{idea.flagged && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-1.5 text-orange-600 hover:text-orange-700"
|
||||||
|
title="Retirer le signalement"
|
||||||
|
onClick={() => unflag(idea.id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-1.5"
|
||||||
|
title={idea.accepted ? "Rejeter manuellement" : "Accepter manuellement"}
|
||||||
|
onClick={() => openOverride(idea)}
|
||||||
|
>
|
||||||
|
{idea.accepted ? <X className="h-3.5 w-3.5 text-red-500" /> : <Check className="h-3.5 w-3.5 text-green-600" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-1.5 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
title="Supprimer définitivement"
|
||||||
|
onClick={() => setDeleteTarget(idea.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{list && list.pages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm" variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground font-mono">
|
||||||
|
{page} / {list.pages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm" variant="outline"
|
||||||
|
disabled={page >= list.pages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog suppression unique */}
|
||||||
|
<AlertDialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer cette contribution ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Cette action est irréversible. La contribution sera définitivement supprimée
|
||||||
|
et la synthèse collective sera régénérée automatiquement.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => deleteTarget !== null && deleteOne(deleteTarget)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Dialog suppression en masse */}
|
||||||
|
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer {selected.size} contribution(s) ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Cette action est irréversible. La synthèse sera régénérée après suppression.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction className="bg-red-600 hover:bg-red-700" onClick={bulkDelete}>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Dialog override */}
|
||||||
|
<Dialog open={overrideTarget !== null} onOpenChange={() => setOverrideTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier le statut manuellement</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{overrideTarget && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground bg-muted/40 p-3 rounded-lg line-clamp-4">
|
||||||
|
{overrideTarget.content}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant={overrideAccepted ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOverrideAccepted(true)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" /> Accepter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!overrideAccepted ? "destructive" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOverrideAccepted(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" /> Rejeter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!overrideAccepted && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">Motif du refus</label>
|
||||||
|
<Input
|
||||||
|
value={overrideReason}
|
||||||
|
onChange={(e) => setOverrideReason(e.target.value)}
|
||||||
|
placeholder="Raison de la modération manuelle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">Note admin (interne)</label>
|
||||||
|
<Textarea
|
||||||
|
value={overrideNote}
|
||||||
|
onChange={(e) => setOverrideNote(e.target.value)}
|
||||||
|
placeholder="Note visible uniquement par l'administrateur"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setOverrideTarget(null)}>Annuler</Button>
|
||||||
|
<Button onClick={override}>Confirmer</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,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,
|
Share2, Printer, Copy, Flag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
@@ -71,6 +71,8 @@ const VALEURS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -79,6 +81,26 @@ export default function Home() {
|
|||||||
message: string;
|
message: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
|
||||||
|
const [flaggingId, setFlaggingId] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleFlag = async (ideaId: number) => {
|
||||||
|
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
||||||
|
setFlaggingId(ideaId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/ideas/${ideaId}/flag`, { method: "POST" });
|
||||||
|
if (res.ok) {
|
||||||
|
setFlaggedIds((prev) => new Set(prev).add(ideaId));
|
||||||
|
toast({ title: "Signalement envoyé", description: "Cette contribution a été signalée à l'administrateur." });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Erreur", description: "Impossible d'envoyer le signalement.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Erreur réseau", description: "Vérifiez votre connexion.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setFlaggingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitIdea = useSubmitIdea();
|
const submitIdea = useSubmitIdea();
|
||||||
const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas();
|
const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas();
|
||||||
@@ -345,6 +367,24 @@ export default function Home() {
|
|||||||
<span>
|
<span>
|
||||||
{format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })}
|
{format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleFlag(idea.id)}
|
||||||
|
disabled={flaggedIds.has(idea.id) || flaggingId === idea.id}
|
||||||
|
className={`ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity text-xs ${
|
||||||
|
flaggedIds.has(idea.id)
|
||||||
|
? "text-orange-500 opacity-100"
|
||||||
|
: "text-muted-foreground/50 hover:text-orange-500"
|
||||||
|
}`}
|
||||||
|
title={flaggedIds.has(idea.id) ? "Déjà signalé" : "Signaler cette contribution"}
|
||||||
|
aria-label="Signaler cette contribution"
|
||||||
|
>
|
||||||
|
{flaggingId === idea.id ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Flag className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{flaggedIds.has(idea.id) ? "Signalé" : "Signaler"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user