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

Configure Feed

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

at main 733 lines 29 kB view raw
1import React, { useEffect, useState } from "react"; 2import { useStore } from "@nanostores/react"; 3import { useTranslation } from "react-i18next"; 4import { $user } from "../../store/auth"; 5import { 6 checkAdminAccess, 7 getAdminReports, 8 adminTakeAction, 9 adminCreateLabel, 10 adminDeleteLabel, 11 adminGetLabels, 12 adminBanAccount, 13 adminUnbanAccount, 14 adminGetBannedAccounts, 15 type BannedAccount, 16} from "../../api/client"; 17import type { ModerationReport, HydratedLabel } from "../../types"; 18import { 19 Shield, 20 CheckCircle, 21 XCircle, 22 AlertTriangle, 23 Eye, 24 ChevronDown, 25 ChevronUp, 26 Tag, 27 FileText, 28 Plus, 29 Trash2, 30 EyeOff, 31 UserX, 32 UserCheck, 33} from "lucide-react"; 34import { Avatar, EmptyState, Skeleton, Button } from "../../components/ui"; 35 36const STATUS_COLORS: Record<string, string> = { 37 pending: 38 "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300", 39 resolved: 40 "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", 41 dismissed: 42 "bg-surface-100 text-surface-600 dark:bg-surface-800 dark:text-surface-400", 43 escalated: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", 44 acknowledged: 45 "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", 46}; 47 48const REASON_LABEL_KEYS: Record<string, string> = { 49 spam: "adminModeration.reasons.spam", 50 violation: "adminModeration.reasons.violation", 51 misleading: "adminModeration.reasons.misleading", 52 sexual: "adminModeration.reasons.sexual", 53 rude: "adminModeration.reasons.rude", 54 other: "adminModeration.reasons.other", 55}; 56 57const LABEL_VALS = [ 58 "sexual", 59 "nudity", 60 "violence", 61 "gore", 62 "spam", 63 "misleading", 64]; 65 66type Tab = "reports" | "labels" | "actions" | "bans"; 67 68export default function AdminModeration() { 69 const { t } = useTranslation(); 70 const user = useStore($user); 71 const [isAdmin, setIsAdmin] = useState(false); 72 const [loading, setLoading] = useState(true); 73 const [activeTab, setActiveTab] = useState<Tab>("reports"); 74 75 const [reports, setReports] = useState<ModerationReport[]>([]); 76 const [pendingCount, setPendingCount] = useState(0); 77 const [totalCount, setTotalCount] = useState(0); 78 const [statusFilter, setStatusFilter] = useState<string>("pending"); 79 const [expandedReport, setExpandedReport] = useState<number | null>(null); 80 const [actionLoading, setActionLoading] = useState<number | null>(null); 81 82 const [labels, setLabels] = useState<HydratedLabel[]>([]); 83 84 const [labelSrc, setLabelSrc] = useState(""); 85 const [labelUri, setLabelUri] = useState(""); 86 const [labelVal, setLabelVal] = useState(""); 87 const [labelSubmitting, setLabelSubmitting] = useState(false); 88 const [labelSuccess, setLabelSuccess] = useState(false); 89 90 const [bans, setBans] = useState<BannedAccount[]>([]); 91 const [banDid, setBanDid] = useState(""); 92 const [banReason, setBanReason] = useState(""); 93 const [banSubmitting, setBanSubmitting] = useState(false); 94 const [banSuccess, setBanSuccess] = useState(false); 95 const [unbanLoading, setUnbanLoading] = useState<string | null>(null); 96 97 const loadReports = async (status: string) => { 98 const data = await getAdminReports(status || undefined); 99 setReports(data.items); 100 setPendingCount(data.pendingCount); 101 setTotalCount(data.totalItems); 102 }; 103 104 const loadLabels = async () => { 105 const data = await adminGetLabels(); 106 setLabels(data.items || []); 107 }; 108 109 const loadBans = async () => { 110 const data = await adminGetBannedAccounts(); 111 setBans(data.items || []); 112 }; 113 114 useEffect(() => { 115 const init = async () => { 116 const admin = await checkAdminAccess(); 117 setIsAdmin(admin); 118 if (admin) await loadReports("pending"); 119 setLoading(false); 120 }; 121 init(); 122 }, []); 123 124 const handleTabChange = async (tab: Tab) => { 125 setActiveTab(tab); 126 if (tab === "labels") await loadLabels(); 127 if (tab === "bans") await loadBans(); 128 }; 129 130 const handleFilterChange = async (status: string) => { 131 setStatusFilter(status); 132 await loadReports(status); 133 }; 134 135 const handleAction = async (reportId: number, action: string) => { 136 setActionLoading(reportId); 137 const success = await adminTakeAction({ reportId, action }); 138 if (success) { 139 await loadReports(statusFilter); 140 setExpandedReport(null); 141 } 142 setActionLoading(null); 143 }; 144 145 const handleBanFromReport = async (did: string, reportId: number) => { 146 setActionLoading(reportId); 147 await adminBanAccount({ did }); 148 setActionLoading(null); 149 }; 150 151 const handleBanAccount = async () => { 152 if (!banDid.trim()) return; 153 setBanSubmitting(true); 154 const success = await adminBanAccount({ 155 did: banDid.trim(), 156 reason: banReason.trim() || undefined, 157 }); 158 if (success) { 159 setBanDid(""); 160 setBanReason(""); 161 setBanSuccess(true); 162 setTimeout(() => setBanSuccess(false), 2000); 163 await loadBans(); 164 } 165 setBanSubmitting(false); 166 }; 167 168 const handleUnban = async (did: string) => { 169 setUnbanLoading(did); 170 const success = await adminUnbanAccount(did); 171 if (success) setBans((prev) => prev.filter((b) => b.did !== did)); 172 setUnbanLoading(null); 173 }; 174 175 const handleCreateLabel = async () => { 176 if (!labelVal || (!labelSrc && !labelUri)) return; 177 setLabelSubmitting(true); 178 const success = await adminCreateLabel({ 179 src: labelSrc || labelUri, 180 uri: labelUri || undefined, 181 val: labelVal, 182 }); 183 if (success) { 184 setLabelSrc(""); 185 setLabelUri(""); 186 setLabelVal(""); 187 setLabelSuccess(true); 188 setTimeout(() => setLabelSuccess(false), 2000); 189 if (activeTab === "labels") await loadLabels(); 190 } 191 setLabelSubmitting(false); 192 }; 193 194 const handleDeleteLabel = async (id: number) => { 195 if (!window.confirm(t("adminModeration.labels.removeConfirm"))) return; 196 const success = await adminDeleteLabel(id); 197 if (success) setLabels((prev) => prev.filter((l) => l.id !== id)); 198 }; 199 200 if (loading) { 201 return ( 202 <div className="max-w-3xl mx-auto animate-slide-up"> 203 <Skeleton className="h-8 w-48 mb-6" /> 204 <div className="space-y-3"> 205 <Skeleton className="h-24 rounded-xl" /> 206 <Skeleton className="h-24 rounded-xl" /> 207 <Skeleton className="h-24 rounded-xl" /> 208 </div> 209 </div> 210 ); 211 } 212 213 if (!user || !isAdmin) { 214 return ( 215 <EmptyState 216 icon={<Shield size={40} />} 217 title={t("adminModeration.accessDenied")} 218 message={t("adminModeration.accessDeniedMessage")} 219 /> 220 ); 221 } 222 223 return ( 224 <div className="max-w-3xl mx-auto animate-slide-up"> 225 <div className="flex items-center justify-between mb-6"> 226 <div> 227 <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white flex items-center gap-2.5"> 228 <Shield 229 size={24} 230 className="text-primary-600 dark:text-primary-400" 231 /> 232 {t("adminModeration.title")} 233 </h1> 234 <p className="text-sm text-surface-500 dark:text-surface-400 mt-1"> 235 {t("adminModeration.stats", { 236 pending: pendingCount, 237 total: totalCount, 238 })} 239 </p> 240 </div> 241 </div> 242 243 <div className="flex gap-1 mb-5 border-b border-surface-200 dark:border-surface-700"> 244 {[ 245 { 246 id: "reports" as Tab, 247 label: t("adminModeration.tabs.reports"), 248 icon: <FileText size={15} />, 249 }, 250 { 251 id: "actions" as Tab, 252 label: t("adminModeration.tabs.actions"), 253 icon: <EyeOff size={15} />, 254 }, 255 { 256 id: "labels" as Tab, 257 label: t("adminModeration.tabs.labels"), 258 icon: <Tag size={15} />, 259 }, 260 { 261 id: "bans" as Tab, 262 label: "Bans", 263 icon: <UserX size={15} />, 264 }, 265 ].map((tab) => ( 266 <button 267 key={tab.id} 268 onClick={() => handleTabChange(tab.id)} 269 className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${ 270 activeTab === tab.id 271 ? "border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400" 272 : "border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300" 273 }`} 274 > 275 {tab.icon} 276 {tab.label} 277 </button> 278 ))} 279 </div> 280 281 {activeTab === "reports" && ( 282 <> 283 <div className="flex gap-2 mb-5"> 284 {["pending", "resolved", "dismissed", "escalated", ""].map( 285 (status) => ( 286 <button 287 key={status || "all"} 288 onClick={() => handleFilterChange(status)} 289 className={`px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors ${ 290 statusFilter === status 291 ? "bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300" 292 : "text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800" 293 }`} 294 > 295 {status 296 ? t(`adminModeration.filters.${status}`, { 297 defaultValue: 298 status.charAt(0).toUpperCase() + status.slice(1), 299 }) 300 : t("adminModeration.filters.all")} 301 </button> 302 ), 303 )} 304 </div> 305 306 {reports.length === 0 ? ( 307 <EmptyState 308 icon={<CheckCircle size={40} />} 309 title={t("adminModeration.reports.empty")} 310 message={ 311 statusFilter === "pending" 312 ? t("adminModeration.reports.emptyPending") 313 : t("adminModeration.reports.emptyFiltered", { 314 status: statusFilter || "", 315 }) 316 } 317 /> 318 ) : ( 319 <div className="space-y-3"> 320 {reports.map((report) => ( 321 <div 322 key={report.id} 323 className="card overflow-hidden transition-all" 324 > 325 <button 326 onClick={() => 327 setExpandedReport( 328 expandedReport === report.id ? null : report.id, 329 ) 330 } 331 className="w-full p-4 flex items-center gap-4 text-left hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors" 332 > 333 <Avatar 334 did={report.subject.did} 335 avatar={report.subject.avatar} 336 size="sm" 337 /> 338 <div className="flex-1 min-w-0"> 339 <div className="flex items-center gap-2 mb-0.5"> 340 <span className="font-medium text-surface-900 dark:text-white text-sm truncate"> 341 {report.subject.displayName || 342 report.subject.handle || 343 report.subject.did} 344 </span> 345 <span 346 className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[report.status] || STATUS_COLORS.pending}`} 347 > 348 {report.status} 349 </span> 350 </div> 351 <p className="text-xs text-surface-500 dark:text-surface-400"> 352 {REASON_LABEL_KEYS[report.reasonType] 353 ? t(REASON_LABEL_KEYS[report.reasonType]) 354 : report.reasonType}{" "} 355 · reported by @ 356 {report.reporter.handle || report.reporter.did} ·{" "} 357 {new Date(report.createdAt).toLocaleDateString()} 358 </p> 359 </div> 360 {expandedReport === report.id ? ( 361 <ChevronUp size={16} className="text-surface-400" /> 362 ) : ( 363 <ChevronDown size={16} className="text-surface-400" /> 364 )} 365 </button> 366 367 {expandedReport === report.id && ( 368 <div className="px-4 pb-4 border-t border-surface-100 dark:border-surface-800 pt-3 space-y-3"> 369 <div className="grid grid-cols-2 gap-3 text-sm"> 370 <div> 371 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 372 {t("adminModeration.reports.reportedUser")} 373 </span> 374 <a 375 href={`/profile/${report.subject.did}`} 376 className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium" 377 > 378 @{report.subject.handle || report.subject.did} 379 </a> 380 </div> 381 <div> 382 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 383 {t("adminModeration.reports.reporter")} 384 </span> 385 <a 386 href={`/profile/${report.reporter.did}`} 387 className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium" 388 > 389 @{report.reporter.handle || report.reporter.did} 390 </a> 391 </div> 392 </div> 393 394 {report.reasonText && ( 395 <div> 396 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 397 {t("adminModeration.reports.details")} 398 </span> 399 <p className="text-sm text-surface-700 dark:text-surface-300 mt-1"> 400 {report.reasonText} 401 </p> 402 </div> 403 )} 404 405 {report.subjectUri && ( 406 <div> 407 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 408 {t("adminModeration.reports.contentUri")} 409 </span> 410 <p className="text-xs text-surface-500 font-mono mt-1 break-all"> 411 {report.subjectUri} 412 </p> 413 </div> 414 )} 415 416 {report.status === "pending" && ( 417 <div className="flex flex-wrap items-center gap-2 pt-2"> 418 <Button 419 size="sm" 420 variant="secondary" 421 onClick={() => 422 handleAction(report.id, "acknowledge") 423 } 424 loading={actionLoading === report.id} 425 icon={<Eye size={14} />} 426 > 427 {t("adminModeration.reports.acknowledge")} 428 </Button> 429 <Button 430 size="sm" 431 variant="secondary" 432 onClick={() => handleAction(report.id, "dismiss")} 433 loading={actionLoading === report.id} 434 icon={<XCircle size={14} />} 435 > 436 {t("adminModeration.reports.dismiss")} 437 </Button> 438 <Button 439 size="sm" 440 onClick={() => handleAction(report.id, "takedown")} 441 loading={actionLoading === report.id} 442 icon={<AlertTriangle size={14} />} 443 className="!bg-red-600 hover:!bg-red-700 !text-white" 444 > 445 {t("adminModeration.reports.takedown")} 446 </Button> 447 <Button 448 size="sm" 449 onClick={() => 450 handleBanFromReport(report.subject.did, report.id) 451 } 452 loading={actionLoading === report.id} 453 icon={<UserX size={14} />} 454 className="!bg-gray-800 hover:!bg-gray-900 !text-white dark:!bg-gray-700 dark:hover:!bg-gray-600" 455 > 456 Ban user 457 </Button> 458 </div> 459 )} 460 </div> 461 )} 462 </div> 463 ))} 464 </div> 465 )} 466 </> 467 )} 468 469 {activeTab === "actions" && ( 470 <div className="space-y-6"> 471 <div className="card p-5"> 472 <h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2"> 473 <Tag 474 size={16} 475 className="text-primary-600 dark:text-primary-400" 476 /> 477 {t("adminModeration.actions.applyWarning")} 478 </h3> 479 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4"> 480 {t("adminModeration.actions.applyWarningDesc")} 481 </p> 482 483 <div className="space-y-3"> 484 <div> 485 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 486 {t("adminModeration.actions.accountDid")} 487 </label> 488 <input 489 type="text" 490 value={labelSrc} 491 onChange={(e) => setLabelSrc(e.target.value)} 492 placeholder="did:plc:..." 493 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" 494 /> 495 </div> 496 497 <div> 498 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 499 {t("adminModeration.actions.contentUri")}{" "} 500 <span className="text-surface-400"> 501 ({t("adminModeration.actions.contentUriOptional")}) 502 </span> 503 </label> 504 <input 505 type="text" 506 value={labelUri} 507 onChange={(e) => setLabelUri(e.target.value)} 508 placeholder="at://did:plc:.../at.margin.annotation/..." 509 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" 510 /> 511 </div> 512 513 <div> 514 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 515 {t("adminModeration.actions.labelType")} 516 </label> 517 <div className="grid grid-cols-3 gap-2"> 518 {LABEL_VALS.map((val) => ( 519 <button 520 key={val} 521 onClick={() => setLabelVal(val)} 522 className={`px-3 py-2 text-sm font-medium rounded-lg border transition-all ${ 523 labelVal === val 524 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 ring-2 ring-primary-500/20" 525 : "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800" 526 }`} 527 > 528 {t(`card.labelDescriptions.${val}`)} 529 </button> 530 ))} 531 </div> 532 </div> 533 534 <div className="flex items-center gap-3 pt-1"> 535 <Button 536 onClick={handleCreateLabel} 537 loading={labelSubmitting} 538 disabled={!labelVal || (!labelSrc && !labelUri)} 539 icon={<Plus size={14} />} 540 size="sm" 541 > 542 {t("adminModeration.actions.applyLabel")} 543 </Button> 544 {labelSuccess && ( 545 <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5"> 546 <CheckCircle size={14} />{" "} 547 {t("adminModeration.actions.labelApplied")} 548 </span> 549 )} 550 </div> 551 </div> 552 </div> 553 </div> 554 )} 555 556 {activeTab === "labels" && ( 557 <div> 558 {labels.length === 0 ? ( 559 <EmptyState 560 icon={<Tag size={40} />} 561 title={t("adminModeration.labels.empty")} 562 message={t("adminModeration.labels.emptyMessage")} 563 /> 564 ) : ( 565 <div className="space-y-2"> 566 {labels.map((label) => ( 567 <div 568 key={label.id} 569 className="card p-4 flex items-center gap-4" 570 > 571 {label.subject && ( 572 <Avatar 573 did={label.subject.did} 574 avatar={label.subject.avatar} 575 size="sm" 576 /> 577 )} 578 <div className="flex-1 min-w-0"> 579 <div className="flex items-center gap-2 mb-0.5"> 580 <span 581 className={`text-xs px-2 py-0.5 rounded-full font-medium ${ 582 label.val === "sexual" || label.val === "nudity" 583 ? "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300" 584 : label.val === "violence" || label.val === "gore" 585 ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300" 586 : "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" 587 }`} 588 > 589 {label.val} 590 </span> 591 {label.subject && ( 592 <a 593 href={`/profile/${label.subject.did}`} 594 className="text-sm font-medium text-surface-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 truncate" 595 > 596 @{label.subject.handle || label.subject.did} 597 </a> 598 )} 599 </div> 600 <p className="text-xs text-surface-500 dark:text-surface-400 truncate"> 601 {label.uri !== label.src 602 ? label.uri 603 : t("adminModeration.labels.accountLevel")}{" "} 604 · {new Date(label.createdAt).toLocaleDateString()} · by @ 605 {label.createdBy.handle || label.createdBy.did} 606 </p> 607 </div> 608 <button 609 onClick={() => handleDeleteLabel(label.id)} 610 className="p-2 rounded-lg text-surface-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" 611 title={t("adminModeration.labels.removeTitle")} 612 > 613 <Trash2 size={14} /> 614 </button> 615 </div> 616 ))} 617 </div> 618 )} 619 </div> 620 )} 621 622 {activeTab === "bans" && ( 623 <div className="space-y-6"> 624 <div className="card p-5"> 625 <h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2"> 626 <UserX size={16} className="text-red-500" /> 627 Ban account 628 </h3> 629 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4"> 630 Banned users cannot sign in and their content is hidden everywhere 631 on Margin. 632 </p> 633 634 <div className="space-y-3"> 635 <div> 636 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 637 Account DID 638 </label> 639 <input 640 type="text" 641 value={banDid} 642 onChange={(e) => setBanDid(e.target.value)} 643 placeholder="did:plc:..." 644 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" 645 /> 646 </div> 647 648 <div> 649 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 650 Reason <span className="text-surface-400">(optional)</span> 651 </label> 652 <input 653 type="text" 654 value={banReason} 655 onChange={(e) => setBanReason(e.target.value)} 656 placeholder="Reason for ban..." 657 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" 658 /> 659 </div> 660 661 <div className="flex items-center gap-3 pt-1"> 662 <Button 663 onClick={handleBanAccount} 664 loading={banSubmitting} 665 disabled={!banDid.trim()} 666 icon={<UserX size={14} />} 667 size="sm" 668 className="!bg-red-600 hover:!bg-red-700 !text-white" 669 > 670 Ban account 671 </Button> 672 {banSuccess && ( 673 <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5"> 674 <CheckCircle size={14} /> Account banned 675 </span> 676 )} 677 </div> 678 </div> 679 </div> 680 681 <div> 682 {bans.length === 0 ? ( 683 <EmptyState 684 icon={<UserCheck size={40} />} 685 title="No banned accounts" 686 message="Banned accounts will appear here." 687 /> 688 ) : ( 689 <div className="space-y-2"> 690 {bans.map((ban) => ( 691 <div 692 key={ban.did} 693 className="card p-4 flex items-center gap-4" 694 > 695 <Avatar 696 did={ban.did} 697 avatar={ban.profile?.avatar} 698 size="sm" 699 /> 700 <div className="flex-1 min-w-0"> 701 <div className="flex items-center gap-2 mb-0.5"> 702 <span className="font-medium text-surface-900 dark:text-white text-sm truncate"> 703 {ban.profile?.displayName || 704 (ban.profile?.handle && `@${ban.profile.handle}`) || 705 ban.did} 706 </span> 707 <span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"> 708 banned 709 </span> 710 </div> 711 <p className="text-xs text-surface-500 dark:text-surface-400 truncate"> 712 {ban.reason ? `${ban.reason} · ` : ""} 713 {new Date(ban.bannedAt).toLocaleDateString()} 714 </p> 715 </div> 716 <button 717 onClick={() => handleUnban(ban.did)} 718 disabled={unbanLoading === ban.did} 719 className="p-2 rounded-lg text-surface-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors" 720 title="Unban" 721 > 722 <UserCheck size={14} /> 723 </button> 724 </div> 725 ))} 726 </div> 727 )} 728 </div> 729 </div> 730 )} 731 </div> 732 ); 733}