diff --git a/src/index.html b/src/index.html index c9fe53e..9e160cb 100644 --- a/src/index.html +++ b/src/index.html @@ -6117,30 +6117,76 @@ return row; } - async function loadPurchasedDesignsIntoWall(wall) { + function _renderVaultItemsIntoWall(wall, items, hadError = false, opts = {}) { + wall.innerHTML = ''; + // Top toolbar: count + refresh button. Refresh forces a relay re- + // query in case the user just bought something on another device. + const topBar = document.createElement('div'); + topBar.style.cssText = 'grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;font-size:.78rem;color:var(--muted)'; + const count = document.createElement('span'); + count.textContent = items.length + ? `${items.length} unlocked design${items.length === 1 ? '' : 's'}` + : (hadError ? 'Vault load failed' : 'No premium designs unlocked yet'); + const refreshBtn = document.createElement('button'); + refreshBtn.type = 'button'; + refreshBtn.textContent = '↻ Refresh'; + refreshBtn.title = 'Re-query relays for the latest vault'; + refreshBtn.addEventListener('click', () => loadPurchasedDesignsIntoWall(wall, { force: true })); + topBar.append(count, refreshBtn); + wall.appendChild(topBar); + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'hint'; + empty.style.cssText = 'grid-column:1/-1'; + empty.textContent = hadError + ? 'Couldn’t load your purchase vault from relays. Try refresh.' + : 'Visit the feed and zap a premium template to start your collection.'; + wall.appendChild(empty); + return; + } + for (const item of items) { + const row = _rowFromVaultItem(item); + const card = buildFeedCard(row, () => { + if (item._missing) { + toastError('This vault entry is missing its per-item event on relays. Try Refresh, or re-unlock from the feed.'); + return; + } + try { loadProjectFromText(JSON.stringify(row.payload)); } + catch (err) { toastError('Could not open: ' + (err?.message || err)); return; } + $('nostrModal').classList.remove('open'); + }); + if (item._missing) card.style.opacity = '0.55'; + wall.appendChild(card); + } + } + + async function loadPurchasedDesignsIntoWall(wall, opts = {}) { if (!_supportsVault()) { wall.innerHTML = '
Your Nostr extension doesn’t support encryption (NIP-44 / NIP-04), so the purchase vault is unavailable.
'; return; } - wall.innerHTML = '
Loading purchased designs…
'; + // Render the in-memory cache immediately if we have one — feels + // instant. Then re-fetch in the background and re-render with the + // latest from relays. If we have no cache, show a placeholder while + // the first fetch happens. + const cached = _vaultCache; + if (cached?.items?.length) { + _renderVaultItemsIntoWall(wall, cached.items, false); + } else { + wall.innerHTML = '
Loading purchased designs…
'; + } try { - const vault = await loadPurchaseVault({ force: true }); - const items = vault?.items || []; - wall.innerHTML = ''; - if (!items.length) { - wall.innerHTML = '
No premium designs unlocked yet. Visit the feed and zap a premium template to start your collection.
'; - return; - } - for (const item of items) { - const row = _rowFromVaultItem(item); - wall.appendChild(buildFeedCard(row, () => { - try { loadProjectFromText(JSON.stringify(row.payload)); } - catch (err) { toastError('Could not open: ' + (err?.message || err)); return; } - $('nostrModal').classList.remove('open'); - })); - } + const vault = await loadPurchaseVault({ force: opts.force !== false }); + _renderVaultItemsIntoWall(wall, vault?.items || [], false); } catch (err) { - wall.innerHTML = `
Failed to load purchase vault: ${escapeHtml(err?.message || String(err))}
`; + console.warn('[purchased tab] load failed', err); + // Keep whatever we had cached on the wall; only fall back to the + // error placeholder if we never had any cache. + if (!cached?.items?.length) { + _renderVaultItemsIntoWall(wall, [], true); + } else { + if (typeof toastError === 'function') toastError('Vault refresh failed: ' + (err?.message || err)); + } } } // ---- end Purchase vault ------------------------------------------------ @@ -18375,6 +18421,13 @@ function isPreviewBroken(row) { if (!row) return false; + // Premium rows carry their own watermarked-JPEG preview embedded in + // the envelope (or the cached decrypted payload's mini-preview). + // They never count as "broken" — without this exception, premium + // cover/disc/jewel rows (whose synthesised stub has no slot art so + // row.preview is '') would always sort to the back of the New tab + // and get hidden past the chunk-size cutoff. + if (row.premium) return false; const preview = row.preview; // No URL at all + this mode expects one → effectively a missing image. if (!preview && MODES_REQUIRE_IMAGE.has(row.mode)) return true; @@ -21061,11 +21114,20 @@ btn.title = 'React to this template on Nostr'; btn.addEventListener('click', async e => { e.stopPropagation(); + if (btn.disabled || btn.classList.contains('reacted')) return; + // Optimistic update so the button feels responsive even when the + // relay publish takes a few seconds. We roll back on failure. + const prevText = btn.textContent; + btn.disabled = true; + btn.textContent = '⋯'; const ok = await reactToNostrEvent(row.e.id, row.e.pubkey); + btn.disabled = false; if (ok) { row.reactions = _feedCache.reactions[row.e.id] || 1; btn.textContent = `▲ ${row.reactions}`; btn.classList.add('reacted'); + } else { + btn.textContent = prevText; } }); return btn;