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 ← 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