Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import { useStore } from "@nanostores/react";
3import { $user } from "../../store/auth";
4import {
5 checkAdminAccess,
6 getAdminReports,
7 adminTakeAction,
8 adminCreateLabel,
9 adminDeleteLabel,
10 adminGetLabels,
11} from "../../api/client";
12import type { ModerationReport, HydratedLabel } from "../../types";
13import {
14 Shield,
15 CheckCircle,
16 XCircle,
17 AlertTriangle,
18 Eye,
19 ChevronDown,
20 ChevronUp,
21 Tag,
22 FileText,
23 Plus,
24 Trash2,
25 EyeOff,
26} from "lucide-react";
27import { Avatar, EmptyState, Skeleton, Button } from "../../components/ui";
28import { Link } from "react-router-dom";
29
30const STATUS_COLORS: Record<string, string> = {
31 pending:
32 "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
33 resolved:
34 "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
35 dismissed:
36 "bg-surface-100 text-surface-600 dark:bg-surface-800 dark:text-surface-400",
37 escalated: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
38 acknowledged:
39 "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
40};
41
42const REASON_LABELS: Record<string, string> = {
43 spam: "Spam",
44 violation: "Rule Violation",
45 misleading: "Misleading",
46 sexual: "Inappropriate",
47 rude: "Rude / Harassing",
48 other: "Other",
49};
50
51const LABEL_OPTIONS = [
52 { val: "sexual", label: "Sexual Content" },
53 { val: "nudity", label: "Nudity" },
54 { val: "violence", label: "Violence" },
55 { val: "gore", label: "Graphic Content" },
56 { val: "spam", label: "Spam" },
57 { val: "misleading", label: "Misleading" },
58];
59
60type Tab = "reports" | "labels" | "actions";
61
62export default function AdminModeration() {
63 const user = useStore($user);
64 const [isAdmin, setIsAdmin] = useState(false);
65 const [loading, setLoading] = useState(true);
66 const [activeTab, setActiveTab] = useState<Tab>("reports");
67
68 const [reports, setReports] = useState<ModerationReport[]>([]);
69 const [pendingCount, setPendingCount] = useState(0);
70 const [totalCount, setTotalCount] = useState(0);
71 const [statusFilter, setStatusFilter] = useState<string>("pending");
72 const [expandedReport, setExpandedReport] = useState<number | null>(null);
73 const [actionLoading, setActionLoading] = useState<number | null>(null);
74
75 const [labels, setLabels] = useState<HydratedLabel[]>([]);
76
77 const [labelSrc, setLabelSrc] = useState("");
78 const [labelUri, setLabelUri] = useState("");
79 const [labelVal, setLabelVal] = useState("");
80 const [labelSubmitting, setLabelSubmitting] = useState(false);
81 const [labelSuccess, setLabelSuccess] = useState(false);
82
83 const loadReports = async (status: string) => {
84 const data = await getAdminReports(status || undefined);
85 setReports(data.items);
86 setPendingCount(data.pendingCount);
87 setTotalCount(data.totalItems);
88 };
89
90 const loadLabels = async () => {
91 const data = await adminGetLabels();
92 setLabels(data.items || []);
93 };
94
95 useEffect(() => {
96 const init = async () => {
97 const admin = await checkAdminAccess();
98 setIsAdmin(admin);
99 if (admin) await loadReports("pending");
100 setLoading(false);
101 };
102 init();
103 }, []);
104
105 const handleTabChange = async (tab: Tab) => {
106 setActiveTab(tab);
107 if (tab === "labels") await loadLabels();
108 };
109
110 const handleFilterChange = async (status: string) => {
111 setStatusFilter(status);
112 await loadReports(status);
113 };
114
115 const handleAction = async (reportId: number, action: string) => {
116 setActionLoading(reportId);
117 const success = await adminTakeAction({ reportId, action });
118 if (success) {
119 await loadReports(statusFilter);
120 setExpandedReport(null);
121 }
122 setActionLoading(null);
123 };
124
125 const handleCreateLabel = async () => {
126 if (!labelVal || (!labelSrc && !labelUri)) return;
127 setLabelSubmitting(true);
128 const success = await adminCreateLabel({
129 src: labelSrc || labelUri,
130 uri: labelUri || undefined,
131 val: labelVal,
132 });
133 if (success) {
134 setLabelSrc("");
135 setLabelUri("");
136 setLabelVal("");
137 setLabelSuccess(true);
138 setTimeout(() => setLabelSuccess(false), 2000);
139 if (activeTab === "labels") await loadLabels();
140 }
141 setLabelSubmitting(false);
142 };
143
144 const handleDeleteLabel = async (id: number) => {
145 if (!window.confirm("Remove this label?")) return;
146 const success = await adminDeleteLabel(id);
147 if (success) setLabels((prev) => prev.filter((l) => l.id !== id));
148 };
149
150 if (loading) {
151 return (
152 <div className="max-w-3xl mx-auto animate-slide-up">
153 <Skeleton className="h-8 w-48 mb-6" />
154 <div className="space-y-3">
155 <Skeleton className="h-24 rounded-xl" />
156 <Skeleton className="h-24 rounded-xl" />
157 <Skeleton className="h-24 rounded-xl" />
158 </div>
159 </div>
160 );
161 }
162
163 if (!user || !isAdmin) {
164 return (
165 <EmptyState
166 icon={<Shield size={40} />}
167 title="Access Denied"
168 message="You don't have permission to access the moderation dashboard."
169 />
170 );
171 }
172
173 return (
174 <div className="max-w-3xl mx-auto animate-slide-up">
175 <div className="flex items-center justify-between mb-6">
176 <div>
177 <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white flex items-center gap-2.5">
178 <Shield
179 size={24}
180 className="text-primary-600 dark:text-primary-400"
181 />
182 Moderation
183 </h1>
184 <p className="text-sm text-surface-500 dark:text-surface-400 mt-1">
185 {pendingCount} pending · {totalCount} total reports
186 </p>
187 </div>
188 </div>
189
190 <div className="flex gap-1 mb-5 border-b border-surface-200 dark:border-surface-700">
191 {[
192 {
193 id: "reports" as Tab,
194 label: "Reports",
195 icon: <FileText size={15} />,
196 },
197 {
198 id: "actions" as Tab,
199 label: "Actions",
200 icon: <EyeOff size={15} />,
201 },
202 { id: "labels" as Tab, label: "Labels", icon: <Tag size={15} /> },
203 ].map((tab) => (
204 <button
205 key={tab.id}
206 onClick={() => handleTabChange(tab.id)}
207 className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
208 activeTab === tab.id
209 ? "border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400"
210 : "border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300"
211 }`}
212 >
213 {tab.icon}
214 {tab.label}
215 </button>
216 ))}
217 </div>
218
219 {activeTab === "reports" && (
220 <>
221 <div className="flex gap-2 mb-5">
222 {["pending", "resolved", "dismissed", "escalated", ""].map(
223 (status) => (
224 <button
225 key={status || "all"}
226 onClick={() => handleFilterChange(status)}
227 className={`px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors ${
228 statusFilter === status
229 ? "bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
230 : "text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800"
231 }`}
232 >
233 {status
234 ? status.charAt(0).toUpperCase() + status.slice(1)
235 : "All"}
236 </button>
237 ),
238 )}
239 </div>
240
241 {reports.length === 0 ? (
242 <EmptyState
243 icon={<CheckCircle size={40} />}
244 title="No reports"
245 message={
246 statusFilter === "pending"
247 ? "No pending reports to review."
248 : `No ${statusFilter || ""} reports found.`
249 }
250 />
251 ) : (
252 <div className="space-y-3">
253 {reports.map((report) => (
254 <div
255 key={report.id}
256 className="card overflow-hidden transition-all"
257 >
258 <button
259 onClick={() =>
260 setExpandedReport(
261 expandedReport === report.id ? null : report.id,
262 )
263 }
264 className="w-full p-4 flex items-center gap-4 text-left hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors"
265 >
266 <Avatar
267 did={report.subject.did}
268 avatar={report.subject.avatar}
269 size="sm"
270 />
271 <div className="flex-1 min-w-0">
272 <div className="flex items-center gap-2 mb-0.5">
273 <span className="font-medium text-surface-900 dark:text-white text-sm truncate">
274 {report.subject.displayName ||
275 report.subject.handle ||
276 report.subject.did}
277 </span>
278 <span
279 className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[report.status] || STATUS_COLORS.pending}`}
280 >
281 {report.status}
282 </span>
283 </div>
284 <p className="text-xs text-surface-500 dark:text-surface-400">
285 {REASON_LABELS[report.reasonType] || report.reasonType}{" "}
286 · reported by @
287 {report.reporter.handle || report.reporter.did} ·{" "}
288 {new Date(report.createdAt).toLocaleDateString()}
289 </p>
290 </div>
291 {expandedReport === report.id ? (
292 <ChevronUp size={16} className="text-surface-400" />
293 ) : (
294 <ChevronDown size={16} className="text-surface-400" />
295 )}
296 </button>
297
298 {expandedReport === report.id && (
299 <div className="px-4 pb-4 border-t border-surface-100 dark:border-surface-800 pt-3 space-y-3">
300 <div className="grid grid-cols-2 gap-3 text-sm">
301 <div>
302 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
303 Reported User
304 </span>
305 <Link
306 to={`/profile/${report.subject.did}`}
307 className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium"
308 >
309 @{report.subject.handle || report.subject.did}
310 </Link>
311 </div>
312 <div>
313 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
314 Reporter
315 </span>
316 <Link
317 to={`/profile/${report.reporter.did}`}
318 className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium"
319 >
320 @{report.reporter.handle || report.reporter.did}
321 </Link>
322 </div>
323 </div>
324
325 {report.reasonText && (
326 <div>
327 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
328 Details
329 </span>
330 <p className="text-sm text-surface-700 dark:text-surface-300 mt-1">
331 {report.reasonText}
332 </p>
333 </div>
334 )}
335
336 {report.subjectUri && (
337 <div>
338 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
339 Content URI
340 </span>
341 <p className="text-xs text-surface-500 font-mono mt-1 break-all">
342 {report.subjectUri}
343 </p>
344 </div>
345 )}
346
347 {report.status === "pending" && (
348 <div className="flex items-center gap-2 pt-2">
349 <Button
350 size="sm"
351 variant="secondary"
352 onClick={() =>
353 handleAction(report.id, "acknowledge")
354 }
355 loading={actionLoading === report.id}
356 icon={<Eye size={14} />}
357 >
358 Acknowledge
359 </Button>
360 <Button
361 size="sm"
362 variant="secondary"
363 onClick={() => handleAction(report.id, "dismiss")}
364 loading={actionLoading === report.id}
365 icon={<XCircle size={14} />}
366 >
367 Dismiss
368 </Button>
369 <Button
370 size="sm"
371 onClick={() => handleAction(report.id, "takedown")}
372 loading={actionLoading === report.id}
373 icon={<AlertTriangle size={14} />}
374 className="!bg-red-600 hover:!bg-red-700 !text-white"
375 >
376 Takedown
377 </Button>
378 </div>
379 )}
380 </div>
381 )}
382 </div>
383 ))}
384 </div>
385 )}
386 </>
387 )}
388
389 {activeTab === "actions" && (
390 <div className="space-y-6">
391 <div className="card p-5">
392 <h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2">
393 <Tag
394 size={16}
395 className="text-primary-600 dark:text-primary-400"
396 />
397 Apply Content Warning
398 </h3>
399 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4">
400 Add a content warning label to a specific post or account. Users
401 will see a blur overlay with the option to reveal.
402 </p>
403
404 <div className="space-y-3">
405 <div>
406 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
407 Account DID
408 </label>
409 <input
410 type="text"
411 value={labelSrc}
412 onChange={(e) => setLabelSrc(e.target.value)}
413 placeholder="did:plc:..."
414 className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
415 />
416 </div>
417
418 <div>
419 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
420 Content URI{" "}
421 <span className="text-surface-400">
422 (optional — leave empty for account-level label)
423 </span>
424 </label>
425 <input
426 type="text"
427 value={labelUri}
428 onChange={(e) => setLabelUri(e.target.value)}
429 placeholder="at://did:plc:.../at.margin.annotation/..."
430 className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
431 />
432 </div>
433
434 <div>
435 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
436 Label Type
437 </label>
438 <div className="grid grid-cols-3 gap-2">
439 {LABEL_OPTIONS.map((opt) => (
440 <button
441 key={opt.val}
442 onClick={() => setLabelVal(opt.val)}
443 className={`px-3 py-2 text-sm font-medium rounded-lg border transition-all ${
444 labelVal === opt.val
445 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 ring-2 ring-primary-500/20"
446 : "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800"
447 }`}
448 >
449 {opt.label}
450 </button>
451 ))}
452 </div>
453 </div>
454
455 <div className="flex items-center gap-3 pt-1">
456 <Button
457 onClick={handleCreateLabel}
458 loading={labelSubmitting}
459 disabled={!labelVal || (!labelSrc && !labelUri)}
460 icon={<Plus size={14} />}
461 size="sm"
462 >
463 Apply Label
464 </Button>
465 {labelSuccess && (
466 <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5">
467 <CheckCircle size={14} /> Label applied
468 </span>
469 )}
470 </div>
471 </div>
472 </div>
473 </div>
474 )}
475
476 {activeTab === "labels" && (
477 <div>
478 {labels.length === 0 ? (
479 <EmptyState
480 icon={<Tag size={40} />}
481 title="No labels"
482 message="No content labels have been applied yet."
483 />
484 ) : (
485 <div className="space-y-2">
486 {labels.map((label) => (
487 <div
488 key={label.id}
489 className="card p-4 flex items-center gap-4"
490 >
491 {label.subject && (
492 <Avatar
493 did={label.subject.did}
494 avatar={label.subject.avatar}
495 size="sm"
496 />
497 )}
498 <div className="flex-1 min-w-0">
499 <div className="flex items-center gap-2 mb-0.5">
500 <span
501 className={`text-xs px-2 py-0.5 rounded-full font-medium ${
502 label.val === "sexual" || label.val === "nudity"
503 ? "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300"
504 : label.val === "violence" || label.val === "gore"
505 ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
506 : "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
507 }`}
508 >
509 {label.val}
510 </span>
511 {label.subject && (
512 <Link
513 to={`/profile/${label.subject.did}`}
514 className="text-sm font-medium text-surface-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 truncate"
515 >
516 @{label.subject.handle || label.subject.did}
517 </Link>
518 )}
519 </div>
520 <p className="text-xs text-surface-500 dark:text-surface-400 truncate">
521 {label.uri !== label.src
522 ? label.uri
523 : "Account-level label"}{" "}
524 · {new Date(label.createdAt).toLocaleDateString()} · by @
525 {label.createdBy.handle || label.createdBy.did}
526 </p>
527 </div>
528 <button
529 onClick={() => handleDeleteLabel(label.id)}
530 className="p-2 rounded-lg text-surface-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
531 title="Remove label"
532 >
533 <Trash2 size={14} />
534 </button>
535 </div>
536 ))}
537 </div>
538 )}
539 </div>
540 )}
541 </div>
542 );
543}