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:
@@ -7,6 +7,7 @@ import Home from "@/pages/home";
|
||||
import About from "@/pages/about";
|
||||
import Transparence from "@/pages/transparence";
|
||||
import Flyer from "@/pages/flyer";
|
||||
import Admin from "@/pages/admin";
|
||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||
|
||||
@@ -67,6 +68,7 @@ function Router() {
|
||||
<Route path="/about" component={About} />
|
||||
<Route path="/transparence" component={Transparence} />
|
||||
<Route path="/flyer" component={Flyer} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</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";
|
||||
import {
|
||||
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
|
||||
Share2, Printer, Copy,
|
||||
Share2, Printer, Copy, Flag,
|
||||
} from "lucide-react";
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
@@ -79,6 +81,26 @@ export default function Home() {
|
||||
message: string;
|
||||
reason?: string;
|
||||
} | 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 { data: ideas, isLoading: isLoadingIdeas } = useListIdeas();
|
||||
@@ -345,6 +367,24 @@ export default function Home() {
|
||||
<span>
|
||||
{format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })}
|
||||
</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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user