BlossomAdminTab.tsx raw
1 import { useCallback, useEffect, useRef, useState } from 'react'
2 import { useRelayAdmin } from '@/providers/RelayAdminProvider'
3 import client from '@/services/client.service'
4 import { Button } from '@/components/ui/button'
5 import { toast } from 'sonner'
6 import { nip19 } from 'nostr-tools'
7
8 // ── Types ──
9
10 interface BlobDescriptor {
11 sha256: string
12 size: number
13 type?: string
14 uploaded?: number
15 url?: string
16 }
17
18 interface UserStat {
19 pubkey: string
20 blob_count: number
21 total_size_bytes: number
22 profile?: { name?: string; picture?: string }
23 }
24
25 type SortField = 'date' | 'size'
26 type SortOrder = 'asc' | 'desc'
27
28 // ── Helpers ──
29
30 function getApiBase(): string {
31 return window.location.origin
32 }
33
34 async function createBlossomAuth(verb: string, sha256Hex?: string): Promise<string> {
35 const now = Math.floor(Date.now() / 1000)
36 const expiration = now + 60
37 const tags: string[][] = [
38 ['t', verb],
39 ['expiration', expiration.toString()]
40 ]
41 if (sha256Hex) {
42 tags.push(['x', sha256Hex])
43 }
44 const event = await client.signer!.signEvent({
45 kind: 24242,
46 created_at: now,
47 tags,
48 content: `Blossom ${verb} operation`
49 })
50 return 'Nostr ' + btoa(JSON.stringify(event))
51 }
52
53 function formatSize(bytes: number | undefined): string {
54 if (!bytes) return '0 B'
55 const units = ['B', 'KB', 'MB', 'GB']
56 let i = 0
57 let size = bytes
58 while (size >= 1024 && i < units.length - 1) {
59 size /= 1024
60 i++
61 }
62 return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
63 }
64
65 function formatDate(timestamp: number | undefined): string {
66 if (!timestamp) return 'Unknown'
67 return new Date(timestamp * 1000).toLocaleString()
68 }
69
70 function truncateHash(hash: string): string {
71 if (!hash) return ''
72 return `${hash.slice(0, 8)}...${hash.slice(-8)}`
73 }
74
75 function hexToNpub(hex: string): string {
76 try {
77 return nip19.npubEncode(hex)
78 } catch {
79 return hex
80 }
81 }
82
83 function truncateNpub(npub: string): string {
84 if (!npub) return ''
85 return `${npub.slice(0, 12)}...${npub.slice(-8)}`
86 }
87
88 function sortBlobs(list: BlobDescriptor[], by: SortField, order: SortOrder): BlobDescriptor[] {
89 if (!list.length) return list
90 const sorted = [...list].sort((a, b) => {
91 const cmp = by === 'date' ? (a.uploaded || 0) - (b.uploaded || 0) : (a.size || 0) - (b.size || 0)
92 return order === 'desc' ? -cmp : cmp
93 })
94 return sorted
95 }
96
97 function getBlobUrl(blob: BlobDescriptor): string {
98 if (blob.url) {
99 if (blob.url.startsWith('http://') || blob.url.startsWith('https://')) return blob.url
100 if (blob.url.startsWith('/')) return `${getApiBase()}${blob.url}`
101 return `http://${blob.url}`
102 }
103 return `${getApiBase()}/blossom/${blob.sha256}`
104 }
105
106 function getThumbnailUrl(blob: BlobDescriptor): string {
107 const base = getBlobUrl(blob)
108 const sep = base.includes('?') ? '&' : '?'
109 return `${base}${sep}w=128`
110 }
111
112 function mimeCategory(mimeType?: string): string {
113 if (!mimeType) return 'unknown'
114 if (mimeType.startsWith('image/')) return 'image'
115 if (mimeType.startsWith('video/')) return 'video'
116 if (mimeType.startsWith('audio/')) return 'audio'
117 return 'file'
118 }
119
120 // ── Constants ──
121
122 const PAGE_SIZE = 40
123
124 // ── Component ──
125
126 export default function BlossomAdminTab() {
127 const { isAdmin, isOwner } = useRelayAdmin()
128
129 // Admin user listing
130 const [userStats, setUserStats] = useState<UserStat[]>([])
131 const [isLoadingUsers, setIsLoadingUsers] = useState(false)
132
133 // Selected user blob listing
134 const [selectedUser, setSelectedUser] = useState<UserStat | null>(null)
135 const [userBlobs, setUserBlobs] = useState<BlobDescriptor[]>([])
136 const [isLoadingBlobs, setIsLoadingBlobs] = useState(false)
137
138 // Sort
139 const [sortBy, setSortBy] = useState<SortField>('date')
140 const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
141
142 // Pagination
143 const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
144 const sentinelRef = useRef<HTMLDivElement>(null)
145
146 // Selection for bulk delete
147 const [selectedHashes, setSelectedHashes] = useState<Set<string>>(new Set())
148 const [isDeletingSelected, setIsDeletingSelected] = useState(false)
149
150 const [error, setError] = useState('')
151
152 const canAdmin = isAdmin || isOwner
153
154 // ── Data loading ──
155
156 const fetchUserStats = useCallback(async () => {
157 setIsLoadingUsers(true)
158 setError('')
159 try {
160 const auth = await createBlossomAuth('admin')
161 const url = `${getApiBase()}/blossom/admin/users`
162 const res = await fetch(url, { headers: { Authorization: auth } })
163 if (!res.ok) throw new Error(`Failed to load user stats: ${res.statusText}`)
164 const data: UserStat[] = await res.json()
165
166 // Resolve profiles in the background
167 const statsWithProfiles = data.map((s) => ({ ...s }))
168 setUserStats(statsWithProfiles)
169
170 for (const stat of statsWithProfiles) {
171 client
172 .fetchProfile(stat.pubkey)
173 .then((profile) => {
174 if (profile) {
175 stat.profile = { name: profile.username, picture: profile.avatar }
176 setUserStats((prev) => [...prev])
177 }
178 })
179 .catch(() => {})
180 }
181 } catch (e) {
182 setError(e instanceof Error ? e.message : 'Failed to load user stats')
183 } finally {
184 setIsLoadingUsers(false)
185 }
186 }, [])
187
188 const loadUserBlobs = useCallback(async (userPubkey: string) => {
189 setIsLoadingBlobs(true)
190 setError('')
191 try {
192 const url = `${getApiBase()}/blossom/list/${userPubkey}`
193 const res = await fetch(url)
194 if (!res.ok) throw new Error(`Failed to load blobs: ${res.statusText}`)
195 const data: BlobDescriptor[] = await res.json()
196 setUserBlobs(data)
197 } catch (e) {
198 setError(e instanceof Error ? e.message : 'Failed to load blobs')
199 } finally {
200 setIsLoadingBlobs(false)
201 }
202 }, [])
203
204 // Load user stats on mount
205 useEffect(() => {
206 if (canAdmin) {
207 fetchUserStats()
208 }
209 }, [canAdmin, fetchUserStats])
210
211 // Load blobs when a user is selected
212 useEffect(() => {
213 if (selectedUser) {
214 setVisibleCount(PAGE_SIZE)
215 setSelectedHashes(new Set())
216 loadUserBlobs(selectedUser.pubkey)
217 }
218 }, [selectedUser, loadUserBlobs])
219
220 // Reset visible count on sort change
221 useEffect(() => {
222 setVisibleCount(PAGE_SIZE)
223 }, [sortBy, sortOrder])
224
225 // Intersection observer for infinite scroll
226 useEffect(() => {
227 const el = sentinelRef.current
228 if (!el) return
229 const observer = new IntersectionObserver(
230 (entries) => {
231 if (entries[0].isIntersecting) {
232 setVisibleCount((prev) => prev + PAGE_SIZE)
233 }
234 },
235 { rootMargin: '0px 0px 150% 0px' }
236 )
237 observer.observe(el)
238 return () => observer.disconnect()
239 }, [selectedUser])
240
241 // ── Derived data ──
242
243 const sortedBlobs = sortBlobs(userBlobs, sortBy, sortOrder)
244 const displayBlobs = sortedBlobs.slice(0, visibleCount)
245 const hasMore = visibleCount < sortedBlobs.length
246
247 const totalStorage = userStats.reduce((sum, u) => sum + (u.total_size_bytes || 0), 0)
248 const totalBlobs = userStats.reduce((sum, u) => sum + (u.blob_count || 0), 0)
249
250 // ── Actions ──
251
252 const toggleSort = (field: SortField) => {
253 if (sortBy === field) {
254 setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
255 } else {
256 setSortBy(field)
257 setSortOrder('desc')
258 }
259 }
260
261 const toggleSelection = (hash: string) => {
262 setSelectedHashes((prev) => {
263 const next = new Set(prev)
264 if (next.has(hash)) next.delete(hash)
265 else next.add(hash)
266 return next
267 })
268 }
269
270 const deleteBlob = async (blob: BlobDescriptor) => {
271 if (!confirm(`Delete blob ${truncateHash(blob.sha256)}?`)) return
272 try {
273 const auth = await createBlossomAuth('delete', blob.sha256)
274 const url = `${getApiBase()}/blossom/${blob.sha256}`
275 const res = await fetch(url, { method: 'DELETE', headers: { Authorization: auth } })
276 if (!res.ok) throw new Error(`Delete failed: ${res.statusText}`)
277 toast.success('Blob deleted')
278 if (selectedUser) loadUserBlobs(selectedUser.pubkey)
279 } catch (e) {
280 toast.error(e instanceof Error ? e.message : 'Delete failed')
281 }
282 }
283
284 const deleteSelectedBlobs = async () => {
285 if (selectedHashes.size === 0) return
286 if (!confirm(`Delete ${selectedHashes.size} selected file(s)? This cannot be undone.`)) return
287
288 setIsDeletingSelected(true)
289 let deleted = 0
290 let failed = 0
291
292 for (const hash of selectedHashes) {
293 try {
294 const auth = await createBlossomAuth('delete', hash)
295 const url = `${getApiBase()}/blossom/${hash}`
296 const res = await fetch(url, { method: 'DELETE', headers: { Authorization: auth } })
297 if (res.ok) deleted++
298 else failed++
299 } catch {
300 failed++
301 }
302 }
303
304 setSelectedHashes(new Set())
305 setIsDeletingSelected(false)
306
307 if (failed > 0) {
308 toast.warning(`Deleted ${deleted}, failed ${failed}`)
309 } else {
310 toast.success(`Deleted ${deleted} file(s)`)
311 }
312
313 if (selectedUser) loadUserBlobs(selectedUser.pubkey)
314 }
315
316 const handleRefresh = () => {
317 if (selectedUser) {
318 loadUserBlobs(selectedUser.pubkey)
319 } else {
320 fetchUserStats()
321 }
322 }
323
324 // ── Render ──
325
326 if (!canAdmin) {
327 return (
328 <div className="p-4 text-muted-foreground text-center">
329 Admin access required.
330 </div>
331 )
332 }
333
334 // Selected user blob list view
335 if (selectedUser) {
336 return (
337 <div className="p-4 space-y-3 w-full">
338 {/* Header */}
339 <div className="flex flex-wrap items-center justify-between gap-2">
340 <div className="flex items-center gap-2">
341 <Button variant="ghost" size="sm" onClick={() => setSelectedUser(null)}>
342 ← Back
343 </Button>
344 <div className="flex items-center gap-2">
345 {selectedUser.profile?.picture && (
346 <img
347 src={selectedUser.profile.picture}
348 alt=""
349 className="w-6 h-6 rounded-full object-cover"
350 />
351 )}
352 <span className="font-semibold truncate max-w-[200px]">
353 {selectedUser.profile?.name || truncateNpub(hexToNpub(selectedUser.pubkey))}
354 </span>
355 </div>
356 </div>
357 <div className="flex items-center gap-2 flex-wrap">
358 {selectedHashes.size > 0 && (
359 <Button
360 variant="destructive"
361 size="sm"
362 onClick={deleteSelectedBlobs}
363 disabled={isDeletingSelected}
364 >
365 {isDeletingSelected
366 ? 'Deleting...'
367 : `Delete Selected (${selectedHashes.size})`}
368 </Button>
369 )}
370 <Button variant="ghost" size="sm" onClick={() => toggleSort('date')}>
371 Date {sortBy === 'date' ? (sortOrder === 'desc' ? '\u2193' : '\u2191') : ''}
372 </Button>
373 <Button variant="ghost" size="sm" onClick={() => toggleSort('size')}>
374 Size {sortBy === 'size' ? (sortOrder === 'desc' ? '\u2193' : '\u2191') : ''}
375 </Button>
376 <Button size="sm" onClick={handleRefresh} disabled={isLoadingBlobs}>
377 {isLoadingBlobs ? 'Loading...' : 'Refresh'}
378 </Button>
379 </div>
380 </div>
381
382 {error && (
383 <div className="rounded-md bg-destructive/10 text-destructive p-3 text-sm">{error}</div>
384 )}
385
386 <div className="text-xs text-muted-foreground">
387 {sortedBlobs.length} blob(s) · {formatSize(selectedUser.total_size_bytes)}
388 </div>
389
390 {/* Blob list */}
391 {isLoadingBlobs && displayBlobs.length === 0 ? (
392 <div className="text-center py-8 text-muted-foreground">Loading blobs...</div>
393 ) : displayBlobs.length === 0 ? (
394 <div className="text-center py-8 text-muted-foreground">
395 No files found for this user.
396 </div>
397 ) : (
398 <div className="space-y-1">
399 {displayBlobs.map((blob) => (
400 <div
401 key={blob.sha256}
402 className={`flex items-center gap-2 rounded-md px-3 py-2 ${
403 selectedHashes.has(blob.sha256)
404 ? 'bg-primary/10 ring-1 ring-primary/30'
405 : 'bg-card'
406 }`}
407 >
408 <input
409 type="checkbox"
410 checked={selectedHashes.has(blob.sha256)}
411 onChange={() => toggleSelection(blob.sha256)}
412 className="shrink-0"
413 />
414 <div className="w-10 h-10 shrink-0 rounded overflow-hidden bg-muted flex items-center justify-center">
415 {mimeCategory(blob.type) === 'image' ? (
416 <img
417 src={getThumbnailUrl(blob)}
418 alt=""
419 className="w-full h-full object-cover"
420 loading="lazy"
421 />
422 ) : (
423 <span className="text-xs text-muted-foreground">
424 {blob.type?.split('/')[1]?.slice(0, 4) || '?'}
425 </span>
426 )}
427 </div>
428 <div className="flex-1 min-w-0">
429 <div className="font-mono text-xs truncate" title={blob.sha256}>
430 {truncateHash(blob.sha256)}
431 </div>
432 <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
433 <span>{formatSize(blob.size)}</span>
434 <span>{blob.type || 'unknown'}</span>
435 <span>{formatDate(blob.uploaded)}</span>
436 </div>
437 </div>
438 <Button
439 variant="ghost"
440 size="sm"
441 className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
442 onClick={(e) => {
443 e.stopPropagation()
444 deleteBlob(blob)
445 }}
446 >
447 Delete
448 </Button>
449 </div>
450 ))}
451 {hasMore && (
452 <div ref={sentinelRef} className="text-center py-4 text-xs text-muted-foreground">
453 Loading more...
454 </div>
455 )}
456 </div>
457 )}
458 </div>
459 )
460 }
461
462 // User stats list view (default)
463 return (
464 <div className="p-4 space-y-3 w-full">
465 <div className="flex flex-wrap items-center justify-between gap-2">
466 <h3 className="text-lg font-semibold">Blossom Storage Admin</h3>
467 <Button size="sm" onClick={handleRefresh} disabled={isLoadingUsers}>
468 {isLoadingUsers ? 'Loading...' : 'Refresh'}
469 </Button>
470 </div>
471
472 {error && (
473 <div className="rounded-md bg-destructive/10 text-destructive p-3 text-sm">{error}</div>
474 )}
475
476 {/* Summary stats */}
477 <div className="flex gap-4 text-sm">
478 <div className="rounded-lg bg-card p-3 flex-1 text-center">
479 <div className="text-2xl font-bold">{userStats.length}</div>
480 <div className="text-muted-foreground">Users</div>
481 </div>
482 <div className="rounded-lg bg-card p-3 flex-1 text-center">
483 <div className="text-2xl font-bold">{totalBlobs}</div>
484 <div className="text-muted-foreground">Blobs</div>
485 </div>
486 <div className="rounded-lg bg-card p-3 flex-1 text-center">
487 <div className="text-2xl font-bold">{formatSize(totalStorage)}</div>
488 <div className="text-muted-foreground">Total</div>
489 </div>
490 </div>
491
492 {/* User list */}
493 {isLoadingUsers ? (
494 <div className="text-center py-8 text-muted-foreground">Loading user statistics...</div>
495 ) : userStats.length === 0 ? (
496 <div className="text-center py-8 text-muted-foreground">
497 No users have uploaded files yet.
498 </div>
499 ) : (
500 <div className="space-y-1">
501 {userStats.map((stat) => (
502 <button
503 key={stat.pubkey}
504 className="w-full flex items-center gap-3 rounded-md bg-card px-3 py-2.5 text-left hover:bg-accent transition-colors"
505 onClick={() => setSelectedUser(stat)}
506 >
507 <div className="w-9 h-9 rounded-full overflow-hidden bg-muted shrink-0 flex items-center justify-center">
508 {stat.profile?.picture ? (
509 <img
510 src={stat.profile.picture}
511 alt=""
512 className="w-full h-full object-cover"
513 />
514 ) : (
515 <span className="text-xs text-muted-foreground">?</span>
516 )}
517 </div>
518 <div className="flex-1 min-w-0">
519 <div className="font-medium truncate">
520 {stat.profile?.name || truncateNpub(hexToNpub(stat.pubkey))}
521 </div>
522 <div className="text-xs text-muted-foreground font-mono truncate">
523 {truncateNpub(hexToNpub(stat.pubkey))}
524 </div>
525 </div>
526 <div className="text-right shrink-0">
527 <div className="text-sm font-medium">{stat.blob_count} files</div>
528 <div className="text-xs text-muted-foreground">
529 {formatSize(stat.total_size_bytes)}
530 </div>
531 </div>
532 </button>
533 ))}
534 </div>
535 )}
536 </div>
537 )
538 }
539