mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
External embed Phase 1: chromeless read-only viewer
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/<id> (canonical; needs SPA rewrite)
#/embed/<id> (hash; works on any host)
?embed=<id> (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=<naddr> (query form)
http://localhost:8000/#/embed/<naddr> (hash form)
http://localhost:8001/ (cross-origin
iframe host)
This commit is contained in:
1 parent
4d355f87d4
commit
f6007876b7
2 files changed
+275
-1
No files matched your search
+264
@@ -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/<id> — canonical (Vercel rewrites land here)
|
||||
// #/embed/<id> — hash fallback (works on any static host)
|
||||
// ?embed=<id> — 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();
|
||||
|
||||
+11
-1
@@ -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 *" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user