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                &larr; 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) &middot; {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