BlossomView.svelte raw

   1  <script>
   2      import { createEventDispatcher, onMount, onDestroy, tick } from "svelte";
   3      import { npubEncode } from "nostr-tools/nip19";
   4      import { SimplePool } from "nostr-tools/pool";
   5      import { fetchUserProfile, nostrClient } from "./nostr.js";
   6      import { getApiBase, getRelayUrls } from "./config.js";
   7      import { publishEventWithAuth } from "./websocket-auth.js";
   8  
   9      export let isLoggedIn = false;
  10      export let userPubkey = "";
  11      export let userSigner = null;
  12      export let currentEffectiveRole = "";
  13  
  14      const dispatch = createEventDispatcher();
  15  
  16      let blobs = [];
  17      let isLoading = false;
  18      let error = "";
  19  
  20      // Upload state
  21      let selectedFiles = [];
  22      let isUploading = false;
  23      let uploadProgress = "";
  24      let fileInput;
  25  
  26      // Modal state
  27      let showModal = false;
  28      let selectedBlob = null;
  29      let zoomLevel = 1;
  30      const MIN_ZOOM = 0.25;
  31      const MAX_ZOOM = 4;
  32      const ZOOM_STEP = 0.25;
  33  
  34      // Responsive variants state
  35      let blobVariants = [];
  36      let isLoadingVariants = false;
  37      let copiedVariant = null;
  38      let isDeletingVariants = false;
  39      let responsiveBlobs = new Set();  // Original hashes that have variants
  40      let variantHashes = new Set();    // Variant hashes to filter from list
  41  
  42      // TTL cache for variant fetches - prevents stale IndexedDB fallback
  43      // Map<blobHash, { timestamp: number, hasVariants: boolean }>
  44      const variantFetchCache = new Map();
  45      const VARIANT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
  46  
  47      // Admin view state
  48      let isAdminView = false;
  49      let adminUserStats = [];
  50      let isLoadingAdmin = false;
  51      let selectedAdminUser = null;
  52      let selectedUserBlobs = [];
  53  
  54      // Sort state
  55      let sortBy = "date"; // "date" or "size"
  56      let sortOrder = "desc"; // "asc" or "desc"
  57  
  58      // Infinite scroll state
  59      const SCROLL_PAGE_SIZE = 40;
  60      let visibleCount = SCROLL_PAGE_SIZE;
  61      let scrollSentinel;
  62      let scrollObserver;
  63  
  64      $: canAccess = isLoggedIn && userPubkey;
  65      $: isAdmin = currentEffectiveRole === "admin" || currentEffectiveRole === "owner";
  66  
  67      // Sort and display blobs (filter out variants)
  68      // Note: variantHashes.size forces reactivity when Set changes
  69      $: rawBlobs = selectedAdminUser ? selectedUserBlobs : blobs;
  70      $: filteredBlobs = (variantHashes.size, rawBlobs.filter(b => !variantHashes.has(b.sha256)));
  71      $: allSortedBlobs = sortBlobs(filteredBlobs, sortBy, sortOrder);
  72      // Infinite scroll: only render up to visibleCount items
  73      $: displayBlobs = allSortedBlobs ? allSortedBlobs.slice(0, visibleCount) : [];
  74      $: hasMoreBlobs = allSortedBlobs && visibleCount < allSortedBlobs.length;
  75  
  76      // Reset visible count when sort/filter changes (not when items are added/removed)
  77      let prevSortKey = "";
  78      $: {
  79          const key = `${sortBy}-${sortOrder}-${selectedAdminUser?.pubkey || ""}`;
  80          if (key !== prevSortKey) {
  81              prevSortKey = key;
  82              visibleCount = SCROLL_PAGE_SIZE;
  83          }
  84      }
  85  
  86      // Force Svelte to track selectedHashes changes for checkbox reactivity
  87      $: selectedHashesSize = selectedHashes.size;
  88  
  89      function sortBlobs(blobList, by, order) {
  90          if (!blobList || blobList.length === 0) return blobList;
  91          const sorted = [...blobList].sort((a, b) => {
  92              let cmp = 0;
  93              if (by === "date") {
  94                  cmp = (a.uploaded || 0) - (b.uploaded || 0);
  95              } else if (by === "size") {
  96                  cmp = (a.size || 0) - (b.size || 0);
  97              }
  98              return order === "desc" ? -cmp : cmp;
  99          });
 100          return sorted;
 101      }
 102  
 103      function toggleSort(newSortBy) {
 104          if (sortBy === newSortBy) {
 105              sortOrder = sortOrder === "desc" ? "asc" : "desc";
 106          } else {
 107              sortBy = newSortBy;
 108              sortOrder = "desc";
 109          }
 110      }
 111  
 112      // Track if we've loaded once to prevent repeated loads
 113      let hasLoadedOnce = false;
 114  
 115      /**
 116       * Create Blossom auth header (kind 24242) per BUD-01 spec
 117       * @param {object} signer - The signer instance
 118       * @param {string} verb - The action verb (list, get, upload, delete)
 119       * @param {string} sha256Hex - Optional SHA256 hash for x tag
 120       * @returns {Promise<string|null>} Base64 encoded auth header or null
 121       */
 122      async function createBlossomAuth(signer, verb, sha256Hex = null) {
 123          if (!signer) {
 124              console.log("No signer available for Blossom auth");
 125              return null;
 126          }
 127  
 128          try {
 129              const now = Math.floor(Date.now() / 1000);
 130              const expiration = now + 60; // 60 seconds from now
 131  
 132              const tags = [
 133                  ["t", verb],
 134                  ["expiration", expiration.toString()],
 135              ];
 136  
 137              // Add x tag for blob-specific operations
 138              if (sha256Hex) {
 139                  tags.push(["x", sha256Hex]);
 140              }
 141  
 142              const authEvent = {
 143                  kind: 24242,
 144                  created_at: now,
 145                  tags: tags,
 146                  content: `Blossom ${verb} operation`,
 147              };
 148  
 149              const signedEvent = await signer.signEvent(authEvent);
 150              // Use standard base64 encoding per BUD-01 spec
 151              return btoa(JSON.stringify(signedEvent));
 152          } catch (err) {
 153              console.error("Error creating Blossom auth:", err);
 154              return null;
 155          }
 156      }
 157  
 158      function loadMoreBlobs() {
 159          if (hasMoreBlobs) {
 160              visibleCount += SCROLL_PAGE_SIZE;
 161          }
 162      }
 163  
 164      function setupScrollObserver() {
 165          if (scrollObserver) scrollObserver.disconnect();
 166          if (!scrollSentinel) return;
 167          // rootMargin: 150% bottom means trigger when sentinel is 1.5 viewports away
 168          scrollObserver = new IntersectionObserver(
 169              (entries) => {
 170                  if (entries[0].isIntersecting) {
 171                      loadMoreBlobs();
 172                  }
 173              },
 174              { rootMargin: "0px 0px 150% 0px" }
 175          );
 176          scrollObserver.observe(scrollSentinel);
 177      }
 178  
 179      // Re-attach observer whenever the sentinel element changes
 180      $: if (scrollSentinel) { setupScrollObserver(); }
 181  
 182      onMount(() => {
 183          if (canAccess && !hasLoadedOnce) {
 184              hasLoadedOnce = true;
 185              loadBlobs();
 186          }
 187      });
 188  
 189      onDestroy(() => {
 190          if (scrollObserver) scrollObserver.disconnect();
 191      });
 192  
 193      // Load once when canAccess becomes true (for when user logs in after mount)
 194      $: if (canAccess && !hasLoadedOnce && !isLoading) {
 195          hasLoadedOnce = true;
 196          loadBlobs();
 197      }
 198  
 199      // Parse a single binding event into {originalHash, variantHashes[]}
 200      function parseBindingEvent(ev) {
 201          let originalHash = null;
 202          const allHashes = [];
 203  
 204          if (ev.kind === 30063) {
 205              const dTag = ev.tags.find(t => t[0] === "d");
 206              originalHash = dTag?.[1];
 207              for (const tag of ev.tags) {
 208                  if (tag[0] === "x" && tag[1]) {
 209                      allHashes.push(tag[1]);
 210                  }
 211              }
 212          } else if (ev.kind === 1063) {
 213              for (const tag of ev.tags) {
 214                  if (tag[0] === "imeta") {
 215                      const variantField = tag.find(f => f.startsWith("variant "));
 216                      const xField = tag.find(f => f.startsWith("x "));
 217                      if (xField) {
 218                          const hash = xField.substring(2);
 219                          allHashes.push(hash);
 220                          if (variantField === "variant original") {
 221                              originalHash = hash;
 222                          }
 223                      }
 224                  } else if (tag[0] === "x" && tag[1]) {
 225                      if (!allHashes.includes(tag[1])) {
 226                          allHashes.push(tag[1]);
 227                      }
 228                  }
 229              }
 230          }
 231  
 232          return { originalHash, allHashes };
 233      }
 234  
 235      // Collect parsed binding events into originals/variants sets
 236      function collectBindingEvents(events, originals, variants) {
 237          for (const ev of events) {
 238              const { originalHash, allHashes } = parseBindingEvent(ev);
 239              if (originalHash) {
 240                  originals.add(originalHash);
 241                  for (const hash of allHashes) {
 242                      if (hash !== originalHash) {
 243                          variants.add(hash);
 244                      }
 245                  }
 246              }
 247          }
 248      }
 249  
 250      const RELAY_LIMIT = 256;
 251  
 252      async function loadResponsiveBlobs(pubkey, merge = false) {
 253          try {
 254              const relays = getRelayUrls();
 255              const relayUrl = relays[0];
 256              const pool = nostrClient.getPool();
 257              const originals = merge ? new Set(responsiveBlobs) : new Set();
 258              const variants = merge ? new Set(variantHashes) : new Set();
 259  
 260              const baseFilter = { kinds: [30063, 1063], authors: [pubkey] };
 261  
 262              // Step 1: COUNT to know total binding events
 263              let totalCount;
 264              try {
 265                  totalCount = await nostrClient.countEvents(relayUrl, baseFilter);
 266                  console.log("Binding event COUNT:", totalCount);
 267              } catch (e) {
 268                  console.warn("COUNT not supported, falling back to sequential fetch:", e);
 269                  totalCount = null;
 270              }
 271  
 272              if (totalCount !== null && totalCount <= RELAY_LIMIT) {
 273                  // Fits in one query
 274                  const events = await pool.querySync(relays, { ...baseFilter, limit: RELAY_LIMIT });
 275                  collectBindingEvents(events, originals, variants);
 276                  console.log("Found responsive blobs:", originals.size, "variants to hide:", variants.size, "total:", events.length);
 277              } else {
 278                  // Need parallel windowed queries. Determine how many windows we need.
 279                  // Use half the relay limit per window to avoid edge-case truncation.
 280                  const windowLimit = Math.floor(RELAY_LIMIT / 2);
 281                  const estimatedCount = totalCount || 512;
 282                  const numWindows = Math.max(2, Math.ceil(estimatedCount / windowLimit));
 283  
 284                  // Partition the time range: query the newest page first to find time bounds
 285                  const probeEvents = await pool.querySync(relays, { ...baseFilter, limit: RELAY_LIMIT });
 286                  if (probeEvents.length === 0) {
 287                      responsiveBlobs = originals;
 288                      variantHashes = variants;
 289                      return;
 290                  }
 291  
 292                  let newest = 0;
 293                  let oldest = Infinity;
 294                  for (const ev of probeEvents) {
 295                      if (ev.created_at > newest) newest = ev.created_at;
 296                      if (ev.created_at < oldest) oldest = ev.created_at;
 297                  }
 298  
 299                  // If probe got everything, no need for parallel queries
 300                  if (probeEvents.length < RELAY_LIMIT) {
 301                      collectBindingEvents(probeEvents, originals, variants);
 302                  } else {
 303                      // Build time windows spanning from oldest to newest
 304                      // Extend oldest bound further back to catch events before the probe's oldest
 305                      const rangeStart = oldest - (newest - oldest);
 306                      const rangeEnd = newest + 1;
 307                      const windowSize = Math.ceil((rangeEnd - rangeStart) / numWindows);
 308  
 309                      const windowFilters = [];
 310                      for (let i = 0; i < numWindows; i++) {
 311                          const since = rangeStart + (i * windowSize);
 312                          const until = (i === numWindows - 1) ? rangeEnd : rangeStart + ((i + 1) * windowSize) - 1;
 313                          windowFilters.push({
 314                              ...baseFilter,
 315                              since,
 316                              until,
 317                              limit: RELAY_LIMIT,
 318                          });
 319                      }
 320  
 321                      // Fire all window queries in parallel
 322                      const windowResults = await Promise.all(
 323                          windowFilters.map(f => pool.querySync(relays, f))
 324                      );
 325  
 326                      // Collect results and track windows that hit the limit (may have more)
 327                      let totalFetched = 0;
 328                      const saturatedWindows = [];
 329  
 330                      for (let i = 0; i < windowResults.length; i++) {
 331                          const events = windowResults[i];
 332                          totalFetched += events.length;
 333                          collectBindingEvents(events, originals, variants);
 334  
 335                          if (events.length >= RELAY_LIMIT) {
 336                              // This window was truncated - find its oldest event to page from
 337                              let windowOldest = Infinity;
 338                              for (const ev of events) {
 339                                  if (ev.created_at < windowOldest) windowOldest = ev.created_at;
 340                              }
 341                              saturatedWindows.push({
 342                                  since: windowFilters[i].since,
 343                                  until: windowOldest - 1,
 344                              });
 345                          }
 346                      }
 347  
 348                      // Mop up: re-query any saturated windows with tighter bounds
 349                      if (saturatedWindows.length > 0 && (totalCount === null || totalFetched < totalCount)) {
 350                          console.log("Re-querying", saturatedWindows.length, "saturated windows");
 351                          const mopUpResults = await Promise.all(
 352                              saturatedWindows.map(w => pool.querySync(relays, {
 353                                  ...baseFilter,
 354                                  since: w.since,
 355                                  until: w.until,
 356                                  limit: RELAY_LIMIT,
 357                              }))
 358                          );
 359                          for (const events of mopUpResults) {
 360                              totalFetched += events.length;
 361                              collectBindingEvents(events, originals, variants);
 362                          }
 363                      }
 364  
 365                      console.log("Found responsive blobs:", originals.size, "variants to hide:", variants.size, "total fetched:", totalFetched);
 366                  }
 367              }
 368  
 369              responsiveBlobs = originals;
 370              variantHashes = variants;
 371          } catch (err) {
 372              console.warn("Failed to load responsive blob info:", err);
 373              if (!merge) {
 374                  responsiveBlobs = new Set();
 375                  variantHashes = new Set();
 376              }
 377          }
 378      }
 379  
 380      async function loadBlobs() {
 381          if (!userPubkey) {
 382              console.log("loadBlobs: no userPubkey, skipping");
 383              return;
 384          }
 385  
 386          console.log("loadBlobs: starting, userSigner available:", !!userSigner);
 387          isLoading = true;
 388          error = "";
 389  
 390          try {
 391              await loadResponsiveBlobs(userPubkey, true);
 392  
 393              const url = `${getApiBase()}/blossom/list/${userPubkey}`;
 394              const authHeader = await createBlossomAuth(userSigner, "list");
 395              const response = await fetch(url, {
 396                  headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
 397              });
 398  
 399              if (!response.ok) {
 400                  throw new Error(`Failed to load blobs: ${response.statusText}`);
 401              }
 402  
 403              const data = await response.json();
 404              const blobList = Array.isArray(data) ? data : [];
 405              blobs = [...blobList].sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
 406              console.log("Loaded blobs:", blobs.length);
 407          } catch (err) {
 408              console.error("Error loading blobs:", err);
 409              error = err.message || "Failed to load blobs";
 410          } finally {
 411              isLoading = false;
 412          }
 413      }
 414  
 415      function formatSize(bytes) {
 416          if (!bytes) return "0 B";
 417          const units = ["B", "KB", "MB", "GB"];
 418          let i = 0;
 419          let size = bytes;
 420          while (size >= 1024 && i < units.length - 1) {
 421              size /= 1024;
 422              i++;
 423          }
 424          return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
 425      }
 426  
 427      function formatDate(timestamp) {
 428          if (!timestamp) return "Unknown";
 429          return new Date(timestamp * 1000).toLocaleString();
 430      }
 431  
 432      function truncateHash(hash) {
 433          if (!hash) return "";
 434          return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
 435      }
 436  
 437      function getMimeCategory(mimeType) {
 438          if (!mimeType) return "unknown";
 439          if (mimeType.startsWith("image/")) return "image";
 440          if (mimeType.startsWith("video/")) return "video";
 441          if (mimeType.startsWith("audio/")) return "audio";
 442          return "file";
 443      }
 444  
 445      function getMimeIcon(mimeType) {
 446          const category = getMimeCategory(mimeType);
 447          switch (category) {
 448              case "image": return "🖼️";
 449              case "video": return "🎬";
 450              case "audio": return "🎵";
 451              default: return "📄";
 452          }
 453      }
 454  
 455      function openModal(blob) {
 456          selectedBlob = blob;
 457          zoomLevel = 1;
 458          showModal = true;
 459          blobVariants = [];
 460          copiedVariant = null;
 461          // Fetch variants for images
 462          if (getMimeCategory(blob.type) === "image") {
 463              fetchBlobVariants(blob.sha256);
 464          }
 465      }
 466  
 467      function closeModal() {
 468          showModal = false;
 469          selectedBlob = null;
 470          zoomLevel = 1;
 471          blobVariants = [];
 472          copiedVariant = null;
 473      }
 474  
 475      /**
 476       * Parse imeta tag fields into an object
 477       */
 478      function parseImetaTag(tag) {
 479          const fields = {};
 480          for (let i = 1; i < tag.length; i++) {
 481              const part = tag[i];
 482              const spaceIndex = part.indexOf(' ');
 483              if (spaceIndex > 0) {
 484                  const key = part.substring(0, spaceIndex);
 485                  const value = part.substring(spaceIndex + 1);
 486                  fields[key] = value;
 487              }
 488          }
 489          return fields;
 490      }
 491  
 492      // Track current variant fetch to prevent race conditions
 493      let currentVariantFetch = null;
 494  
 495      /**
 496       * Fetch binding events (kind 30063 or 1063) for a blob hash
 497       */
 498      async function fetchBlobVariants(sha256Hex) {
 499          // Store this request's hash to check later
 500          currentVariantFetch = sha256Hex;
 501          isLoadingVariants = true;
 502          blobVariants = [];
 503  
 504          try {
 505              const relays = getRelayUrls();
 506              const pool = nostrClient.getPool();
 507  
 508              // Use the correct pubkey - if viewing another user's blobs, use their pubkey
 509              const targetPubkey = selectedAdminUser?.pubkey || userPubkey;
 510  
 511              // Query for kind 30063 (ORLY format) with #d tag
 512              const filter30063 = {
 513                  kinds: [30063],
 514                  "#d": [sha256Hex],
 515                  limit: 10
 516              };
 517              if (targetPubkey) {
 518                  filter30063.authors = [targetPubkey];
 519              }
 520  
 521              // Query for kind 1063 (NIP-94/smesh format) with #x tag
 522              const filter1063 = {
 523                  kinds: [1063],
 524                  "#x": [sha256Hex],
 525                  limit: 10
 526              };
 527              if (targetPubkey) {
 528                  filter1063.authors = [targetPubkey];
 529              }
 530  
 531              console.log("Querying for variants with filters:", JSON.stringify([filter30063, filter1063]));
 532              // Query both filters in parallel
 533              const [events30063, events1063] = await Promise.all([
 534                  pool.querySync(relays, filter30063),
 535                  pool.querySync(relays, filter1063)
 536              ]);
 537              let events = [...events30063, ...events1063];
 538  
 539              // Check if this is still the current request (user might have switched blobs)
 540              if (currentVariantFetch !== sha256Hex) {
 541                  console.log("Variant fetch cancelled - different blob selected");
 542                  return;
 543              }
 544  
 545              console.log(`Found ${events.length} binding events from relay`);
 546  
 547              // If no events from relay, check local cache as fallback - but respect TTL
 548              if (events.length === 0) {
 549                  const cached = variantFetchCache.get(sha256Hex);
 550                  const now = Date.now();
 551                  const cacheIsFresh = cached && (now - cached.timestamp) < VARIANT_CACHE_TTL_MS;
 552  
 553                  if (cacheIsFresh) {
 554                      // Cache is fresh - trust it over stale IndexedDB data
 555                      // If cached says no variants, we're done; if it says has variants but relay
 556                      // returned nothing, the binding event was likely deleted - trust relay
 557                      console.log(`Cache is fresh (${cached.hasVariants ? 'had' : 'no'} variants), trusting relay result`);
 558                  } else {
 559                      // Cache is stale or missing - check IndexedDB as fallback
 560                      console.log("No events from relay, cache stale, checking IndexedDB...");
 561                      try {
 562                          const { queryEventsFromDB } = await import('./nostr.js');
 563                          const cachedEvents = await queryEventsFromDB([filter30063, filter1063]);
 564                          if (cachedEvents.length > 0) {
 565                              console.log(`Found ${cachedEvents.length} binding events in IndexedDB cache`);
 566                              events = cachedEvents;
 567                          }
 568                      } catch (cacheErr) {
 569                          console.warn("Failed to query IndexedDB cache:", cacheErr);
 570                      }
 571                  }
 572              }
 573  
 574              // Update the TTL cache with current result
 575              variantFetchCache.set(sha256Hex, {
 576                  timestamp: Date.now(),
 577                  hasVariants: events.length > 0
 578              });
 579  
 580              // Check again after cache query
 581              if (currentVariantFetch !== sha256Hex) {
 582                  return;
 583              }
 584  
 585              if (events.length === 0) {
 586                  // No binding events found - remove from responsiveBlobs if present
 587                  if (responsiveBlobs.has(sha256Hex)) {
 588                      console.log(`Removing ${sha256Hex} from responsiveBlobs (no binding events found)`);
 589                      responsiveBlobs = new Set([...responsiveBlobs].filter(h => h !== sha256Hex));
 590                  }
 591                  isLoadingVariants = false;
 592                  return;
 593              }
 594  
 595              // Parse variants from the most recent event
 596              const latestEvent = events.reduce((a, b) =>
 597                  a.created_at > b.created_at ? a : b
 598              );
 599  
 600              const variants = [];
 601              for (const tag of latestEvent.tags) {
 602                  if (tag[0] !== "imeta") continue;
 603  
 604                  const fields = parseImetaTag(tag);
 605                  if (!fields.url || !fields.x || !fields.dim) continue;
 606  
 607                  const dimMatch = fields.dim.match(/^(\d+)x(\d+)$/);
 608                  if (!dimMatch) continue;
 609  
 610                  variants.push({
 611                      variant: fields.variant || "original",
 612                      url: fields.url,
 613                      sha256: fields.x,
 614                      width: parseInt(dimMatch[1], 10),
 615                      height: parseInt(dimMatch[2], 10),
 616                      mimeType: fields.m || "image/jpeg",
 617                      size: fields.size ? parseInt(fields.size, 10) : null
 618                  });
 619              }
 620  
 621              // Final check before updating state
 622              if (currentVariantFetch !== sha256Hex) {
 623                  return;
 624              }
 625  
 626              // Sort by width (smallest first)
 627              variants.sort((a, b) => a.width - b.width);
 628              blobVariants = variants;
 629  
 630          } catch (err) {
 631              console.error("Failed to fetch variants:", err);
 632          }
 633  
 634          if (currentVariantFetch === sha256Hex) {
 635              isLoadingVariants = false;
 636          }
 637      }
 638  
 639      /**
 640       * Copy variant URL to clipboard
 641       */
 642      async function copyVariantUrl(variant) {
 643          try {
 644              await navigator.clipboard.writeText(variant.url);
 645              copiedVariant = variant.sha256;
 646              setTimeout(() => {
 647                  if (copiedVariant === variant.sha256) {
 648                      copiedVariant = null;
 649                  }
 650              }, 2000);
 651          } catch (err) {
 652              console.error("Failed to copy:", err);
 653          }
 654      }
 655  
 656      /**
 657       * Format variant label for display
 658       */
 659      function formatVariantLabel(variant) {
 660          const labels = {
 661              thumb: "Thumbnail",
 662              mobile: "Mobile",
 663              "mobile-lg": "Mobile+",
 664              desktop: "Desktop",
 665              "desktop-lg": "Desktop+",
 666              original: "Original"
 667          };
 668          return labels[variant.variant] || variant.variant;
 669      }
 670  
 671      function zoomIn() {
 672          if (zoomLevel < MAX_ZOOM) {
 673              zoomLevel = Math.min(MAX_ZOOM, zoomLevel + ZOOM_STEP);
 674          }
 675      }
 676  
 677      function zoomOut() {
 678          if (zoomLevel > MIN_ZOOM) {
 679              zoomLevel = Math.max(MIN_ZOOM, zoomLevel - ZOOM_STEP);
 680          }
 681      }
 682  
 683      function handleKeydown(event) {
 684          if (!showModal) return;
 685          if (event.key === "Escape") {
 686              closeModal();
 687          } else if (event.key === "+" || event.key === "=") {
 688              zoomIn();
 689          } else if (event.key === "-") {
 690              zoomOut();
 691          }
 692      }
 693  
 694      function getBlobUrl(blob) {
 695          // Prefer the URL from the API response (includes extension for proper MIME handling)
 696          if (blob.url) {
 697              // Already an absolute URL - return as-is
 698              if (blob.url.startsWith("http://") || blob.url.startsWith("https://")) {
 699                  return blob.url;
 700              }
 701              // Starts with / - it's a path, prepend API base
 702              if (blob.url.startsWith("/")) {
 703                  return `${getApiBase()}${blob.url}`;
 704              }
 705              // No protocol - looks like host:port/path, add http://
 706              // This handles cases like "localhost:3334/blossom/..."
 707              return `http://${blob.url}`;
 708          }
 709          // Fallback: construct URL with sha256 only
 710          return `${getApiBase()}/blossom/${blob.sha256}`;
 711      }
 712  
 713      function getThumbnailUrl(blob) {
 714          // Get thumbnail URL for images (128px using Lanczos scaling)
 715          const baseUrl = getBlobUrl(blob);
 716          const sep = baseUrl.includes('?') ? '&' : '?';
 717          return `${baseUrl}${sep}w=128`;
 718      }
 719  
 720      function openLoginModal() {
 721          dispatch("openLoginModal");
 722      }
 723  
 724      async function deleteBlob(blob) {
 725          const hasVariants = responsiveBlobs.has(blob.sha256) || blobVariants.length > 0;
 726          const confirmMsg = hasVariants
 727              ? `Delete blob ${truncateHash(blob.sha256)} and all its responsive variants?`
 728              : `Delete blob ${truncateHash(blob.sha256)}?`;
 729  
 730          if (!confirm(confirmMsg)) return;
 731  
 732          try {
 733              // If this blob has variants, delete them first
 734              if (hasVariants) {
 735                  // Get variant hashes from blobVariants if available, otherwise query
 736                  let variantHashesToDelete = blobVariants
 737                      .filter(v => v.sha256 !== blob.sha256)
 738                      .map(v => v.sha256);
 739  
 740                  // If we don't have variants loaded, query for them
 741                  // Track the binding event for deletion
 742                  let bindingEventToDelete = null;
 743                  if (variantHashesToDelete.length === 0 && responsiveBlobs.has(blob.sha256)) {
 744                      try {
 745                          const relays = getRelayUrls();
 746                          const pool = nostrClient.getPool();
 747  
 748                          // Query both kind 30063 (ORLY legacy) and kind 1063 (NIP-94)
 749                          const filter30063 = {
 750                              kinds: [30063],
 751                              "#d": [blob.sha256],
 752                              authors: userPubkey ? [userPubkey] : undefined,
 753                              limit: 1
 754                          };
 755                          const filter1063 = {
 756                              kinds: [1063],
 757                              "#x": [blob.sha256],
 758                              authors: userPubkey ? [userPubkey] : undefined,
 759                              limit: 1
 760                          };
 761  
 762                          const [events30063, events1063] = await Promise.all([
 763                              pool.querySync(relays, filter30063),
 764                              pool.querySync(relays, filter1063)
 765                          ]);
 766  
 767                          const events = [...events30063, ...events1063];
 768                          if (events.length > 0) {
 769                              // Use most recent event
 770                              bindingEventToDelete = events.reduce((a, b) =>
 771                                  a.created_at > b.created_at ? a : b
 772                              );
 773                              for (const tag of bindingEventToDelete.tags) {
 774                                  if (tag[0] === "x" && tag[1] && tag[1] !== blob.sha256) {
 775                                      variantHashesToDelete.push(tag[1]);
 776                                  }
 777                              }
 778                          }
 779                      } catch (err) {
 780                          console.warn("Failed to query variants for deletion:", err);
 781                      }
 782                  }
 783  
 784                  // Delete variant blobs
 785                  for (const variantHash of variantHashesToDelete) {
 786                      try {
 787                          const variantUrl = `${getApiBase()}/blossom/${variantHash}`;
 788                          const variantAuth = await createBlossomAuth(userSigner, "delete", variantHash);
 789                          await fetch(variantUrl, {
 790                              method: "DELETE",
 791                              headers: variantAuth ? { Authorization: `Nostr ${variantAuth}` } : {},
 792                          });
 793                          console.log("Deleted variant:", variantHash);
 794                      } catch (err) {
 795                          console.warn("Failed to delete variant:", variantHash, err);
 796                      }
 797                  }
 798  
 799                  // Publish a deletion event for the binding event (kind 5)
 800                  try {
 801                      const deleteTags = [];
 802                      if (bindingEventToDelete) {
 803                          if (bindingEventToDelete.kind === 30063) {
 804                              // Addressable event - use 'a' tag
 805                              deleteTags.push(["a", `30063:${userPubkey}:${blob.sha256}`]);
 806                          } else {
 807                              // Regular event (kind 1063) - use 'e' tag with event ID
 808                              deleteTags.push(["e", bindingEventToDelete.id]);
 809                          }
 810                      } else {
 811                          // Fallback to 'a' tag for legacy events
 812                          deleteTags.push(["a", `30063:${userPubkey}:${blob.sha256}`]);
 813                      }
 814  
 815                      const deleteEvent = {
 816                          kind: 5,
 817                          created_at: Math.floor(Date.now() / 1000),
 818                          content: "Deleted responsive variants",
 819                          tags: deleteTags,
 820                      };
 821                      const signedDelete = await userSigner.signEvent(deleteEvent);
 822                      const relayUrl = getRelayUrls()[0];
 823                      await publishEventWithAuth(relayUrl, signedDelete, userSigner, userPubkey);
 824                      console.log("Published deletion event for binding:", signedDelete.id);
 825                  } catch (err) {
 826                      console.warn("Failed to publish deletion event:", err);
 827                  }
 828  
 829                  // Update local state
 830                  responsiveBlobs = new Set([...responsiveBlobs].filter(h => h !== blob.sha256));
 831                  variantHashes = new Set([...variantHashes].filter(h => !variantHashesToDelete.includes(h)));
 832              }
 833  
 834              // Delete the original blob
 835              const url = `${getApiBase()}/blossom/${blob.sha256}`;
 836              const authHeader = await createBlossomAuth(userSigner, "delete", blob.sha256);
 837              const response = await fetch(url, {
 838                  method: "DELETE",
 839                  headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
 840              });
 841  
 842              if (!response.ok) {
 843                  throw new Error(`Failed to delete: ${response.statusText}`);
 844              }
 845  
 846              console.log("Delete successful, removing blob from list:", blob.sha256);
 847              blobs = blobs.filter(b => b.sha256 !== blob.sha256);
 848              console.log("Blobs after filter:", blobs.length);
 849              if (selectedBlob?.sha256 === blob.sha256) {
 850                  closeModal();
 851              }
 852          } catch (err) {
 853              console.error("Error deleting blob:", err);
 854              alert(`Failed to delete blob: ${err.message}`);
 855          }
 856      }
 857  
 858      async function deleteVariants(blob) {
 859          if (!confirm(`Delete all responsive variants for this image?\n\nThis will remove all generated sizes (thumbnail, mobile, desktop) but keep the original.`)) return;
 860  
 861          isDeletingVariants = true;
 862  
 863          try {
 864              const url = `${getApiBase()}/blossom/delete-variants/${blob.sha256}`;
 865              const authHeader = await createBlossomAuth(userSigner, "delete", blob.sha256);
 866              const response = await fetch(url, {
 867                  method: "DELETE",
 868                  headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
 869              });
 870  
 871              if (!response.ok) {
 872                  const reason = response.headers.get("X-Reason") || response.statusText;
 873                  throw new Error(reason);
 874              }
 875  
 876              const result = await response.json();
 877              console.log("Delete variants result:", result);
 878  
 879              // Clear variants list and refresh
 880              blobVariants = [];
 881              alert(`Deleted ${result.deleted} variants.`);
 882          } catch (err) {
 883              console.error("Error deleting variants:", err);
 884              alert(`Failed to delete variants: ${err.message}`);
 885          } finally {
 886              isDeletingVariants = false;
 887          }
 888      }
 889  
 890      function handleFileSelect(event) {
 891          selectedFiles = Array.from(event.target.files);
 892      }
 893  
 894      function triggerFileInput() {
 895          fileInput?.click();
 896      }
 897  
 898      // Static image types that should get variants (excludes animated formats)
 899      const STATIC_IMAGE_TYPES = [
 900          "image/jpeg",
 901          "image/png",
 902          "image/webp",  // Can be animated but usually static
 903          "image/bmp",
 904          "image/tiff",
 905          "image/avif",
 906          "image/heic",
 907          "image/heif",
 908      ];
 909  
 910      function isStaticImage(mimeType) {
 911          return STATIC_IMAGE_TYPES.includes(mimeType);
 912      }
 913  
 914      /**
 915       * Generate variants for a file and upload all (original + variants).
 916       * Order: generate variants -> publish binding event -> upload blobs
 917       */
 918      async function uploadWithVariants(file, fileIndex, totalFiles) {
 919          const baseProgress = `[${fileIndex + 1}/${totalFiles}] ${file.name}`;
 920          const uploadUrl = `${getApiBase()}/blossom/upload`;
 921  
 922          // Step 1: Compute original hash
 923          uploadProgress = `${baseProgress}: Computing hash...`;
 924          const originalData = await file.arrayBuffer();
 925          const originalHash = await computeSHA256(originalData);
 926  
 927          // Step 2: Create ImageBitmap and generate all variants in memory
 928          uploadProgress = `${baseProgress}: Processing image...`;
 929          const imageBitmap = await createImageBitmap(file);
 930          const originalWidth = imageBitmap.width;
 931          const originalHeight = imageBitmap.height;
 932  
 933          const variantBlobs = [];
 934          for (const { name, maxWidth } of VARIANT_SIZES) {
 935              if (originalWidth <= maxWidth) continue;
 936  
 937              uploadProgress = `${baseProgress}: Creating ${name} variant...`;
 938              const resized = await resizeImage(imageBitmap, maxWidth);
 939              const resizedData = await resized.blob.arrayBuffer();
 940              const variantHash = await computeSHA256(resizedData);
 941  
 942              variantBlobs.push({
 943                  name,
 944                  blob: resized.blob,
 945                  hash: variantHash,
 946                  width: resized.width,
 947                  height: resized.height,
 948              });
 949          }
 950          imageBitmap.close();
 951  
 952          // Step 3: Build variant metadata for binding event
 953          const variants = [
 954              {
 955                  variant: "original",
 956                  sha256: originalHash,
 957                  url: `${getApiBase()}/blossom/${originalHash}`,
 958                  width: originalWidth,
 959                  height: originalHeight,
 960                  mimeType: file.type || "image/jpeg",
 961                  size: file.size,
 962              },
 963              ...variantBlobs.map(v => ({
 964                  variant: v.name,
 965                  sha256: v.hash,
 966                  url: `${getApiBase()}/blossom/${v.hash}.jpg`,
 967                  width: v.width,
 968                  height: v.height,
 969                  mimeType: "image/jpeg",
 970                  size: v.blob.size,
 971              })),
 972          ];
 973  
 974          console.log("Variants created:", variants.length, variants.map(v => v.variant));
 975  
 976          // Step 4: Publish binding event FIRST (before uploading blobs)
 977          if (variants.length > 1) {
 978              uploadProgress = `${baseProgress}: Publishing binding event...`;
 979  
 980              // Use kind 1063 (NIP-94 File Metadata) for binding events
 981              const bindingEvent = {
 982                  kind: 1063,
 983                  created_at: Math.floor(Date.now() / 1000),
 984                  content: "",
 985                  tags: [
 986                      ...variants.map(v => [
 987                          "imeta",
 988                          `url ${v.url}`,
 989                          `x ${v.sha256}`,
 990                          `m ${v.mimeType}`,
 991                          `dim ${v.width}x${v.height}`,
 992                          `variant ${v.variant}`,
 993                          `size ${v.size}`,
 994                      ]),
 995                      ...variants.map(v => ["x", v.sha256]),
 996                  ],
 997              };
 998  
 999              const signedEvent = await userSigner.signEvent(bindingEvent);
1000  
1001              // Use websocket auth for proper NIP-42 handling
1002              const relayUrl = getRelayUrls()[0];
1003              const result = await publishEventWithAuth(relayUrl, signedEvent, userSigner, userPubkey);
1004              console.log("Binding event published:", signedEvent.id, result);
1005  
1006              // Update local sets immediately
1007              responsiveBlobs = new Set([...responsiveBlobs, originalHash]);
1008              const newVariantHashes = variants
1009                  .filter(v => v.sha256 !== originalHash)
1010                  .map(v => v.sha256);
1011              variantHashes = new Set([...variantHashes, ...newVariantHashes]);
1012          }
1013  
1014          // Step 5: Upload original blob
1015          uploadProgress = `${baseProgress}: Uploading original...`;
1016          const originalAuth = await createBlossomAuth(userSigner, "upload", originalHash);
1017  
1018          const originalResponse = await fetch(uploadUrl, {
1019              method: "PUT",
1020              headers: {
1021                  "Content-Type": file.type || "application/octet-stream",
1022                  "X-SHA-256": originalHash,
1023                  ...(originalAuth ? { Authorization: `Nostr ${originalAuth}` } : {}),
1024              },
1025              body: file,
1026          });
1027  
1028          if (!originalResponse.ok) {
1029              const reason = originalResponse.headers.get("X-Reason") || originalResponse.statusText;
1030              throw new Error(reason);
1031          }
1032  
1033          const descriptor = await originalResponse.json();
1034  
1035          // Step 6: Upload variant blobs
1036          for (const v of variantBlobs) {
1037              uploadProgress = `${baseProgress}: Uploading ${v.name}...`;
1038  
1039              // Check if variant already exists
1040              const checkUrl = `${getApiBase()}/blossom/${v.hash}`;
1041              const headResponse = await fetch(checkUrl, { method: "HEAD" });
1042  
1043              if (!headResponse.ok) {
1044                  const variantAuth = await createBlossomAuth(userSigner, "upload", v.hash);
1045                  const variantResponse = await fetch(uploadUrl, {
1046                      method: "PUT",
1047                      headers: {
1048                          "Content-Type": "image/jpeg",
1049                          "X-SHA-256": v.hash,
1050                          ...(variantAuth ? { Authorization: `Nostr ${variantAuth}` } : {}),
1051                      },
1052                      body: v.blob,
1053                  });
1054  
1055                  if (!variantResponse.ok) {
1056                      console.warn(`Failed to upload ${v.name} variant:`, variantResponse.statusText);
1057                  }
1058              }
1059          }
1060  
1061          return descriptor;
1062      }
1063  
1064      async function uploadFiles() {
1065          if (selectedFiles.length === 0) return;
1066  
1067          isUploading = true;
1068          error = "";
1069          const uploaded = [];
1070          const failed = [];
1071  
1072          for (let i = 0; i < selectedFiles.length; i++) {
1073              const file = selectedFiles[i];
1074  
1075              try {
1076                  if (isStaticImage(file.type)) {
1077                      // Upload with variant generation for static images
1078                      const descriptor = await uploadWithVariants(file, i, selectedFiles.length);
1079                      console.log("Upload with variants complete:", descriptor);
1080                      uploaded.push(descriptor);
1081                  } else {
1082                      // Regular upload for non-images
1083                      uploadProgress = `Uploading ${i + 1}/${selectedFiles.length}: ${file.name}`;
1084                      const url = `${getApiBase()}/blossom/upload`;
1085                      const authHeader = await createBlossomAuth(userSigner, "upload");
1086  
1087                      const response = await fetch(url, {
1088                          method: "PUT",
1089                          headers: {
1090                              "Content-Type": file.type || "application/octet-stream",
1091                              ...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
1092                          },
1093                          body: file,
1094                      });
1095  
1096                      if (!response.ok) {
1097                          const reason = response.headers.get("X-Reason") || response.statusText;
1098                          throw new Error(reason);
1099                      }
1100  
1101                      const descriptor = await response.json();
1102                      console.log("Upload response:", descriptor);
1103                      uploaded.push(descriptor);
1104                  }
1105              } catch (err) {
1106                  console.error(`Error uploading ${file.name}:`, err);
1107                  failed.push({ name: file.name, error: err.message });
1108              }
1109          }
1110  
1111          isUploading = false;
1112          uploadProgress = "";
1113          selectedFiles = [];
1114          if (fileInput) fileInput.value = "";
1115  
1116          if (uploaded.length > 0) {
1117              console.log("Upload complete, refreshing blob list...");
1118              await loadBlobs();
1119              console.log("Blob list refresh complete, blobs count:", blobs.length);
1120          }
1121  
1122          if (failed.length > 0) {
1123              error = `Failed to upload: ${failed.map(f => f.name).join(", ")}`;
1124          }
1125      }
1126  
1127      // Admin functions
1128      function hexToNpub(pubkeyHex) {
1129          try {
1130              return npubEncode(pubkeyHex);
1131          } catch (e) {
1132              return truncateHash(pubkeyHex);
1133          }
1134      }
1135  
1136      function truncateNpub(npub) {
1137          if (!npub) return "";
1138          return `${npub.slice(0, 12)}...${npub.slice(-8)}`;
1139      }
1140  
1141      async function fetchAdminUserStats() {
1142          isLoadingAdmin = true;
1143          error = "";
1144  
1145          try {
1146              const url = `${getApiBase()}/blossom/admin/users`;
1147              const authHeader = await createBlossomAuth(userSigner, "admin");
1148              const response = await fetch(url, {
1149                  headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
1150              });
1151  
1152              if (!response.ok) {
1153                  throw new Error(`Failed to load user stats: ${response.statusText}`);
1154              }
1155  
1156              adminUserStats = await response.json();
1157  
1158              // Fetch profiles for each user (non-blocking)
1159              for (const stat of adminUserStats) {
1160                  fetchUserProfile(stat.pubkey).then(profile => {
1161                      stat.profile = profile || { name: "", picture: "" };
1162                      adminUserStats = adminUserStats; // trigger reactivity
1163                  }).catch(() => {
1164                      stat.profile = { name: "", picture: "" };
1165                  });
1166              }
1167          } catch (err) {
1168              console.error("Error fetching admin user stats:", err);
1169              error = err.message || "Failed to load user stats";
1170          } finally {
1171              isLoadingAdmin = false;
1172          }
1173      }
1174  
1175      async function loadUserBlobs(pubkeyHex) {
1176          isLoading = true;
1177          error = "";
1178  
1179          try {
1180              // Load the user's responsive blob info (replaces current sets)
1181              await loadResponsiveBlobs(pubkeyHex, false);
1182  
1183              const url = `${getApiBase()}/blossom/list/${pubkeyHex}`;
1184              const authHeader = await createBlossomAuth(userSigner, "list");
1185              const response = await fetch(url, {
1186                  headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
1187              });
1188  
1189              if (!response.ok) {
1190                  throw new Error(`Failed to load user blobs: ${response.statusText}`);
1191              }
1192  
1193              const userBlobData = await response.json();
1194              selectedUserBlobs = [...userBlobData].sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
1195          } catch (err) {
1196              console.error("Error loading user blobs:", err);
1197              error = err.message || "Failed to load user blobs";
1198          } finally {
1199              isLoading = false;
1200          }
1201      }
1202  
1203      function enterAdminView() {
1204          isAdminView = true;
1205          fetchAdminUserStats();
1206      }
1207  
1208      async function exitAdminView() {
1209          isAdminView = false;
1210          adminUserStats = [];
1211          selectedAdminUser = null;
1212          selectedUserBlobs = [];
1213          // Reload responsive blob info for the current user's own blobs
1214          if (userPubkey) {
1215              await loadResponsiveBlobs(userPubkey, false);
1216          }
1217      }
1218  
1219      async function selectUser(userStat) {
1220          selectedAdminUser = {
1221              pubkey: userStat.pubkey,
1222              profile: userStat.profile
1223          };
1224          await loadUserBlobs(userStat.pubkey);
1225      }
1226  
1227      function exitUserView() {
1228          selectedAdminUser = null;
1229          selectedUserBlobs = [];
1230      }
1231  
1232      function handleRefresh() {
1233          if (selectedAdminUser) {
1234              loadUserBlobs(selectedAdminUser.pubkey);
1235          } else if (isAdminView) {
1236              fetchAdminUserStats();
1237          } else {
1238              loadBlobs();
1239          }
1240      }
1241  
1242      // Generate variants state (for single image)
1243      let isGeneratingVariants = false;
1244      let generatingProgress = "";
1245  
1246      // Selection state for bulk delete
1247      let selectedHashes = new Set();
1248      let isDeletingSelected = false;
1249  
1250      function toggleSelection(hash, event) {
1251          event.stopPropagation();
1252          if (selectedHashes.has(hash)) {
1253              selectedHashes.delete(hash);
1254          } else {
1255              selectedHashes.add(hash);
1256          }
1257          selectedHashes = selectedHashes; // trigger reactivity
1258      }
1259  
1260      function isSelected(hash) {
1261          return selectedHashes.has(hash);
1262      }
1263  
1264      async function deleteSelectedBlobs() {
1265          if (selectedHashes.size === 0) return;
1266          if (!confirm(`Delete ${selectedHashes.size} selected file(s)? This cannot be undone.`)) return;
1267  
1268          isDeletingSelected = true;
1269          error = "";
1270          let deleted = 0;
1271          let failed = 0;
1272  
1273          const hashesToDelete = Array.from(selectedHashes);
1274  
1275          for (const hash of hashesToDelete) {
1276              try {
1277                  const url = `${getApiBase()}/blossom/${hash}`;
1278                  const authHeader = await createBlossomAuth(userSigner, "delete", hash);
1279                  const response = await fetch(url, {
1280                      method: "DELETE",
1281                      headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
1282                  });
1283  
1284                  if (response.ok) {
1285                      deleted++;
1286                  } else {
1287                      console.error(`Delete failed for ${hash}: ${response.status} ${response.statusText}`);
1288                      failed++;
1289                  }
1290              } catch (err) {
1291                  console.error(`Failed to delete ${hash}:`, err);
1292                  failed++;
1293              }
1294          }
1295  
1296          // Clear selection
1297          selectedHashes = new Set();
1298  
1299          // Refresh the list
1300          try {
1301              if (selectedAdminUser) {
1302                  await loadUserBlobs(selectedAdminUser.pubkey);
1303              } else {
1304                  await loadBlobs();
1305              }
1306          } catch (err) {
1307              console.error("Failed to refresh blobs:", err);
1308          }
1309  
1310          isDeletingSelected = false;
1311  
1312          if (failed > 0) {
1313              error = `Deleted ${deleted}, failed ${failed}`;
1314          }
1315      }
1316  
1317      // Variant size definitions per NIP-XX
1318      // Selection rule: pick smallest variant >= target width (next-larger)
1319      const VARIANT_SIZES = [
1320          { name: "thumb", maxWidth: 128 },
1321          { name: "mobile-sm", maxWidth: 512 },
1322          { name: "mobile-lg", maxWidth: 1024 },
1323          { name: "desktop-sm", maxWidth: 1536 },
1324          { name: "desktop-md", maxWidth: 2048 },
1325          { name: "desktop-lg", maxWidth: 2560 },
1326      ];
1327  
1328      /**
1329       * Compute SHA256 hash of ArrayBuffer using Web Crypto API
1330       */
1331      async function computeSHA256(data) {
1332          const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1333          const hashArray = Array.from(new Uint8Array(hashBuffer));
1334          return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
1335      }
1336  
1337      /**
1338       * Resize image using Canvas API (GPU accelerated)
1339       * Uses createImageBitmap for hardware acceleration when available
1340       */
1341      async function resizeImage(imageBitmap, maxWidth, quality = 0.85) {
1342          const { width, height } = imageBitmap;
1343  
1344          // Calculate new dimensions maintaining aspect ratio
1345          let newWidth = width;
1346          let newHeight = height;
1347          if (width > maxWidth) {
1348              newWidth = maxWidth;
1349              newHeight = Math.round((height * maxWidth) / width);
1350          }
1351  
1352          // Use OffscreenCanvas if available (better performance)
1353          let canvas;
1354          let ctx;
1355          if (typeof OffscreenCanvas !== "undefined") {
1356              canvas = new OffscreenCanvas(newWidth, newHeight);
1357              ctx = canvas.getContext("2d", { alpha: false });
1358          } else {
1359              canvas = document.createElement("canvas");
1360              canvas.width = newWidth;
1361              canvas.height = newHeight;
1362              ctx = canvas.getContext("2d", { alpha: false });
1363          }
1364  
1365          // Enable image smoothing for quality scaling
1366          ctx.imageSmoothingEnabled = true;
1367          ctx.imageSmoothingQuality = "high";
1368  
1369          // Draw scaled image
1370          ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);
1371  
1372          // Convert to blob
1373          let blob;
1374          if (typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas) {
1375              blob = await canvas.convertToBlob({ type: "image/jpeg", quality });
1376          } else {
1377              blob = await new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", quality));
1378          }
1379  
1380          return {
1381              blob,
1382              width: newWidth,
1383              height: newHeight,
1384          };
1385      }
1386  
1387      /**
1388       * Generate responsive variants for a single image.
1389       * Order: generate variants -> publish binding event -> upload blobs
1390       */
1391      async function generateVariants(blob) {
1392          if (!confirm(`Generate responsive variants for this image?\n\nThis will create thumb (128px), mobile (512px), desktop (1280px), and original variants.`)) {
1393              return;
1394          }
1395  
1396          isGeneratingVariants = true;
1397          generatingProgress = "Loading image...";
1398          error = "";
1399  
1400          try {
1401              // Step 1: Fetch the original image
1402              const imageUrl = getBlobUrl(blob);
1403              const imageResponse = await fetch(imageUrl);
1404              if (!imageResponse.ok) {
1405                  throw new Error("Failed to fetch original image");
1406              }
1407              const imageBlob = await imageResponse.blob();
1408  
1409              // Create ImageBitmap for hardware-accelerated processing
1410              generatingProgress = "Processing image...";
1411              const imageBitmap = await createImageBitmap(imageBlob);
1412              const originalWidth = imageBitmap.width;
1413              const originalHeight = imageBitmap.height;
1414  
1415              // Step 2: Generate all variants in memory (don't upload yet)
1416              const variantBlobs = [];
1417              for (let i = 0; i < VARIANT_SIZES.length; i++) {
1418                  const { name, maxWidth } = VARIANT_SIZES[i];
1419                  if (originalWidth <= maxWidth) continue;
1420  
1421                  generatingProgress = `Creating ${name}...`;
1422                  const resized = await resizeImage(imageBitmap, maxWidth);
1423                  const resizedData = await resized.blob.arrayBuffer();
1424                  const variantHash = await computeSHA256(resizedData);
1425  
1426                  variantBlobs.push({
1427                      name,
1428                      blob: resized.blob,
1429                      hash: variantHash,
1430                      width: resized.width,
1431                      height: resized.height,
1432                  });
1433              }
1434              imageBitmap.close();
1435  
1436              // Step 3: Build variant metadata for binding event
1437              const variants = [
1438                  {
1439                      variant: "original",
1440                      sha256: blob.sha256,
1441                      url: imageUrl,
1442                      width: originalWidth,
1443                      height: originalHeight,
1444                      mimeType: blob.type || "image/jpeg",
1445                      size: imageBlob.size,
1446                  },
1447                  ...variantBlobs.map(v => ({
1448                      variant: v.name,
1449                      sha256: v.hash,
1450                      url: `${getApiBase()}/blossom/${v.hash}.jpg`,
1451                      width: v.width,
1452                      height: v.height,
1453                      mimeType: "image/jpeg",
1454                      size: v.blob.size,
1455                  })),
1456              ];
1457  
1458              if (variants.length <= 1) {
1459                  generatingProgress = "Image too small for variants";
1460                  setTimeout(() => { generatingProgress = ""; }, 2000);
1461                  return;
1462              }
1463  
1464              // Step 4: Publish binding event FIRST with proper auth
1465              // Use kind 1063 (NIP-94 File Metadata) for binding events
1466              generatingProgress = "Publishing binding event...";
1467              const bindingEvent = {
1468                  kind: 1063,
1469                  created_at: Math.floor(Date.now() / 1000),
1470                  content: "",
1471                  tags: [
1472                      ...variants.map(v => [
1473                          "imeta",
1474                          `url ${v.url}`,
1475                          `x ${v.sha256}`,
1476                          `m ${v.mimeType}`,
1477                          `dim ${v.width}x${v.height}`,
1478                          `variant ${v.variant}`,
1479                          `size ${v.size}`,
1480                      ]),
1481                      ...variants.map(v => ["x", v.sha256]),
1482                  ],
1483              };
1484  
1485              const signedEvent = await userSigner.signEvent(bindingEvent);
1486              const relayUrl = getRelayUrls()[0];
1487              const result = await publishEventWithAuth(relayUrl, signedEvent, userSigner, userPubkey);
1488              console.log("Binding event published:", signedEvent.id, result);
1489  
1490              // Update local sets
1491              responsiveBlobs = new Set([...responsiveBlobs, blob.sha256]);
1492              const newVariantHashes = variants
1493                  .filter(v => v.sha256 !== blob.sha256)
1494                  .map(v => v.sha256);
1495              variantHashes = new Set([...variantHashes, ...newVariantHashes]);
1496  
1497              // Step 5: Upload variant blobs
1498              const uploadUrl = `${getApiBase()}/blossom/upload`;
1499              for (const v of variantBlobs) {
1500                  generatingProgress = `Uploading ${v.name}...`;
1501  
1502                  // Check if already exists
1503                  const checkUrl = `${getApiBase()}/blossom/${v.hash}`;
1504                  const headResponse = await fetch(checkUrl, { method: "HEAD" });
1505  
1506                  if (!headResponse.ok) {
1507                      const uploadAuth = await createBlossomAuth(userSigner, "upload", v.hash);
1508                      const uploadResponse = await fetch(uploadUrl, {
1509                          method: "PUT",
1510                          headers: {
1511                              "Content-Type": "image/jpeg",
1512                              "X-SHA-256": v.hash,
1513                              ...(uploadAuth ? { Authorization: `Nostr ${uploadAuth}` } : {}),
1514                          },
1515                          body: v.blob,
1516                      });
1517  
1518                      if (!uploadResponse.ok) {
1519                          console.warn(`Failed to upload ${v.name}:`, uploadResponse.statusText);
1520                      }
1521                  }
1522              }
1523  
1524              generatingProgress = "Done!";
1525  
1526              // Reload variants for the modal
1527              await fetchBlobVariants(blob.sha256);
1528  
1529              setTimeout(() => { generatingProgress = ""; }, 2000);
1530          } catch (err) {
1531              console.error("Error generating variants:", err);
1532              error = err.message || "Failed to generate variants";
1533          } finally {
1534              isGeneratingVariants = false;
1535          }
1536      }
1537  
1538  </script>
1539  
1540  <svelte:window on:keydown={handleKeydown} />
1541  
1542  {#if canAccess}
1543      <div class="blossom-view">
1544          <div class="header-section">
1545              {#if selectedAdminUser}
1546                  <button class="back-btn" on:click={exitUserView}>
1547                      &larr; Back
1548                  </button>
1549                  <h3 class="user-header">
1550                      {#if selectedAdminUser.profile?.picture}
1551                          <img src={selectedAdminUser.profile.picture} alt="" class="header-avatar" />
1552                      {/if}
1553                      {selectedAdminUser.profile?.name || truncateNpub(hexToNpub(selectedAdminUser.pubkey))}
1554                  </h3>
1555              {:else if isAdminView}
1556                  <button class="back-btn" on:click={exitAdminView}>
1557                      &larr; Back
1558                  </button>
1559                  <h3>All Users Storage</h3>
1560              {:else}
1561                  <h3>Blossom Media Storage</h3>
1562              {/if}
1563  
1564              <div class="header-buttons">
1565                  {#if !isAdminView || selectedAdminUser}
1566                      {#if selectedHashes.size > 0}
1567                          <button
1568                              class="delete-selected-btn"
1569                              on:click={deleteSelectedBlobs}
1570                              disabled={isDeletingSelected}
1571                          >
1572                              {isDeletingSelected ? "Deleting..." : `Delete Selected (${selectedHashes.size})`}
1573                          </button>
1574                      {/if}
1575                      <select class="sort-select" bind:value={sortBy}>
1576                          <option value="date">Date {sortBy === "date" ? (sortOrder === "desc" ? "↓" : "↑") : ""}</option>
1577                          <option value="size">Size {sortBy === "size" ? (sortOrder === "desc" ? "↓" : "↑") : ""}</option>
1578                      </select>
1579                      <button class="sort-order-btn" on:click={() => sortOrder = sortOrder === "desc" ? "asc" : "desc"} title="Toggle sort order">
1580                          {sortOrder === "desc" ? "↓" : "↑"}
1581                      </button>
1582                  {/if}
1583                  {#if isAdmin && !isAdminView && !selectedAdminUser}
1584                      <button class="admin-btn" on:click={enterAdminView} disabled={isLoading}>
1585                          Admin
1586                      </button>
1587                  {/if}
1588                  <button class="refresh-btn" on:click={handleRefresh} disabled={isLoading || isLoadingAdmin}>
1589                      🔄 {isLoading || isLoadingAdmin ? "Loading..." : "Refresh"}
1590                  </button>
1591              </div>
1592          </div>
1593  
1594          {#if !isAdminView && !selectedAdminUser}
1595              <div class="upload-section">
1596                  <span class="upload-label">Upload new files</span>
1597                  <input
1598                      type="file"
1599                      multiple
1600                      bind:this={fileInput}
1601                      on:change={handleFileSelect}
1602                      class="file-input-hidden"
1603                  />
1604                  {#if selectedFiles.length > 0}
1605                      <span class="selected-count">{selectedFiles.length} file(s) selected</span>
1606                      <button
1607                          class="upload-btn"
1608                          on:click={uploadFiles}
1609                          disabled={isUploading}
1610                      >
1611                          {isUploading ? uploadProgress : "Upload"}
1612                      </button>
1613                  {/if}
1614                  <button class="select-btn" on:click={triggerFileInput} disabled={isUploading}>
1615                      Select Files
1616                  </button>
1617              </div>
1618  
1619          {/if}
1620  
1621          {#if error}
1622              <div class="error-message">
1623                  {error}
1624              </div>
1625          {/if}
1626  
1627          {#if isAdminView && !selectedAdminUser}
1628              <!-- Admin users list view -->
1629              {#if isLoadingAdmin}
1630                  <div class="loading">Loading user statistics...</div>
1631              {:else if adminUserStats.length === 0}
1632                  <div class="empty-state">
1633                      <p>No users have uploaded files yet.</p>
1634                  </div>
1635              {:else}
1636                  <div class="admin-users-list">
1637                      {#each adminUserStats as userStat}
1638                          <div
1639                              class="user-stat-item"
1640                              on:click={() => selectUser(userStat)}
1641                              on:keypress={(e) => e.key === "Enter" && selectUser(userStat)}
1642                              role="button"
1643                              tabindex="0"
1644                          >
1645                              <div class="user-avatar-container">
1646                                  {#if userStat.profile?.picture}
1647                                      <img src={userStat.profile.picture} alt="" class="user-avatar" />
1648                                  {:else}
1649                                      <div class="user-avatar-placeholder"></div>
1650                                  {/if}
1651                              </div>
1652                              <div class="user-info">
1653                                  <div class="user-name">
1654                                      {userStat.profile?.name || truncateNpub(hexToNpub(userStat.pubkey))}
1655                                  </div>
1656                                  <div class="user-npub" title={userStat.pubkey}>
1657                                      <span class="npub-full">{hexToNpub(userStat.pubkey)}</span>
1658                                      <span class="npub-truncated">{truncateNpub(hexToNpub(userStat.pubkey))}</span>
1659                                  </div>
1660                              </div>
1661                              <div class="user-stats">
1662                                  <span class="blob-count">{userStat.blob_count} files</span>
1663                                  <span class="total-size">{formatSize(userStat.total_size_bytes)}</span>
1664                              </div>
1665                          </div>
1666                      {/each}
1667                  </div>
1668              {/if}
1669          {:else}
1670              <!-- Normal blob list view (own files or selected user's files) -->
1671              {#if isLoading && displayBlobs.length === 0}
1672                  <div class="loading">Loading blobs...</div>
1673              {:else if displayBlobs.length === 0}
1674                  <div class="empty-state">
1675                      <p>{selectedAdminUser ? "No files found for this user." : "No files found in your Blossom storage."}</p>
1676                  </div>
1677              {:else}
1678                  <div class="blob-list">
1679                      {#each displayBlobs as blob}
1680                          <div
1681                              class="blob-item"
1682                              class:selected={(selectedHashesSize, selectedHashes.has(blob.sha256))}
1683                              on:click={() => openModal(blob)}
1684                              on:keypress={(e) => e.key === "Enter" && openModal(blob)}
1685                              role="button"
1686                              tabindex="0"
1687                          >
1688                              <input
1689                                  type="checkbox"
1690                                  class="blob-checkbox"
1691                                  checked={(selectedHashesSize, selectedHashes.has(blob.sha256))}
1692                                  on:change={(e) => toggleSelection(blob.sha256, e)}
1693                                  on:keypress|stopPropagation
1694                              />
1695                              <div class="blob-thumbnail">
1696                                  {#if getMimeCategory(blob.type) === "image"}
1697                                      <img src={getThumbnailUrl(blob)} alt="" class="thumbnail-img" loading="lazy" />
1698                                  {:else if getMimeCategory(blob.type) === "video"}
1699                                      <video src={getBlobUrl(blob)} class="thumbnail-video" muted preload="metadata"></video>
1700                                  {:else}
1701                                      <span class="thumbnail-icon">{getMimeIcon(blob.type)}</span>
1702                                  {/if}
1703                              </div>
1704                              <div class="blob-info">
1705                                  <div class="blob-hash" title={blob.sha256}>
1706                                      <span class="hash-full">{blob.sha256}</span>
1707                                      <span class="hash-truncated">{truncateHash(blob.sha256)}</span>
1708                                  </div>
1709                                  <div class="blob-meta">
1710                                      <span class="blob-size">{formatSize(blob.size)}</span>
1711                                      <span class="blob-type">{blob.type || "unknown"}</span>
1712                                      <span class="blob-date">{formatDate(blob.uploaded)}</span>
1713                                      {#if responsiveBlobs.has(blob.sha256)}
1714                                          <span class="responsive-chip">responsive</span>
1715                                      {/if}
1716                                  </div>
1717                              </div>
1718                              <button
1719                                  class="delete-btn"
1720                                  on:click|stopPropagation={() => deleteBlob(blob)}
1721                                  title="Delete"
1722                              >
1723                                  X
1724                              </button>
1725                          </div>
1726                      {/each}
1727                      {#if hasMoreBlobs}
1728                          <div class="scroll-sentinel" bind:this={scrollSentinel}>
1729                              <span class="loading-more">Loading more...</span>
1730                          </div>
1731                      {/if}
1732                  </div>
1733              {/if}
1734          {/if}
1735      </div>
1736  {:else}
1737      <div class="login-prompt">
1738          <p>Please log in to view your Blossom storage.</p>
1739          <button class="login-btn" on:click={openLoginModal}>Log In</button>
1740      </div>
1741  {/if}
1742  
1743  {#if showModal && selectedBlob}
1744      <div
1745          class="modal-overlay"
1746          on:click={closeModal}
1747          on:keypress={(e) => e.key === "Enter" && closeModal()}
1748          role="button"
1749          tabindex="0"
1750      >
1751          <div
1752              class="modal-content"
1753              on:click|stopPropagation
1754              on:keypress|stopPropagation
1755              role="dialog"
1756          >
1757              <div class="modal-header">
1758                  <div class="modal-title">
1759                      <span class="modal-hash">{truncateHash(selectedBlob.sha256)}</span>
1760                      <span class="modal-type">{selectedBlob.type || "unknown"}</span>
1761                  </div>
1762                  <div class="modal-controls">
1763                      {#if getMimeCategory(selectedBlob.type) === "image"}
1764                          <button class="zoom-btn" on:click={zoomOut} disabled={zoomLevel <= MIN_ZOOM}>-</button>
1765                          <span class="zoom-level">{Math.round(zoomLevel * 100)}%</span>
1766                          <button class="zoom-btn" on:click={zoomIn} disabled={zoomLevel >= MAX_ZOOM}>+</button>
1767                      {/if}
1768                      <button class="close-btn" on:click={closeModal}>X</button>
1769                  </div>
1770              </div>
1771              <div class="modal-body">
1772                  {#if getMimeCategory(selectedBlob.type) === "image"}
1773                      <div class="media-container" style="transform: scale({zoomLevel});">
1774                          <img src={getBlobUrl(selectedBlob)} alt="Blob content" />
1775                      </div>
1776                  {:else if getMimeCategory(selectedBlob.type) === "video"}
1777                      <div class="media-container">
1778                          <video controls src={getBlobUrl(selectedBlob)}>
1779                              <track kind="captions" />
1780                          </video>
1781                      </div>
1782                  {:else if getMimeCategory(selectedBlob.type) === "audio"}
1783                      <div class="media-container audio">
1784                          <audio controls src={getBlobUrl(selectedBlob)}></audio>
1785                      </div>
1786                  {:else}
1787                      <div class="file-preview">
1788                          <div class="file-icon">{getMimeIcon(selectedBlob.type)}</div>
1789                          <p>Preview not available for this file type.</p>
1790                          <a href={getBlobUrl(selectedBlob)} target="_blank" rel="noopener noreferrer" class="download-link">
1791                              Download File
1792                          </a>
1793                      </div>
1794                  {/if}
1795              </div>
1796              <div class="modal-footer">
1797                  <div class="blob-details">
1798                      <span>Size: {formatSize(selectedBlob.size)}</span>
1799                      <span>Uploaded: {formatDate(selectedBlob.uploaded)}</span>
1800                  </div>
1801  
1802                  <!-- Responsive Variants Section -->
1803                  {#if getMimeCategory(selectedBlob.type) === "image"}
1804                      <div class="variants-section">
1805                          <div class="variants-header">
1806                              <span class="variants-title">Responsive Variants</span>
1807                              {#if isLoadingVariants}
1808                                  <span class="variants-loading">Loading...</span>
1809                              {/if}
1810                          </div>
1811                          {#if blobVariants.length > 0}
1812                              <div class="variants-list">
1813                                  {#each blobVariants as variant}
1814                                      <div class="variant-item">
1815                                          <span class="variant-label">{formatVariantLabel(variant)}</span>
1816                                          <span class="variant-dims">{variant.width}×{variant.height}</span>
1817                                          {#if variant.size}
1818                                              <span class="variant-size">{formatSize(variant.size)}</span>
1819                                          {/if}
1820                                          <button
1821                                              class="variant-copy-btn"
1822                                              class:copied={copiedVariant === variant.sha256}
1823                                              on:click={() => copyVariantUrl(variant)}
1824                                          >
1825                                              {copiedVariant === variant.sha256 ? "Copied!" : "Copy URL"}
1826                                          </button>
1827                                      </div>
1828                                  {/each}
1829                              </div>
1830                          {:else if !isLoadingVariants}
1831                              <div class="variants-empty">
1832                                  No responsive variants found. Use "Migrate Images" to create them.
1833                              </div>
1834                          {/if}
1835                      </div>
1836                  {/if}
1837  
1838                  <div class="blob-url-section">
1839                      <input
1840                          type="text"
1841                          readonly
1842                          value={getBlobUrl(selectedBlob)}
1843                          class="blob-url-input"
1844                          on:click={(e) => e.target.select()}
1845                      />
1846                      <button
1847                          class="copy-btn"
1848                          on:click={() => {
1849                              navigator.clipboard.writeText(getBlobUrl(selectedBlob));
1850                          }}
1851                      >
1852                          Copy
1853                      </button>
1854                  </div>
1855                  <div class="modal-actions">
1856                      <a href={getBlobUrl(selectedBlob)} target="_blank" rel="noopener noreferrer" class="action-btn">
1857                          Open in New Tab
1858                      </a>
1859                      {#if getMimeCategory(selectedBlob.type) === "image"}
1860                          {#if blobVariants.length === 0}
1861                              <button class="action-btn primary" on:click={() => generateVariants(selectedBlob)} disabled={isGeneratingVariants}>
1862                                  {isGeneratingVariants ? generatingProgress : "Generate Variants"}
1863                              </button>
1864                          {:else}
1865                              <button class="action-btn warning" on:click={() => deleteVariants(selectedBlob)} disabled={isDeletingVariants}>
1866                                  {isDeletingVariants ? "Deleting..." : "Delete Variants"}
1867                              </button>
1868                          {/if}
1869                      {/if}
1870                      <button class="action-btn danger" on:click={() => deleteBlob(selectedBlob)}>
1871                          Delete
1872                      </button>
1873                  </div>
1874              </div>
1875          </div>
1876      </div>
1877  {/if}
1878  
1879  <style>
1880      .blossom-view {
1881          padding: 1em;
1882          box-sizing: border-box;
1883          width: 100%;
1884          max-width: 100%;
1885          overflow-x: hidden;
1886      }
1887  
1888      .header-section {
1889          display: flex;
1890          justify-content: space-between;
1891          align-items: center;
1892          margin-bottom: 1em;
1893      }
1894  
1895      .header-section h3 {
1896          margin: 0;
1897          color: var(--text-color);
1898          flex: 1;
1899      }
1900  
1901      .header-buttons {
1902          display: flex;
1903          align-items: center;
1904          gap: 0.5em;
1905      }
1906  
1907      .sort-select {
1908          padding: 0.4em 0.6em;
1909          border: 1px solid var(--border-color);
1910          border-radius: 4px;
1911          background-color: var(--card-bg);
1912          color: var(--text-color);
1913          font-size: 0.9em;
1914          cursor: pointer;
1915      }
1916  
1917      .sort-select:focus {
1918          outline: none;
1919          border-color: var(--primary);
1920      }
1921  
1922      .sort-order-btn {
1923          padding: 0.4em 0.6em;
1924          border: 1px solid var(--border-color);
1925          border-radius: 4px;
1926          background-color: var(--card-bg);
1927          color: var(--text-color);
1928          font-size: 0.9em;
1929          cursor: pointer;
1930          min-width: 2em;
1931      }
1932  
1933      .sort-order-btn:hover {
1934          background-color: var(--sidebar-bg);
1935      }
1936  
1937      .delete-selected-btn {
1938          padding: 0.4em 0.8em;
1939          border: none;
1940          border-radius: 4px;
1941          background-color: var(--warning, #dc3545);
1942          color: white;
1943          cursor: pointer;
1944          font-size: 0.9em;
1945      }
1946  
1947      .delete-selected-btn:hover:not(:disabled) {
1948          opacity: 0.9;
1949      }
1950  
1951      .delete-selected-btn:disabled {
1952          opacity: 0.6;
1953          cursor: not-allowed;
1954      }
1955  
1956      .back-btn {
1957          background: transparent;
1958          border: 1px solid var(--border-color);
1959          color: var(--text-color);
1960          padding: 0.5em 1em;
1961          border-radius: 4px;
1962          cursor: pointer;
1963          font-size: 0.9em;
1964          margin-right: 0.5em;
1965      }
1966  
1967      .back-btn:hover {
1968          background-color: var(--sidebar-bg);
1969      }
1970  
1971      .admin-btn {
1972          background-color: var(--primary);
1973          color: var(--text-color);
1974          border: none;
1975          padding: 0.5em 1em;
1976          border-radius: 4px;
1977          cursor: pointer;
1978          font-size: 0.9em;
1979      }
1980  
1981      .admin-btn:hover:not(:disabled) {
1982          background-color: var(--accent-hover-color);
1983      }
1984  
1985      .admin-btn:disabled {
1986          opacity: 0.6;
1987          cursor: not-allowed;
1988      }
1989  
1990      .user-header {
1991          display: flex;
1992          align-items: center;
1993          gap: 0.5em;
1994      }
1995  
1996      .header-avatar {
1997          width: 28px;
1998          height: 28px;
1999          border-radius: 50%;
2000          object-fit: cover;
2001      }
2002  
2003      .refresh-btn {
2004          background-color: var(--primary);
2005          color: var(--text-color);
2006          border: none;
2007          padding: 0.5em 1em;
2008          border-radius: 4px;
2009          cursor: pointer;
2010          font-size: 0.9em;
2011      }
2012  
2013      .refresh-btn:hover:not(:disabled) {
2014          background-color: var(--accent-hover-color);
2015      }
2016  
2017      .refresh-btn:disabled {
2018          opacity: 0.6;
2019          cursor: not-allowed;
2020      }
2021  
2022      .upload-section {
2023          display: flex;
2024          align-items: center;
2025          gap: 0.75em;
2026          padding: 0.75em 1em;
2027          background-color: var(--card-bg);
2028          border-radius: 6px;
2029          margin-bottom: 1em;
2030          flex-wrap: wrap;
2031      }
2032  
2033      .upload-label {
2034          color: var(--text-color);
2035          font-size: 0.95em;
2036          flex: 1;
2037      }
2038  
2039      .file-input-hidden {
2040          display: none;
2041      }
2042  
2043      .select-btn {
2044          background-color: var(--primary);
2045          color: var(--text-color);
2046          border: none;
2047          padding: 0.5em 1em;
2048          border-radius: 4px;
2049          cursor: pointer;
2050          font-size: 0.9em;
2051      }
2052  
2053      .select-btn:hover:not(:disabled) {
2054          background-color: var(--accent-hover-color);
2055      }
2056  
2057      .select-btn:disabled {
2058          opacity: 0.6;
2059          cursor: not-allowed;
2060      }
2061  
2062      .selected-count {
2063          color: var(--text-color);
2064          font-size: 0.9em;
2065      }
2066  
2067      .upload-btn {
2068          background-color: var(--success);
2069          color: white;
2070          border: none;
2071          padding: 0.5em 1em;
2072          border-radius: 4px;
2073          cursor: pointer;
2074          font-size: 0.9em;
2075          font-weight: bold;
2076      }
2077  
2078      .upload-btn:hover:not(:disabled) {
2079          opacity: 0.9;
2080      }
2081  
2082      .upload-btn:disabled {
2083          opacity: 0.7;
2084          cursor: not-allowed;
2085      }
2086  
2087      .error-message {
2088          background-color: var(--warning);
2089          color: var(--text-color);
2090          padding: 0.75em 1em;
2091          border-radius: 4px;
2092          margin-bottom: 1em;
2093      }
2094  
2095      .loading, .empty-state {
2096          text-align: center;
2097          padding: 2em;
2098          color: var(--text-color);
2099          opacity: 0.7;
2100      }
2101  
2102      .blob-list {
2103          display: flex;
2104          flex-direction: column;
2105          gap: 0.5em;
2106          width: 100%;
2107      }
2108  
2109      .scroll-sentinel {
2110          display: flex;
2111          justify-content: center;
2112          padding: 1em;
2113      }
2114  
2115      .loading-more {
2116          color: var(--text-color);
2117          opacity: 0.5;
2118          font-size: 0.85em;
2119      }
2120  
2121      .blob-item {
2122          display: flex;
2123          align-items: center;
2124          gap: 1em;
2125          padding: 0.75em 1em;
2126          background-color: var(--card-bg);
2127          border-radius: 6px;
2128          cursor: pointer;
2129          transition: background-color 0.2s;
2130          min-width: 0;
2131          overflow: hidden;
2132      }
2133  
2134      .blob-item:hover,
2135      .blob-item.selected {
2136          background-color: var(--sidebar-bg);
2137      }
2138  
2139      .blob-checkbox {
2140          width: 18px;
2141          height: 18px;
2142          flex-shrink: 0;
2143          cursor: pointer;
2144          accent-color: var(--primary);
2145      }
2146  
2147      .blob-thumbnail {
2148          width: 48px;
2149          height: 48px;
2150          flex-shrink: 0;
2151          display: flex;
2152          align-items: center;
2153          justify-content: center;
2154          background-color: var(--bg-color);
2155          border-radius: 4px;
2156          overflow: hidden;
2157      }
2158  
2159      .thumbnail-img,
2160      .thumbnail-video {
2161          width: 100%;
2162          height: 100%;
2163          object-fit: cover;
2164      }
2165  
2166      .thumbnail-icon {
2167          font-size: 1.5em;
2168      }
2169  
2170      .blob-info {
2171          flex: 1;
2172          min-width: 0;
2173          overflow: hidden;
2174      }
2175  
2176      .blob-hash {
2177          font-family: monospace;
2178          font-size: 0.9em;
2179          color: var(--text-color);
2180          overflow: hidden;
2181          text-overflow: ellipsis;
2182          white-space: nowrap;
2183      }
2184  
2185      .hash-full {
2186          display: none;
2187      }
2188  
2189      .hash-truncated {
2190          display: inline;
2191      }
2192  
2193      @media (min-width: 900px) {
2194          .hash-full {
2195              display: inline;
2196          }
2197          .hash-truncated {
2198              display: none;
2199          }
2200      }
2201  
2202      .blob-meta {
2203          display: flex;
2204          gap: 1em;
2205          font-size: 0.8em;
2206          color: var(--text-color);
2207          opacity: 0.7;
2208          margin-top: 0.25em;
2209          flex-wrap: wrap;
2210      }
2211  
2212      .blob-meta .blob-date {
2213          white-space: nowrap;
2214      }
2215  
2216      .responsive-chip {
2217          background: var(--success, #22c55e);
2218          color: white;
2219          padding: 0.1em 0.5em;
2220          border-radius: 4px;
2221          font-size: 0.85em;
2222          font-weight: 500;
2223          white-space: nowrap;
2224      }
2225  
2226      .delete-btn {
2227          background: transparent;
2228          border: 1px solid var(--warning);
2229          color: var(--warning);
2230          width: 1.75em;
2231          height: 1.75em;
2232          border-radius: 4px;
2233          cursor: pointer;
2234          font-size: 0.85em;
2235          display: flex;
2236          align-items: center;
2237          justify-content: center;
2238      }
2239  
2240      .delete-btn:hover {
2241          background-color: var(--warning);
2242          color: var(--text-color);
2243      }
2244  
2245      /* Admin users list styles */
2246      .admin-users-list {
2247          display: flex;
2248          flex-direction: column;
2249          gap: 0.5em;
2250          width: 100%;
2251      }
2252  
2253      .user-stat-item {
2254          display: flex;
2255          align-items: center;
2256          gap: 1em;
2257          padding: 0.75em 1em;
2258          background-color: var(--card-bg);
2259          border-radius: 6px;
2260          cursor: pointer;
2261          transition: background-color 0.2s;
2262      }
2263  
2264      .user-stat-item:hover {
2265          background-color: var(--sidebar-bg);
2266      }
2267  
2268      .user-avatar-container {
2269          flex-shrink: 0;
2270      }
2271  
2272      .user-avatar {
2273          width: 40px;
2274          height: 40px;
2275          border-radius: 50%;
2276          object-fit: cover;
2277      }
2278  
2279      .user-avatar-placeholder {
2280          width: 40px;
2281          height: 40px;
2282          border-radius: 50%;
2283          background-color: var(--border-color);
2284      }
2285  
2286      .user-info {
2287          flex: 1;
2288          min-width: 0;
2289      }
2290  
2291      .user-name {
2292          font-weight: 500;
2293          color: var(--text-color);
2294      }
2295  
2296      .user-npub {
2297          font-family: monospace;
2298          font-size: 0.8em;
2299          color: var(--text-color);
2300          opacity: 0.6;
2301      }
2302  
2303      .npub-full {
2304          display: inline;
2305      }
2306  
2307      .npub-truncated {
2308          display: none;
2309      }
2310  
2311      .user-stats {
2312          display: flex;
2313          flex-direction: column;
2314          align-items: flex-end;
2315          gap: 0.25em;
2316      }
2317  
2318      .user-stats .blob-count,
2319      .user-stats .total-size {
2320          font-size: 0.85em;
2321          color: var(--text-color);
2322          opacity: 0.7;
2323      }
2324  
2325      .login-prompt {
2326          text-align: center;
2327          padding: 2em;
2328          background-color: var(--card-bg);
2329          border-radius: 8px;
2330          border: 1px solid var(--border-color);
2331          max-width: 32em;
2332          margin: 1em;
2333      }
2334  
2335      .login-prompt p {
2336          margin: 0 0 1.5rem 0;
2337          color: var(--text-color);
2338          font-size: 1.1rem;
2339      }
2340  
2341      .login-btn {
2342          background-color: var(--primary);
2343          color: var(--text-color);
2344          border: none;
2345          padding: 0.75em 1.5em;
2346          border-radius: 4px;
2347          cursor: pointer;
2348          font-weight: bold;
2349          font-size: 0.9em;
2350      }
2351  
2352      .login-btn:hover {
2353          background-color: var(--accent-hover-color);
2354      }
2355  
2356      /* Modal styles */
2357      .modal-overlay {
2358          position: fixed;
2359          top: 3em;
2360          left: 200px;
2361          right: 0;
2362          bottom: 0;
2363          background-color: rgba(0, 0, 0, 0.8);
2364          display: flex;
2365          align-items: center;
2366          justify-content: center;
2367          z-index: 1000;
2368          padding: 1em;
2369          box-sizing: border-box;
2370      }
2371  
2372      .modal-content {
2373          background-color: var(--bg-color);
2374          border-radius: 8px;
2375          max-width: 100%;
2376          max-height: 100%;
2377          width: 100%;
2378          display: flex;
2379          flex-direction: column;
2380          overflow: hidden;
2381      }
2382  
2383      .modal-header {
2384          display: flex;
2385          justify-content: space-between;
2386          align-items: center;
2387          padding: 0.75em 1em;
2388          border-bottom: 1px solid var(--border-color);
2389          background-color: var(--card-bg);
2390      }
2391  
2392      .modal-title {
2393          display: flex;
2394          align-items: center;
2395          gap: 1em;
2396      }
2397  
2398      .modal-hash {
2399          font-family: monospace;
2400          color: var(--text-color);
2401      }
2402  
2403      .modal-type {
2404          font-size: 0.85em;
2405          color: var(--text-color);
2406          opacity: 0.7;
2407      }
2408  
2409      .modal-controls {
2410          display: flex;
2411          align-items: center;
2412          gap: 0.5em;
2413      }
2414  
2415      .zoom-btn {
2416          background-color: var(--primary);
2417          color: var(--text-color);
2418          border: none;
2419          width: 2em;
2420          height: 2em;
2421          border-radius: 4px;
2422          cursor: pointer;
2423          font-size: 1em;
2424          font-weight: bold;
2425      }
2426  
2427      .zoom-btn:hover:not(:disabled) {
2428          background-color: var(--accent-hover-color);
2429      }
2430  
2431      .zoom-btn:disabled {
2432          opacity: 0.5;
2433          cursor: not-allowed;
2434      }
2435  
2436      .zoom-level {
2437          font-size: 0.85em;
2438          color: var(--text-color);
2439          min-width: 3em;
2440          text-align: center;
2441      }
2442  
2443      .close-btn {
2444          background: transparent;
2445          border: 1px solid var(--border-color);
2446          color: var(--text-color);
2447          width: 2em;
2448          height: 2em;
2449          border-radius: 4px;
2450          cursor: pointer;
2451          font-size: 1em;
2452          margin-left: 0.5em;
2453      }
2454  
2455      .close-btn:hover {
2456          background-color: var(--warning);
2457          border-color: var(--warning);
2458      }
2459  
2460      .modal-body {
2461          flex: 1;
2462          overflow: auto;
2463          display: flex;
2464          align-items: center;
2465          justify-content: center;
2466          padding: 1em;
2467          min-height: 200px;
2468      }
2469  
2470      .media-container {
2471          transition: transform 0.2s ease;
2472          transform-origin: center center;
2473      }
2474  
2475      .media-container img {
2476          max-width: 80vw;
2477          max-height: 70vh;
2478          object-fit: contain;
2479      }
2480  
2481      .media-container video {
2482          max-width: 80vw;
2483          max-height: 70vh;
2484      }
2485  
2486      .media-container.audio {
2487          width: 100%;
2488          padding: 2em;
2489      }
2490  
2491      .media-container audio {
2492          width: 100%;
2493      }
2494  
2495      .file-preview {
2496          text-align: center;
2497          padding: 2em;
2498          color: var(--text-color);
2499      }
2500  
2501      .file-icon {
2502          font-size: 4em;
2503          margin-bottom: 0.5em;
2504      }
2505  
2506      .download-link {
2507          display: inline-block;
2508          margin-top: 1em;
2509          padding: 0.75em 1.5em;
2510          background-color: var(--primary);
2511          color: var(--text-color);
2512          text-decoration: none;
2513          border-radius: 4px;
2514      }
2515  
2516      .download-link:hover {
2517          background-color: var(--accent-hover-color);
2518      }
2519  
2520      .modal-footer {
2521          display: flex;
2522          flex-direction: column;
2523          gap: 0.5em;
2524          padding: 0.75em 1em;
2525          border-top: 1px solid var(--border-color);
2526          background-color: var(--card-bg);
2527      }
2528  
2529      .blob-details {
2530          display: flex;
2531          gap: 1.5em;
2532          font-size: 0.85em;
2533          color: var(--text-color);
2534          opacity: 0.7;
2535      }
2536  
2537      /* Responsive variants section */
2538      .variants-section {
2539          padding: 0.75em;
2540          background-color: var(--bg-secondary, rgba(0,0,0,0.05));
2541          border-radius: 6px;
2542          margin: 0.5em 0;
2543      }
2544  
2545      .variants-header {
2546          display: flex;
2547          justify-content: space-between;
2548          align-items: center;
2549          margin-bottom: 0.5em;
2550      }
2551  
2552      .variants-title {
2553          font-weight: 600;
2554          font-size: 0.9em;
2555          color: var(--text-color);
2556      }
2557  
2558      .variants-loading {
2559          font-size: 0.8em;
2560          color: var(--text-secondary, #888);
2561          font-style: italic;
2562      }
2563  
2564      .variants-list {
2565          display: flex;
2566          flex-direction: column;
2567          gap: 0.4em;
2568      }
2569  
2570      .variant-item {
2571          display: flex;
2572          align-items: center;
2573          gap: 0.75em;
2574          padding: 0.4em 0.6em;
2575          background-color: var(--card-bg);
2576          border-radius: 4px;
2577          font-size: 0.85em;
2578      }
2579  
2580      .variant-label {
2581          font-weight: 500;
2582          color: var(--text-color);
2583          min-width: 80px;
2584      }
2585  
2586      .variant-dims {
2587          color: var(--text-secondary, #888);
2588          font-family: monospace;
2589          font-size: 0.9em;
2590      }
2591  
2592      .variant-size {
2593          color: var(--text-secondary, #888);
2594          font-size: 0.85em;
2595          margin-left: auto;
2596      }
2597  
2598      .variant-copy-btn {
2599          padding: 0.25em 0.6em;
2600          background-color: var(--primary);
2601          color: var(--text-on-primary, #fff);
2602          border: none;
2603          border-radius: 3px;
2604          cursor: pointer;
2605          font-size: 0.8em;
2606          transition: background-color 0.2s, transform 0.1s;
2607      }
2608  
2609      .variant-copy-btn:hover {
2610          opacity: 0.9;
2611      }
2612  
2613      .variant-copy-btn.copied {
2614          background-color: var(--success);
2615      }
2616  
2617      .variants-empty {
2618          font-size: 0.85em;
2619          color: var(--text-secondary, #888);
2620          font-style: italic;
2621          padding: 0.5em 0;
2622      }
2623  
2624      .blob-url-section {
2625          display: flex;
2626          gap: 0.5em;
2627          width: 100%;
2628      }
2629  
2630      .blob-url-input {
2631          flex: 1;
2632          padding: 0.4em 0.6em;
2633          font-family: monospace;
2634          font-size: 0.85em;
2635          background-color: var(--bg-color);
2636          color: var(--text-color);
2637          border: 1px solid var(--border-color);
2638          border-radius: 4px;
2639          cursor: text;
2640      }
2641  
2642      .blob-url-input:focus {
2643          outline: none;
2644          border-color: var(--primary);
2645      }
2646  
2647      .copy-btn {
2648          padding: 0.4em 0.8em;
2649          background-color: var(--primary);
2650          color: var(--text-color);
2651          border: none;
2652          border-radius: 4px;
2653          cursor: pointer;
2654          font-size: 0.85em;
2655      }
2656  
2657      .copy-btn:hover {
2658          background-color: var(--accent-hover-color);
2659      }
2660  
2661      .modal-actions {
2662          display: flex;
2663          gap: 0.5em;
2664      }
2665  
2666      .action-btn {
2667          display: inline-flex;
2668          align-items: center;
2669          justify-content: center;
2670          height: 2.2em;
2671          padding: 0 1em;
2672          background-color: var(--primary);
2673          color: var(--text-color);
2674          border: 1px solid transparent;
2675          border-radius: 4px;
2676          cursor: pointer;
2677          text-decoration: none;
2678          font-size: 0.9em;
2679          box-sizing: border-box;
2680      }
2681  
2682      .action-btn:hover {
2683          background-color: var(--accent-hover-color);
2684      }
2685  
2686      .action-btn.danger {
2687          background-color: transparent;
2688          border-color: var(--warning);
2689          color: var(--warning);
2690      }
2691  
2692      .action-btn.danger:hover {
2693          background-color: var(--warning);
2694          color: var(--text-color);
2695      }
2696  
2697      .action-btn.warning {
2698          background-color: transparent;
2699          border-color: #f59e0b;
2700          color: #f59e0b;
2701      }
2702  
2703      .action-btn.warning:hover:not(:disabled) {
2704          background-color: #f59e0b;
2705          color: #fff;
2706      }
2707  
2708      .action-btn.warning:disabled {
2709          opacity: 0.6;
2710          cursor: not-allowed;
2711      }
2712  
2713      @media (max-width: 720px) {
2714          .hash-full {
2715              display: none;
2716          }
2717  
2718          .hash-truncated {
2719              display: inline;
2720          }
2721  
2722          .npub-full {
2723              display: none;
2724          }
2725  
2726          .npub-truncated {
2727              display: inline;
2728          }
2729      }
2730  
2731      @media (max-width: 600px) {
2732          .blob-item {
2733              flex-wrap: wrap;
2734          }
2735  
2736          .modal-overlay {
2737              left: 0;
2738              top: 0;
2739              padding: 0.5em;
2740          }
2741  
2742          .modal-footer {
2743              flex-direction: column;
2744              gap: 0.75em;
2745          }
2746  
2747          .blob-details {
2748              flex-direction: column;
2749              gap: 0.25em;
2750          }
2751      }
2752  
2753  </style>
2754