Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 543 lines 21 kB view raw
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}