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