(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 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}