From f6007876b7909afddaa3a19f762a0c470c872ef0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:01:35 -0400 Subject: [PATCH] External embed Phase 1: chromeless read-only viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/EXTERNAL_EMBED_FEATURE.md Β§18 Phase 1. - parseEmbedRoute() detects three URL forms so the embed works on Vercel-rewrite-friendly hosts AND on plain static servers: /embed/ (canonical; needs SPA rewrite) #/embed/ (hash; works on any host) ?embed= (query; works on any host) - Early-return at the top of init() routes embed mode to bootEmbedViewer; skips welcome modal, slot/disc/jewel controls, attachEvents, walletUI, autosave restore, share-route popstate. Editor JS stays loaded (single bundle), but none of its boot steps run. - bootEmbedViewer() decodes the identifier via the existing decodeNostrId helper (note / nevent / naddr / hex), races the hint relays then user relays + DEFAULT_NOSTR_RELAYS for the event, warms linked-design sub-trees in one prefetchBatch round-trip, then paints via the existing renderDesignPayloadPreview + fitMiniPreview path. - Premium events refuse with "πŸ”’ Premium design β€” open in Artstr to unlock" placeholder; Phase 2 will render the watermarked preview JPEG with an inline CTA. - Private (encrypted) envelopes refuse with a πŸ”’ placeholder; no decryption attempted. - body.embed-mode CSS hides every existing top-level element (`body.embed-mode > :not(#embedRoot) { display: none !important }`) and shows a single #embedRoot with a stage + thin footer (author chip + "Open in Artstr β†—"). attr / open / controls query params toggle each piece. - vercel.json gains /embed/:id SPA rewrites and a Content-Security-Policy: frame-ancestors * header so the route is iframe-able from any origin. Production behavior; dev (python http.server) needs no headers β€” cross-origin framing works by default. Manual smoke-test paths: http://localhost:8000/?embed= (query form) http://localhost:8000/#/embed/ (hash form) http://localhost:8001/ (cross-origin iframe host) --- src/index.html | 264 +++++++++++++++++++++++++++++++++++++++++++++++++ vercel.json | 12 ++- 2 files changed, 275 insertions(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index ed85af4..a1bbc02 100644 --- a/src/index.html +++ b/src/index.html @@ -576,6 +576,43 @@ body.app-designer label[for="layerTarget"], body.app-designer #layerTarget { display: none !important; } + /* Embed viewer (docs/EXTERNAL_EMBED_FEATURE.md). When embed mode + is on, hide every existing top-level element and show only the + dedicated #embedRoot the embed boot path creates. */ + body.embed-mode { margin: 0; padding: 0; overflow: hidden; background: transparent; } + body.embed-mode > :not(#embedRoot) { display: none !important; } + #embedRoot { + position: fixed; inset: 0; + display: flex; flex-direction: column; + background: transparent; + font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: #111827; + } + #embedRoot .embedStage { + flex: 1 1 auto; + position: relative; + overflow: hidden; + display: flex; align-items: center; justify-content: center; + } + #embedRoot .embedFooter { + flex: 0 0 auto; + padding: 6px 10px; + display: flex; gap: 12px; + align-items: center; justify-content: space-between; + font-size: 0.74rem; + background: rgba(255,255,255,0.78); + backdrop-filter: blur(6px); + border-top: 1px solid rgba(0,0,0,0.08); + } + #embedRoot .embedFooter[data-hidden="true"] { display: none; } + #embedRoot .embedFooter a { color: #1e40af; text-decoration: none; } + #embedRoot .embedFooter a:hover { text-decoration: underline; } + #embedRoot .embedStatus { + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 14px 16px; text-align: center; color: #4b5563; font-size: 0.85rem; + } + #embedRoot .embedStatus.error { color: #92400e; } + /* Designer single-disc preview */ body.app-designer .sheetWrap { display: none !important; } @@ -21175,6 +21212,223 @@ return ''; } + // Embed route detection (docs/EXTERNAL_EMBED_FEATURE.md Β§6). + // Supports three forms so the embed works on hosts that do + // and don't do SPA path rewrites: + // /embed/ β€” canonical (Vercel rewrites land here) + // #/embed/ β€” hash fallback (works on any static host) + // ?embed= β€” query fallback (works anywhere too) + function parseEmbedRoute() { + const params = new URLSearchParams(location.search || ''); + const path = location.pathname.match(/^\/embed\/([^/?#]+)/); + if (path) return { isEmbed: true, id: decodeURIComponent(path[1]), params }; + const hash = (location.hash || '').replace(/^#\/?/, ''); + const hm = hash.match(/^embed\/([^/?#]+)/); + if (hm) return { isEmbed: true, id: decodeURIComponent(hm[1]), params }; + const qid = params.get('embed'); + if (qid) return { isEmbed: true, id: qid, params }; + return { isEmbed: false }; + } + + // Collect every linked-design ref reachable from a payload's + // top-level + per-slide layers. Mirrors the host-publish strip + // walker; used to warm the linked-design cache before paint. + function _collectLinkedSpecsFromPayload(payload) { + const out = []; + const visit = (layers) => { + if (!Array.isArray(layers)) return; + for (const l of layers) { + if (l?.type === 'design' && l.design?.kind === 'linked' && l.design.ref) { + out.push({ ref: l.design.ref, pinnedEventId: l.design.pinnedEventId || null }); + } + } + }; + visit(payload?.layers); + if (Array.isArray(payload?.deck?.slides)) { + for (const s of payload.deck.slides) visit(s?.layers); + } + return out; + } + + // Build the chromeless DOM shell the embed paints into. body + // .embed-mode CSS hides everything else; we add a single + // #embedRoot with a stage + a footer. + function _ensureEmbedRoot() { + let root = document.getElementById('embedRoot'); + if (root) return root; + root = document.createElement('div'); + root.id = 'embedRoot'; + const stage = document.createElement('div'); + stage.className = 'embedStage'; + const footer = document.createElement('div'); + footer.className = 'embedFooter'; + footer.dataset.hidden = 'true'; + root.appendChild(stage); + root.appendChild(footer); + document.body.appendChild(root); + return root; + } + + function _setEmbedStatus(root, kind, message) { + const stage = root.querySelector('.embedStage'); + if (!stage) return; + stage.innerHTML = ''; + const box = document.createElement('div'); + box.className = `embedStatus${kind === 'error' ? ' error' : ''}`; + box.textContent = message || ''; + stage.appendChild(box); + } + + // Race relay hints first, fall back to the user's full relay + // list. Pared-down twin of loadSharedDesign β€” we only need the + // event itself; no reactions, deletions, profile lookups. + async function _resolveEmbedEvent(decoded) { + const userRelays = splitRelayList(state.community?.relays || ''); + const defaults = Array.isArray(DEFAULT_NOSTR_RELAYS) ? DEFAULT_NOSTR_RELAYS : []; + const hintRelays = (decoded.relays || []).filter(r => r && /^wss?:\/\//.test(r)); + const filter = decoded.type === 'naddr' + ? { kinds: [decoded.kind], authors: [decoded.pubkey], '#d': [decoded.dTag], limit: 3 } + : { ids: [decoded.eventId], limit: 1 }; + const matches = (ev) => decoded.type === 'naddr' + ? (ev.kind === decoded.kind && ev.pubkey === decoded.pubkey) + : (ev.id === decoded.eventId); + const race = (relays, timeoutMs) => new Promise((resolve) => { + if (!relays.length) return resolve(null); + let done = false; + const finish = (ev) => { if (done) return; done = true; resolve(ev || null); }; + const qp = queryRelays(relays, filter, { + timeoutMs, + onEvent: (ev) => { if (matches(ev)) finish(ev); }, + }); + qp.then((events) => { + if (done) return; + const t = decoded.type === 'naddr' + ? events.slice().sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] + : events.find(e => e.id === decoded.eventId); + finish(t); + }).catch(() => finish(null)); + }); + let ev = hintRelays.length ? await race(hintRelays, 2500) : null; + if (!ev) { + const fallback = [...new Set([...userRelays, ...defaults])].filter(r => !hintRelays.includes(r)); + if (fallback.length) ev = await race(fallback, 4500); + } + return ev; + } + + // Paint the resolved payload into the chromeless embed stage + // and populate the footer (author + "Open in Artstr" link). + function _paintEmbed(root, payload, event, route, decoded) { + const stage = root.querySelector('.embedStage'); + const footer = root.querySelector('.embedFooter'); + if (!stage || !footer) return; + stage.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.style.cssText = 'position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden'; + stage.appendChild(wrap); + try { + renderDesignPayloadPreview(wrap, payload, { compact: true, transparentPixelArt: true }); + fitMiniPreview(wrap); + } catch (err) { + console.warn('[embed] render failed', err); + _setEmbedStatus(root, 'error', 'Design preview unavailable.'); + return; + } + // Footer: author chip + Open in Artstr link, both togglable + // via query params per docs/EXTERNAL_EMBED_FEATURE.md Β§6.3. + footer.innerHTML = ''; + const params = route.params || new URLSearchParams(); + const showAttr = params.get('attr') !== '0'; + const showOpen = params.get('open') !== '0'; + const showControls = params.get('controls') !== '0'; + const left = document.createElement('div'); + const right = document.createElement('div'); + left.style.cssText = 'display:flex;align-items:center;gap:6px;min-width:0'; + right.style.cssText = 'display:flex;align-items:center;gap:8px'; + if (showAttr && event?.pubkey) { + const profile = _feedCache.profiles?.[event.pubkey] || state.community?.profiles?.[event.pubkey] || null; + const name = profile?.display_name || profile?.name || shortPubkey(event.pubkey); + const a = document.createElement('a'); + a.textContent = '@' + name; + a.title = profile?.nip05 || encodeNpub(event.pubkey) || event.pubkey; + a.href = `${location.origin}/u/${encodeNpub(event.pubkey) || event.pubkey}`; + a.target = '_top'; + a.rel = 'noopener'; + a.style.cssText = 'color:#1e40af;text-decoration:none;font-family:monospace;font-size:0.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px'; + left.appendChild(a); + } + if (showOpen) { + const open = document.createElement('a'); + open.textContent = 'Open in Artstr β†—'; + open.href = `${location.origin}/#/share/${route.id}`; + open.target = '_top'; + open.rel = 'noopener'; + right.appendChild(open); + } + footer.appendChild(left); + footer.appendChild(right); + footer.dataset.hidden = (!showControls || (!showAttr && !showOpen)) ? 'true' : 'false'; + } + + // Embed boot path (docs/EXTERNAL_EMBED_FEATURE.md Β§12). Called + // from init() before any editor bootstrap when the route says + // we're in embed mode. Public-only in Phase 1 β€” premium and + // private sources refuse with a placeholder + Open in Artstr. + async function bootEmbedViewer(route) { + document.body.classList.add('embed-mode'); + const root = _ensureEmbedRoot(); + _setEmbedStatus(root, 'loading', '⟳ Loading Artstr design…'); + const decoded = decodeNostrId(route.id); + if (!decoded) { + _setEmbedStatus(root, 'error', "That's not a valid Artstr identifier."); + return; + } + let event; + try { event = await _resolveEmbedEvent(decoded); } + catch (err) { + console.warn('[embed] resolver failed', err); + _setEmbedStatus(root, 'error', "Couldn't reach Nostr relays."); + return; + } + if (!event) { + _setEmbedStatus(root, 'error', 'Could not find this design on any relay.'); + return; + } + // Premium / private boundary (spec Β§10). Phase 1 refuses both + // with an Open-in-Artstr CTA β€” Phase 2 will render the + // watermarked preview + πŸ”’ overlay for premium. + if (typeof isPremiumEvent === 'function' && isPremiumEvent(event)) { + _setEmbedStatus(root, 'error', 'πŸ”’ Premium design β€” open in Artstr to unlock.'); + return; + } + let payload; + try { payload = JSON.parse(event.content || '{}'); } + catch { + _setEmbedStatus(root, 'error', "This event isn't a valid Artstr design."); + return; + } + if (typeof isEncryptedEnvelope === 'function' && isEncryptedEnvelope(payload)) { + _setEmbedStatus(root, 'error', 'πŸ”’ Private design β€” only sharers can view this.'); + return; + } + if (typeof isDesignPayload !== 'function' || !isDesignPayload(payload)) { + _setEmbedStatus(root, 'error', "This event isn't an Artstr design."); + return; + } + // Warm the linked-design cache in one batched round-trip + // before paint so sub-designs paint from a hot cache rather + // than flicker through the placeholder. + try { + const specs = _collectLinkedSpecsFromPayload(payload); + if (specs.length && typeof linkedDesigns?.prefetchBatch === 'function') { + await linkedDesigns.prefetchBatch(specs); + } + } catch (err) { + console.warn('[embed] linked prefetch failed', err); + } + _paintEmbed(root, payload, event, route, decoded); + } + function encodeNpub(pubkeyHex) { const bytes = hexToBytes(pubkeyHex); if (bytes.length !== 32) return ''; @@ -27987,6 +28241,16 @@ } function init() { + // Embed mode: route check runs before any editor bootstrap so + // the iframe boots straight to the read-only viewer. Skips + // welcome modal, slot/disc/jewel controls, attachEvents, + // walletUI, autosave restore, popstate listener for shares β€” + // none of those apply in a chromeless read-only embed. + const embedRoute = parseEmbedRoute(); + if (embedRoute.isEmbed) { + bootEmbedViewer(embedRoute); + return; + } restoreAutosave(); state.appMode = state.appMode || 'template'; normalizeDiscState(); diff --git a/vercel.json b/vercel.json index 2862fbc..68c8dc2 100644 --- a/vercel.json +++ b/vercel.json @@ -4,6 +4,16 @@ { "source": "/share/:id", "destination": "/" }, { "source": "/share/:id/", "destination": "/" }, { "source": "/u/:npub", "destination": "/" }, - { "source": "/u/:npub/", "destination": "/" } + { "source": "/u/:npub/", "destination": "/" }, + { "source": "/embed/:id", "destination": "/" }, + { "source": "/embed/:id/", "destination": "/" } + ], + "headers": [ + { + "source": "/embed/:id", + "headers": [ + { "key": "Content-Security-Policy", "value": "frame-ancestors *" } + ] + } ] }