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:
pironantoine
2026-04-05 03:42:58 +00:00
parent e58c1cef85
commit 2a792cbbb5
5 changed files with 984 additions and 10 deletions
+2
View File
@@ -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>
);
}
+41 -1
View File
@@ -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>
))