CurationTab.tsx raw

   1  import { useCallback, useEffect, useState } from 'react'
   2  import relayAdmin from '@/services/relay-admin.service'
   3  import { Button } from '@/components/ui/button'
   4  import { cn } from '@/lib/utils'
   5  import { toast } from 'sonner'
   6  
   7  // ---------------------------------------------------------------------------
   8  // Types
   9  // ---------------------------------------------------------------------------
  10  
  11  interface TrustedEntry {
  12    pubkey: string
  13    note?: string
  14  }
  15  
  16  interface BlacklistedEntry {
  17    pubkey: string
  18    reason?: string
  19  }
  20  
  21  interface UnclassifiedEntry {
  22    pubkey: string
  23    event_count: number
  24  }
  25  
  26  interface SpamEntry {
  27    event_id: string
  28    pubkey: string
  29    reason?: string
  30  }
  31  
  32  interface BlockedIP {
  33    ip: string
  34    reason?: string
  35    expires_at?: string
  36  }
  37  
  38  interface UserEvent {
  39    id: string
  40    kind: number
  41    content: string
  42    created_at: number
  43  }
  44  
  45  type Tab = 'trusted' | 'blacklist' | 'unclassified' | 'spam' | 'ips' | 'settings'
  46  type UserCategory = 'trusted' | 'blacklisted' | 'unclassified'
  47  
  48  // ---------------------------------------------------------------------------
  49  // Helpers
  50  // ---------------------------------------------------------------------------
  51  
  52  function formatPubkey(pk: string): string {
  53    if (!pk || pk.length < 16) return pk
  54    return `${pk.slice(0, 8)}...${pk.slice(-8)}`
  55  }
  56  
  57  function formatDate(ts: number | string | undefined): string {
  58    if (!ts) return ''
  59    const n = typeof ts === 'string' ? Date.parse(ts) : ts
  60    return new Date(n).toLocaleString()
  61  }
  62  
  63  const KIND_NAMES: Record<number, string> = {
  64    0: 'Metadata',
  65    1: 'Text Note',
  66    3: 'Follow List',
  67    4: 'Encrypted DM',
  68    6: 'Repost',
  69    7: 'Reaction',
  70    14: 'Chat Message',
  71    1063: 'File Metadata',
  72    10002: 'Relay List',
  73    30023: 'Long-form',
  74    30078: 'App Data',
  75  }
  76  
  77  function kindName(k: number): string {
  78    return KIND_NAMES[k] ?? `Kind ${k}`
  79  }
  80  
  81  function truncateContent(c: string, maxLines = 6): string {
  82    if (!c) return ''
  83    const lines = c.split('\n')
  84    if (lines.length <= maxLines && c.length <= maxLines * 100) return c
  85    let t = lines.slice(0, maxLines).join('\n')
  86    if (t.length > maxLines * 100) t = t.substring(0, maxLines * 100)
  87    return t
  88  }
  89  
  90  function isContentTruncated(c: string, maxLines = 6): boolean {
  91    if (!c) return false
  92    const lines = c.split('\n')
  93    return lines.length > maxLines || c.length > maxLines * 100
  94  }
  95  
  96  // Wrapper that extracts .result from NIP-86 JSON-RPC response
  97  async function nip86(method: string, params: unknown[] = []): Promise<unknown> {
  98    const res = await relayAdmin.nip86Request(method, params)
  99    if (res.error) throw new Error(String(res.error))
 100    return res.result
 101  }
 102  
 103  // ---------------------------------------------------------------------------
 104  // Sub-components
 105  // ---------------------------------------------------------------------------
 106  
 107  function TabButton({
 108    active,
 109    onClick,
 110    children,
 111  }: {
 112    active: boolean
 113    onClick: () => void
 114    children: React.ReactNode
 115  }) {
 116    return (
 117      <button
 118        onClick={onClick}
 119        className={cn(
 120          'px-3 py-2 text-sm border-b-2 transition-colors',
 121          active
 122            ? 'border-primary text-primary'
 123            : 'border-transparent text-muted-foreground hover:text-foreground hover:bg-accent/20'
 124        )}
 125      >
 126        {children}
 127      </button>
 128    )
 129  }
 130  
 131  // ---------------------------------------------------------------------------
 132  // User Detail Panel
 133  // ---------------------------------------------------------------------------
 134  
 135  function UserDetail({
 136    pubkey,
 137    category,
 138    onClose,
 139    onChanged,
 140  }: {
 141    pubkey: string
 142    category: UserCategory
 143    onClose: () => void
 144    onChanged: () => void
 145  }) {
 146    const [events, setEvents] = useState<UserEvent[]>([])
 147    const [total, setTotal] = useState(0)
 148    const [loadingEvents, setLoadingEvents] = useState(false)
 149    const [expanded, setExpanded] = useState<Record<string, boolean>>({})
 150    const [busy, setBusy] = useState(false)
 151  
 152    const loadEvents = useCallback(
 153      async (offset: number) => {
 154        if (loadingEvents) return
 155        setLoadingEvents(true)
 156        try {
 157          const res = (await nip86('geteventsforpubkey', [pubkey, 100, offset])) as {
 158            events?: UserEvent[]
 159            total?: number
 160          } | null
 161          if (res) {
 162            if (offset === 0) {
 163              setEvents(res.events ?? [])
 164            } else {
 165              setEvents((prev) => [...prev, ...(res.events ?? [])])
 166            }
 167            setTotal(res.total ?? 0)
 168          }
 169        } catch (e) {
 170          toast.error(`Failed to load events: ${e instanceof Error ? e.message : String(e)}`)
 171        } finally {
 172          setLoadingEvents(false)
 173        }
 174      },
 175      [pubkey, loadingEvents]
 176    )
 177  
 178    useEffect(() => {
 179      loadEvents(0)
 180      // eslint-disable-next-line react-hooks/exhaustive-deps
 181    }, [pubkey])
 182  
 183    const act = async (method: string, params: unknown[], msg: string) => {
 184      setBusy(true)
 185      try {
 186        await nip86(method, params)
 187        toast.success(msg)
 188        onChanged()
 189        onClose()
 190      } catch (e) {
 191        toast.error(e instanceof Error ? e.message : String(e))
 192      } finally {
 193        setBusy(false)
 194      }
 195    }
 196  
 197    const deleteAll = async () => {
 198      if (!confirm(`Delete ALL ${total} events from this user? This cannot be undone.`)) return
 199      setBusy(true)
 200      try {
 201        const res = (await nip86('deleteeventsforpubkey', [pubkey])) as { deleted?: number } | null
 202        toast.success(`Deleted ${res?.deleted ?? 0} events`)
 203        setEvents([])
 204        setTotal(0)
 205      } catch (e) {
 206        toast.error(e instanceof Error ? e.message : String(e))
 207      } finally {
 208        setBusy(false)
 209      }
 210    }
 211  
 212    return (
 213      <div className="rounded-lg border bg-card p-4 space-y-4">
 214        {/* Header */}
 215        <div className="flex flex-wrap items-center justify-between gap-2 border-b pb-3">
 216          <div className="flex items-center gap-3 flex-wrap">
 217            <Button variant="outline" size="sm" onClick={onClose}>
 218              &larr; Back
 219            </Button>
 220            <span className="font-semibold">User Events</span>
 221            <code className="rounded bg-muted px-2 py-0.5 text-xs" title={pubkey}>
 222              {formatPubkey(pubkey)}
 223            </code>
 224            <span className="text-xs text-green-500 font-medium">{total} events</span>
 225          </div>
 226  
 227          <div className="flex gap-2 flex-wrap">
 228            {category === 'trusted' && (
 229              <>
 230                <Button
 231                  variant="destructive"
 232                  size="sm"
 233                  disabled={busy}
 234                  onClick={() => act('untrustpubkey', [pubkey], 'Trust removed')}
 235                >
 236                  Remove Trust
 237                </Button>
 238                <Button
 239                  variant="destructive"
 240                  size="sm"
 241                  disabled={busy}
 242                  onClick={() => act('blacklistpubkey', [pubkey, ''], 'Blacklisted')}
 243                >
 244                  Blacklist
 245                </Button>
 246              </>
 247            )}
 248            {category === 'blacklisted' && (
 249              <>
 250                <Button
 251                  variant="destructive"
 252                  size="sm"
 253                  disabled={busy || total === 0}
 254                  onClick={deleteAll}
 255                  className="bg-red-900 hover:bg-red-950"
 256                >
 257                  Delete All Events
 258                </Button>
 259                <Button
 260                  size="sm"
 261                  disabled={busy}
 262                  onClick={() => act('unblacklistpubkey', [pubkey], 'Removed from blacklist')}
 263                >
 264                  Remove from Blacklist
 265                </Button>
 266                <Button
 267                  size="sm"
 268                  disabled={busy}
 269                  onClick={() => act('trustpubkey', [pubkey, ''], 'Trusted')}
 270                >
 271                  Trust
 272                </Button>
 273              </>
 274            )}
 275            {category === 'unclassified' && (
 276              <>
 277                <Button
 278                  size="sm"
 279                  disabled={busy}
 280                  onClick={() => act('trustpubkey', [pubkey, ''], 'Trusted')}
 281                >
 282                  Trust
 283                </Button>
 284                <Button
 285                  variant="destructive"
 286                  size="sm"
 287                  disabled={busy}
 288                  onClick={() => act('blacklistpubkey', [pubkey, ''], 'Blacklisted')}
 289                >
 290                  Blacklist
 291                </Button>
 292              </>
 293            )}
 294          </div>
 295        </div>
 296  
 297        {/* Events */}
 298        <div className="max-h-[600px] overflow-y-auto space-y-2">
 299          {loadingEvents && events.length === 0 ? (
 300            <p className="text-center py-8 text-muted-foreground">Loading events...</p>
 301          ) : events.length === 0 ? (
 302            <p className="text-center py-8 text-muted-foreground italic">No events found.</p>
 303          ) : (
 304            events.map((ev) => {
 305              const isExp = !!expanded[ev.id]
 306              const isTrunc = isContentTruncated(ev.content)
 307              return (
 308                <div key={ev.id} className="rounded-md border bg-background p-3 space-y-1">
 309                  <div className="flex flex-wrap items-center gap-2 text-xs">
 310                    <span className="rounded bg-primary/80 text-primary-foreground px-2 py-0.5 font-medium">
 311                      {kindName(ev.kind)}
 312                    </span>
 313                    <code className="text-muted-foreground" title={ev.id}>
 314                      {formatPubkey(ev.id)}
 315                    </code>
 316                    <span className="text-muted-foreground/60">
 317                      {formatDate(ev.created_at * 1000)}
 318                    </span>
 319                  </div>
 320                  <pre
 321                    className={cn(
 322                      'whitespace-pre-wrap break-words text-sm bg-card rounded p-2',
 323                      !isExp && 'max-h-[150px] overflow-hidden'
 324                    )}
 325                  >
 326                    {isExp || !isTrunc
 327                      ? ev.content || '(empty)'
 328                      : `${truncateContent(ev.content)}...`}
 329                  </pre>
 330                  {isTrunc && (
 331                    <button
 332                      className="text-xs text-primary hover:underline"
 333                      onClick={() =>
 334                        setExpanded((prev) => ({ ...prev, [ev.id]: !prev[ev.id] }))
 335                      }
 336                    >
 337                      {isExp ? 'Show less' : 'Show more'}
 338                    </button>
 339                  )}
 340                </div>
 341              )
 342            })
 343          )}
 344  
 345          {events.length > 0 && events.length < total && (
 346            <div className="text-center py-3">
 347              <Button
 348                variant="outline"
 349                size="sm"
 350                disabled={loadingEvents}
 351                onClick={() => loadEvents(events.length)}
 352              >
 353                {loadingEvents ? 'Loading...' : `Load more (${events.length} of ${total})`}
 354              </Button>
 355            </div>
 356          )}
 357        </div>
 358      </div>
 359    )
 360  }
 361  
 362  // ---------------------------------------------------------------------------
 363  // Main component
 364  // ---------------------------------------------------------------------------
 365  
 366  export default function CurationTab() {
 367    const [activeTab, setActiveTab] = useState<Tab>('trusted')
 368    const [loading, setLoading] = useState(false)
 369  
 370    // Data
 371    const [trusted, setTrusted] = useState<TrustedEntry[]>([])
 372    const [blacklisted, setBlacklisted] = useState<BlacklistedEntry[]>([])
 373    const [unclassified, setUnclassified] = useState<UnclassifiedEntry[]>([])
 374    const [spam, setSpam] = useState<SpamEntry[]>([])
 375    const [blockedIPs, setBlockedIPs] = useState<BlockedIP[]>([])
 376  
 377    // Add-form inputs
 378    const [newTrustedPk, setNewTrustedPk] = useState('')
 379    const [newTrustedNote, setNewTrustedNote] = useState('')
 380    const [newBlackPk, setNewBlackPk] = useState('')
 381    const [newBlackReason, setNewBlackReason] = useState('')
 382  
 383    // User detail
 384    const [selectedUser, setSelectedUser] = useState<string | null>(null)
 385    const [selectedCategory, setSelectedCategory] = useState<UserCategory>('unclassified')
 386  
 387    // Settings
 388    const [dailyLimit, setDailyLimit] = useState(50)
 389    const [firstBanHours, setFirstBanHours] = useState(1)
 390    const [secondBanHours, setSecondBanHours] = useState(168)
 391  
 392    // -----------------------------------------------------------------------
 393    // Loaders
 394    // -----------------------------------------------------------------------
 395  
 396    const loadTrusted = useCallback(async () => {
 397      try {
 398        const r = (await nip86('listtrustedpubkeys')) as TrustedEntry[] | null
 399        setTrusted(r ?? [])
 400      } catch {
 401        setTrusted([])
 402      }
 403    }, [])
 404  
 405    const loadBlacklisted = useCallback(async () => {
 406      try {
 407        const r = (await nip86('listblacklistedpubkeys')) as BlacklistedEntry[] | null
 408        setBlacklisted(r ?? [])
 409      } catch {
 410        setBlacklisted([])
 411      }
 412    }, [])
 413  
 414    const loadUnclassified = useCallback(async () => {
 415      try {
 416        const r = (await nip86('listunclassifiedusers')) as UnclassifiedEntry[] | null
 417        setUnclassified(r ?? [])
 418      } catch {
 419        setUnclassified([])
 420      }
 421    }, [])
 422  
 423    const loadSpam = useCallback(async () => {
 424      try {
 425        const r = (await nip86('listspamevents')) as SpamEntry[] | null
 426        setSpam(r ?? [])
 427      } catch {
 428        setSpam([])
 429      }
 430    }, [])
 431  
 432    const loadIPs = useCallback(async () => {
 433      try {
 434        const r = (await nip86('listblockedips')) as BlockedIP[] | null
 435        setBlockedIPs(r ?? [])
 436      } catch {
 437        setBlockedIPs([])
 438      }
 439    }, [])
 440  
 441    const loadConfig = useCallback(async () => {
 442      try {
 443        const r = (await nip86('getcuratingconfig')) as Record<string, unknown> | null
 444        if (r) {
 445          setDailyLimit((r.daily_limit as number) ?? 50)
 446          setFirstBanHours((r.first_ban_hours as number) ?? 1)
 447          setSecondBanHours((r.second_ban_hours as number) ?? 168)
 448        }
 449      } catch {
 450        /* keep defaults */
 451      }
 452    }, [])
 453  
 454    const loadAll = useCallback(async () => {
 455      setLoading(true)
 456      await Promise.all([loadTrusted(), loadBlacklisted(), loadUnclassified(), loadSpam(), loadIPs(), loadConfig()])
 457      setLoading(false)
 458    }, [loadTrusted, loadBlacklisted, loadUnclassified, loadSpam, loadIPs, loadConfig])
 459  
 460    useEffect(() => {
 461      loadAll()
 462    }, [loadAll])
 463  
 464    // -----------------------------------------------------------------------
 465    // Actions
 466    // -----------------------------------------------------------------------
 467  
 468    const trustPubkey = async (pk: string, note: string) => {
 469      if (!pk) return
 470      try {
 471        await nip86('trustpubkey', [pk, note])
 472        toast.success('Pubkey trusted')
 473        setNewTrustedPk('')
 474        setNewTrustedNote('')
 475        await Promise.all([loadTrusted(), loadUnclassified()])
 476      } catch (e) {
 477        toast.error(e instanceof Error ? e.message : String(e))
 478      }
 479    }
 480  
 481    const untrustPubkey = async (pk: string) => {
 482      try {
 483        await nip86('untrustpubkey', [pk])
 484        toast.success('Trust removed')
 485        await loadTrusted()
 486      } catch (e) {
 487        toast.error(e instanceof Error ? e.message : String(e))
 488      }
 489    }
 490  
 491    const blacklistPubkey = async (pk: string, reason: string) => {
 492      if (!pk) return
 493      try {
 494        await nip86('blacklistpubkey', [pk, reason])
 495        toast.success('Pubkey blacklisted')
 496        setNewBlackPk('')
 497        setNewBlackReason('')
 498        await Promise.all([loadBlacklisted(), loadUnclassified()])
 499      } catch (e) {
 500        toast.error(e instanceof Error ? e.message : String(e))
 501      }
 502    }
 503  
 504    const unblacklistPubkey = async (pk: string) => {
 505      try {
 506        await nip86('unblacklistpubkey', [pk])
 507        toast.success('Removed from blacklist')
 508        await loadBlacklisted()
 509      } catch (e) {
 510        toast.error(e instanceof Error ? e.message : String(e))
 511      }
 512    }
 513  
 514    const unmarkSpam = async (eventId: string) => {
 515      try {
 516        await nip86('unmarkspam', [eventId])
 517        toast.success('Spam mark removed')
 518        await loadSpam()
 519      } catch (e) {
 520        toast.error(e instanceof Error ? e.message : String(e))
 521      }
 522    }
 523  
 524    const deleteEvent = async (eventId: string) => {
 525      if (!confirm('Permanently delete this event?')) return
 526      try {
 527        await nip86('deleteevent', [eventId])
 528        toast.success('Event deleted')
 529        await loadSpam()
 530      } catch (e) {
 531        toast.error(e instanceof Error ? e.message : String(e))
 532      }
 533    }
 534  
 535    const unblockIP = async (ip: string) => {
 536      try {
 537        await nip86('unblockip', [ip])
 538        toast.success('IP unblocked')
 539        await loadIPs()
 540      } catch (e) {
 541        toast.error(e instanceof Error ? e.message : String(e))
 542      }
 543    }
 544  
 545    const scanDatabase = async () => {
 546      try {
 547        const res = (await nip86('scanpubkeys')) as {
 548          total_pubkeys?: number
 549          total_events?: number
 550          skipped?: number
 551        } | null
 552        toast.success(
 553          `Scanned: ${res?.total_pubkeys ?? 0} pubkeys, ${res?.total_events ?? 0} events (${res?.skipped ?? 0} skipped)`
 554        )
 555        await loadUnclassified()
 556      } catch (e) {
 557        toast.error(e instanceof Error ? e.message : String(e))
 558      }
 559    }
 560  
 561    const saveSettings = async () => {
 562      setLoading(true)
 563      try {
 564        await nip86('updatecuratingconfig', [
 565          { daily_limit: dailyLimit, first_ban_hours: firstBanHours, second_ban_hours: secondBanHours },
 566        ])
 567        toast.success('Settings updated')
 568      } catch (e) {
 569        toast.error(e instanceof Error ? e.message : String(e))
 570      } finally {
 571        setLoading(false)
 572      }
 573    }
 574  
 575    // -----------------------------------------------------------------------
 576    // Detail view
 577    // -----------------------------------------------------------------------
 578  
 579    if (selectedUser) {
 580      return (
 581        <div className="p-4 w-full">
 582          <UserDetail
 583            pubkey={selectedUser}
 584            category={selectedCategory}
 585            onClose={() => setSelectedUser(null)}
 586            onChanged={loadAll}
 587          />
 588        </div>
 589      )
 590    }
 591  
 592    // -----------------------------------------------------------------------
 593    // Tabs
 594    // -----------------------------------------------------------------------
 595  
 596    return (
 597      <div className="p-4 space-y-4 w-full">
 598        <h3 className="text-lg font-semibold">Curation Mode</h3>
 599  
 600        {/* Tab bar */}
 601        <div className="flex flex-wrap border-b">
 602          <TabButton active={activeTab === 'trusted'} onClick={() => setActiveTab('trusted')}>
 603            Trusted ({trusted.length})
 604          </TabButton>
 605          <TabButton active={activeTab === 'blacklist'} onClick={() => setActiveTab('blacklist')}>
 606            Blacklist ({blacklisted.length})
 607          </TabButton>
 608          <TabButton active={activeTab === 'unclassified'} onClick={() => setActiveTab('unclassified')}>
 609            Unclassified ({unclassified.length})
 610          </TabButton>
 611          <TabButton active={activeTab === 'spam'} onClick={() => setActiveTab('spam')}>
 612            Spam ({spam.length})
 613          </TabButton>
 614          <TabButton active={activeTab === 'ips'} onClick={() => setActiveTab('ips')}>
 615            Blocked IPs ({blockedIPs.length})
 616          </TabButton>
 617          <TabButton active={activeTab === 'settings'} onClick={() => setActiveTab('settings')}>
 618            Settings
 619          </TabButton>
 620        </div>
 621  
 622        {/* ===== Trusted ===== */}
 623        {activeTab === 'trusted' && (
 624          <div className="rounded-lg border bg-card p-4 space-y-3">
 625            <div>
 626              <h4 className="font-medium">Trusted Publishers</h4>
 627              <p className="text-xs text-muted-foreground">
 628                Trusted users can publish unlimited events without rate limiting.
 629              </p>
 630            </div>
 631  
 632            <div className="flex flex-wrap gap-2">
 633              <input
 634                className="flex-1 min-w-[200px] rounded-md border bg-background px-3 py-1.5 text-sm"
 635                placeholder="Pubkey (64 hex chars)"
 636                value={newTrustedPk}
 637                onChange={(e) => setNewTrustedPk(e.target.value)}
 638              />
 639              <input
 640                className="flex-1 min-w-[120px] rounded-md border bg-background px-3 py-1.5 text-sm"
 641                placeholder="Note (optional)"
 642                value={newTrustedNote}
 643                onChange={(e) => setNewTrustedNote(e.target.value)}
 644              />
 645              <Button size="sm" disabled={loading || !newTrustedPk} onClick={() => trustPubkey(newTrustedPk, newTrustedNote)}>
 646                Trust
 647              </Button>
 648            </div>
 649  
 650            <div className="rounded-md border max-h-[400px] overflow-y-auto divide-y">
 651              {trusted.length === 0 ? (
 652                <p className="text-center py-6 text-muted-foreground italic">No trusted pubkeys yet.</p>
 653              ) : (
 654                trusted.map((item) => (
 655                  <div
 656                    key={item.pubkey}
 657                    className="flex items-center justify-between gap-2 px-3 py-2 cursor-pointer hover:bg-accent/20 transition-colors"
 658                    onClick={() => {
 659                      setSelectedUser(item.pubkey)
 660                      setSelectedCategory('trusted')
 661                    }}
 662                  >
 663                    <div className="min-w-0">
 664                      <code className="text-sm" title={item.pubkey}>
 665                        {formatPubkey(item.pubkey)}
 666                      </code>
 667                      {item.note && (
 668                        <p className="text-xs text-muted-foreground truncate">{item.note}</p>
 669                      )}
 670                    </div>
 671                    <Button
 672                      variant="destructive"
 673                      size="sm"
 674                      onClick={(e) => {
 675                        e.stopPropagation()
 676                        untrustPubkey(item.pubkey)
 677                      }}
 678                    >
 679                      Remove
 680                    </Button>
 681                  </div>
 682                ))
 683              )}
 684            </div>
 685          </div>
 686        )}
 687  
 688        {/* ===== Blacklist ===== */}
 689        {activeTab === 'blacklist' && (
 690          <div className="rounded-lg border bg-card p-4 space-y-3">
 691            <div>
 692              <h4 className="font-medium">Blacklisted Publishers</h4>
 693              <p className="text-xs text-muted-foreground">
 694                Blacklisted users cannot publish any events.
 695              </p>
 696            </div>
 697  
 698            <div className="flex flex-wrap gap-2">
 699              <input
 700                className="flex-1 min-w-[200px] rounded-md border bg-background px-3 py-1.5 text-sm"
 701                placeholder="Pubkey (64 hex chars)"
 702                value={newBlackPk}
 703                onChange={(e) => setNewBlackPk(e.target.value)}
 704              />
 705              <input
 706                className="flex-1 min-w-[120px] rounded-md border bg-background px-3 py-1.5 text-sm"
 707                placeholder="Reason (optional)"
 708                value={newBlackReason}
 709                onChange={(e) => setNewBlackReason(e.target.value)}
 710              />
 711              <Button
 712                variant="destructive"
 713                size="sm"
 714                disabled={loading || !newBlackPk}
 715                onClick={() => blacklistPubkey(newBlackPk, newBlackReason)}
 716              >
 717                Blacklist
 718              </Button>
 719            </div>
 720  
 721            <div className="rounded-md border max-h-[400px] overflow-y-auto divide-y">
 722              {blacklisted.length === 0 ? (
 723                <p className="text-center py-6 text-muted-foreground italic">No blacklisted pubkeys.</p>
 724              ) : (
 725                blacklisted.map((item) => (
 726                  <div
 727                    key={item.pubkey}
 728                    className="flex items-center justify-between gap-2 px-3 py-2 cursor-pointer hover:bg-accent/20 transition-colors"
 729                    onClick={() => {
 730                      setSelectedUser(item.pubkey)
 731                      setSelectedCategory('blacklisted')
 732                    }}
 733                  >
 734                    <div className="min-w-0">
 735                      <code className="text-sm" title={item.pubkey}>
 736                        {formatPubkey(item.pubkey)}
 737                      </code>
 738                      {item.reason && (
 739                        <p className="text-xs text-muted-foreground truncate">{item.reason}</p>
 740                      )}
 741                    </div>
 742                    <Button
 743                      size="sm"
 744                      onClick={(e) => {
 745                        e.stopPropagation()
 746                        unblacklistPubkey(item.pubkey)
 747                      }}
 748                    >
 749                      Remove
 750                    </Button>
 751                  </div>
 752                ))
 753              )}
 754            </div>
 755          </div>
 756        )}
 757  
 758        {/* ===== Unclassified ===== */}
 759        {activeTab === 'unclassified' && (
 760          <div className="rounded-lg border bg-card p-4 space-y-3">
 761            <div>
 762              <h4 className="font-medium">Unclassified Users</h4>
 763              <p className="text-xs text-muted-foreground">
 764                Users who have posted events but haven't been classified. Sorted by event count.
 765              </p>
 766            </div>
 767  
 768            <div className="flex gap-2">
 769              <Button variant="outline" size="sm" disabled={loading} onClick={loadUnclassified}>
 770                Refresh
 771              </Button>
 772              <Button variant="secondary" size="sm" disabled={loading} onClick={scanDatabase}>
 773                Scan Database
 774              </Button>
 775            </div>
 776  
 777            <div className="rounded-md border max-h-[400px] overflow-y-auto divide-y">
 778              {unclassified.length === 0 ? (
 779                <p className="text-center py-6 text-muted-foreground italic">No unclassified users.</p>
 780              ) : (
 781                unclassified.map((user) => (
 782                  <div
 783                    key={user.pubkey}
 784                    className="flex items-center justify-between gap-2 px-3 py-2 cursor-pointer hover:bg-accent/20 transition-colors"
 785                    onClick={() => {
 786                      setSelectedUser(user.pubkey)
 787                      setSelectedCategory('unclassified')
 788                    }}
 789                  >
 790                    <div className="min-w-0">
 791                      <code className="text-sm" title={user.pubkey}>
 792                        {formatPubkey(user.pubkey)}
 793                      </code>
 794                      <span className="ml-2 text-xs text-green-500 font-medium">
 795                        {user.event_count} events
 796                      </span>
 797                    </div>
 798                    <div className="flex gap-1.5 shrink-0">
 799                      <Button
 800                        size="sm"
 801                        onClick={(e) => {
 802                          e.stopPropagation()
 803                          trustPubkey(user.pubkey, '')
 804                        }}
 805                      >
 806                        Trust
 807                      </Button>
 808                      <Button
 809                        variant="destructive"
 810                        size="sm"
 811                        onClick={(e) => {
 812                          e.stopPropagation()
 813                          blacklistPubkey(user.pubkey, '')
 814                        }}
 815                      >
 816                        Blacklist
 817                      </Button>
 818                    </div>
 819                  </div>
 820                ))
 821              )}
 822            </div>
 823          </div>
 824        )}
 825  
 826        {/* ===== Spam ===== */}
 827        {activeTab === 'spam' && (
 828          <div className="rounded-lg border bg-card p-4 space-y-3">
 829            <div>
 830              <h4 className="font-medium">Spam Events</h4>
 831              <p className="text-xs text-muted-foreground">
 832                Events flagged as spam are hidden from query results but remain in the database.
 833              </p>
 834            </div>
 835  
 836            <Button variant="outline" size="sm" disabled={loading} onClick={loadSpam}>
 837              Refresh
 838            </Button>
 839  
 840            <div className="rounded-md border max-h-[400px] overflow-y-auto divide-y">
 841              {spam.length === 0 ? (
 842                <p className="text-center py-6 text-muted-foreground italic">No spam events flagged.</p>
 843              ) : (
 844                spam.map((ev) => (
 845                  <div key={ev.event_id} className="flex items-center justify-between gap-2 px-3 py-2">
 846                    <div className="min-w-0">
 847                      <code className="text-sm" title={ev.event_id}>
 848                        {formatPubkey(ev.event_id)}
 849                      </code>
 850                      <span className="ml-2 text-xs text-muted-foreground" title={ev.pubkey}>
 851                        by {formatPubkey(ev.pubkey)}
 852                      </span>
 853                      {ev.reason && (
 854                        <p className="text-xs text-muted-foreground truncate">{ev.reason}</p>
 855                      )}
 856                    </div>
 857                    <div className="flex gap-1.5 shrink-0">
 858                      <Button size="sm" onClick={() => unmarkSpam(ev.event_id)}>
 859                        Unmark
 860                      </Button>
 861                      <Button variant="destructive" size="sm" onClick={() => deleteEvent(ev.event_id)}>
 862                        Delete
 863                      </Button>
 864                    </div>
 865                  </div>
 866                ))
 867              )}
 868            </div>
 869          </div>
 870        )}
 871  
 872        {/* ===== Blocked IPs ===== */}
 873        {activeTab === 'ips' && (
 874          <div className="rounded-lg border bg-card p-4 space-y-3">
 875            <div>
 876              <h4 className="font-medium">Blocked IP Addresses</h4>
 877              <p className="text-xs text-muted-foreground">
 878                IP addresses blocked due to rate limit violations.
 879              </p>
 880            </div>
 881  
 882            <Button variant="outline" size="sm" disabled={loading} onClick={loadIPs}>
 883              Refresh
 884            </Button>
 885  
 886            <div className="rounded-md border max-h-[400px] overflow-y-auto divide-y">
 887              {blockedIPs.length === 0 ? (
 888                <p className="text-center py-6 text-muted-foreground italic">No blocked IPs.</p>
 889              ) : (
 890                blockedIPs.map((ip) => (
 891                  <div key={ip.ip} className="flex items-center justify-between gap-2 px-3 py-2">
 892                    <div className="min-w-0">
 893                      <code className="text-sm">{ip.ip}</code>
 894                      {ip.reason && (
 895                        <span className="ml-2 text-xs text-muted-foreground">{ip.reason}</span>
 896                      )}
 897                      {ip.expires_at && (
 898                        <span className="ml-2 text-xs text-muted-foreground/60">
 899                          Expires: {formatDate(ip.expires_at)}
 900                        </span>
 901                      )}
 902                    </div>
 903                    <Button size="sm" onClick={() => unblockIP(ip.ip)}>
 904                      Unblock
 905                    </Button>
 906                  </div>
 907                ))
 908              )}
 909            </div>
 910          </div>
 911        )}
 912  
 913        {/* ===== Settings ===== */}
 914        {activeTab === 'settings' && (
 915          <div className="rounded-lg border bg-card p-4 space-y-4">
 916            <div>
 917              <h4 className="font-medium">Rate Limiting</h4>
 918              <p className="text-xs text-muted-foreground">
 919                Configure rate limits for unclassified users and IP ban durations.
 920              </p>
 921            </div>
 922  
 923            <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
 924              <label className="space-y-1">
 925                <span className="text-sm font-medium">Daily Event Limit</span>
 926                <input
 927                  type="number"
 928                  min={1}
 929                  className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
 930                  value={dailyLimit}
 931                  onChange={(e) => setDailyLimit(Number(e.target.value))}
 932                />
 933              </label>
 934              <label className="space-y-1">
 935                <span className="text-sm font-medium">First Ban (hours)</span>
 936                <input
 937                  type="number"
 938                  min={1}
 939                  className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
 940                  value={firstBanHours}
 941                  onChange={(e) => setFirstBanHours(Number(e.target.value))}
 942                />
 943              </label>
 944              <label className="space-y-1">
 945                <span className="text-sm font-medium">Second+ Ban (hours)</span>
 946                <input
 947                  type="number"
 948                  min={1}
 949                  className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
 950                  value={secondBanHours}
 951                  onChange={(e) => setSecondBanHours(Number(e.target.value))}
 952                />
 953              </label>
 954            </div>
 955  
 956            <div>
 957              <Button size="sm" disabled={loading} onClick={saveSettings}>
 958                {loading ? 'Saving...' : 'Save Settings'}
 959              </Button>
 960            </div>
 961          </div>
 962        )}
 963      </div>
 964    )
 965  }
 966