rspace-online/server/shell.ts

2423 lines
104 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Shell HTML renderer.
*
* Wraps module content in the shared rSpace layout: header with app/space
* switchers + identity, <main> with module content, shell script + styles.
*/
import { resolve } from "node:path";
import type { ModuleInfo, SubPageInfo, OnboardingAction } from "../shared/module";
import { getDocumentData } from "./community-store";
// ── Browser compatibility polyfills (inline, runs before ES modules) ──
const COMPAT_POLYFILLS = `<script>(function(){if(typeof AbortSignal.timeout!=="function"){AbortSignal.timeout=function(ms){var c=new AbortController();setTimeout(function(){c.abort(new DOMException("The operation was aborted due to timeout","TimeoutError"))},ms);return c.signal}}if(typeof crypto!=="undefined"&&typeof crypto.randomUUID!=="function"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80;var h=Array.from(b,function(x){return x.toString(16).padStart(2,"0")}).join("");return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}})()</script>`;
// ── Dynamic per-module favicon (inline, runs after body parse) ──
// Badge map mirrors MODULE_BADGES from rstack-app-switcher.ts — kept in sync manually.
const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
rspace: { badge: "r🎨", color: "#5eead4" },
rnotes: { badge: "r📝", color: "#fcd34d" },
rpubs: { badge: "r📖", color: "#fda4af" },
rswag: { badge: "r👕", color: "#fda4af" },
rsplat: { badge: "r🔮", color: "#d8b4fe" },
rcal: { badge: "r📅", color: "#7dd3fc" },
rtrips: { badge: "r✈", color: "#6ee7b7" },
rmaps: { badge: "r🗺", color: "#86efac" },
rchats: { badge: "r🗨", color: "#6ee7b7" },
rinbox: { badge: "r📨", color: "#a5b4fc" },
rmail: { badge: "r✉", color: "#93c5fd" },
rforum: { badge: "r💬", color: "#fcd34d" },
rmeets: { badge: "r📹", color: "#67e8f9" },
rchoices: { badge: "r☑", color: "#f0abfc" },
rvote: { badge: "r🗳", color: "#c4b5fd" },
rflows: { badge: "r🌊", color: "#bef264" },
rwallet: { badge: "r💰", color: "#fde047" },
rcart: { badge: "r🛒", color: "#fdba74" },
rauctions: { badge: "r🏛", color: "#fca5a5" },
rtube: { badge: "r🎬", color: "#f9a8d4" },
rphotos: { badge: "r📸", color: "#f9a8d4" },
rnetwork: { badge: "r🌐", color: "#93c5fd" },
rsocials: { badge: "r📢", color: "#7dd3fc" },
rfiles: { badge: "r📁", color: "#67e8f9" },
rbooks: { badge: "r📚", color: "#fda4af" },
rdata: { badge: "r📊", color: "#d8b4fe" },
rbnb: { badge: "r🏠", color: "#fbbf24" },
rvnb: { badge: "r🚐", color: "#a5f3fc" },
rtasks: { badge: "r📋", color: "#cbd5e1" },
rschedule: { badge: "r⏱", color: "#a5b4fc" },
crowdsurf: { badge: "r🏄", color: "#fde68a" },
rids: { badge: "r🪪", color: "#6ee7b7" },
rstack: { badge: "r✨", color: "#c4b5fd" },
};
const FAVICON_BADGE_JSON = JSON.stringify(FAVICON_BADGE_MAP);
/** Generate an inline script that sets the favicon to the module's badge SVG */
function faviconScript(moduleId: string): string {
return `<script data-mod="${moduleId}">(function(){var m=${FAVICON_BADGE_JSON};var id=document.currentScript.dataset.mod;if(!id||!m[id])return;var b=m[id];var s='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="14" fill="'+b.color+'"/><text x="32" y="44" text-anchor="middle" font-size="26" font-family="sans-serif">'+b.badge+'</text></svg>';var l=document.querySelector('link[rel="icon"]');if(l)l.href="data:image/svg+xml,"+encodeURIComponent(s)})()</script>`;
}
// ── Content-hash cache busting ──
let moduleHashes: Record<string, string> = {};
try {
const hashFile = resolve(import.meta.dir, "../dist/module-hashes.json");
moduleHashes = JSON.parse(await Bun.file(hashFile).text());
} catch { /* dev mode or first run — no hashes yet */ }
/** Append ?v=<content-hash> to module/shell asset URLs in rendered HTML. */
export function versionAssetUrls(html: string): string {
return html.replace(
/(["'])(\/(?:modules\/[^"'?]+\.(?:js|css)|shell\.js|shell\.css|theme\.css))(?:\?v=[^"']*)?(\1)/g,
(_, q, path, qEnd) => {
const hash = moduleHashes[path];
return hash ? `${q}${path}?v=${hash}${qEnd}` : `${q}${path}${qEnd}`;
}
);
}
/** Extract enabledModules and encryption status from a loaded space. */
export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[] | null; spaceEncrypted: boolean } {
const data = getDocumentData(spaceSlug);
return {
enabledModules: data?.meta?.enabledModules ?? null,
spaceEncrypted: !!data?.meta?.encrypted,
};
}
export interface ShellOptions {
/** Page <title> */
title: string;
/** Current module ID (highlighted in app switcher) */
moduleId: string;
/** Current space slug */
spaceSlug: string;
/** Space display name */
spaceName?: string;
/** Module HTML content to inject into <main> */
body: string;
/** Additional <script type="module"> tags for module-specific JS */
scripts?: string;
/** Additional <link>/<style> tags for module-specific CSS */
styles?: string;
/** List of available modules (for app switcher) */
modules: ModuleInfo[];
/** Theme for the header: 'dark' or 'light' */
theme?: "dark" | "light";
/** Extra <head> content (meta tags, preloads, etc.) */
head?: string;
/** Space visibility level (for client-side access gate) */
spaceVisibility?: string;
/** Enabled modules for this space (null = all). Filters the app switcher. */
enabledModules?: string[] | null;
/** Whether this space has client-side encryption enabled */
spaceEncrypted?: boolean;
/** Optional tab bar rendered below the subnav. */
tabs?: Array<{ id: string; label: string; icon?: string }>;
/** Active tab ID (matched from URL path by server). First tab if omitted. */
activeTab?: string;
/** Base path for tab links (default: /{space}/{moduleId}). Set to e.g. "/{space}/rnetwork/crm" for sub-pages. */
tabBasePath?: string;
/** Whether this page is being served via subdomain routing (omit space from paths) */
isSubdomain?: boolean;
}
// In production, always use subdomain-style paths (/{moduleId}) unless explicitly overridden
const IS_PRODUCTION = process.env.NODE_ENV === "production";
/**
* Build a full external URL for a space + path, using subdomain routing in production.
* E.g. buildSpaceUrl("demo", "/rcart/pay/123", host) → "https://demo.rspace.online/rcart/pay/123"
*/
export function buildSpaceUrl(space: string, path: string, host?: string): string {
if (IS_PRODUCTION) {
return `https://${space}.rspace.online${path}`;
}
// Dev/localhost
const h = host || "localhost:3000";
return `http://${h}/${space}${path}`;
}
export function renderShell(opts: ShellOptions): string {
const {
title,
moduleId,
spaceSlug,
spaceName,
body,
scripts = "",
styles = "",
modules,
theme = "dark",
head = "",
spaceVisibility = "public",
} = opts;
// Auto-populate from space data when not explicitly provided
const spaceMeta = (opts.enabledModules === undefined || opts.spaceEncrypted === undefined)
? getSpaceShellMeta(spaceSlug)
: null;
const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null;
const spaceEncrypted = opts.spaceEncrypted ?? spaceMeta?.spaceEncrypted ?? false;
// Build scope config for client-side runtime
const spaceData = getDocumentData(spaceSlug);
const scopeOverrides = spaceData?.meta?.moduleScopeOverrides ?? {};
// Filter modules by enabledModules (null = show all)
const visibleModules = enabledModules
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
: modules;
const moduleListJSON = JSON.stringify(visibleModules);
// Full catalog with enabled flags for "Manage rApps" panel
const allModulesJSON = JSON.stringify(modules.map(m => ({
...m,
enabled: !enabledModules || enabledModules.includes(m.id) || m.id === "rspace",
})));
const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/favicon.png">
${faviconScript(moduleId)}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(title)}</title>
<meta name="description" content="rSpace — local-first community platform with 25+ composable apps. Encrypted, interoperable, self-sovereign.">
<meta property="og:type" content="website">
<meta property="og:title" content="${escapeAttr(title)}">
<meta property="og:description" content="rSpace — local-first community platform with 25+ composable apps. Encrypted, interoperable, self-sovereign.">
<meta property="og:image" content="https://rspace.online/og-image.png">
<meta property="og:site_name" content="rSpace">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://rspace.online/og-image.png">
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
hide the shell chrome — the parent rSpace page already provides it. */
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
html.rspace-embedded .rapp-subnav { display: none !important; }
html.rspace-embedded .rapp-tabbar { display: none !important; }
html.rspace-embedded #app { padding-top: 0 !important; }
html.rspace-embedded .rspace-iframe-loading,
html.rspace-embedded .rspace-iframe-error { top: 0 !important; }
</style>
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
${styles}
${head}
<style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style>
<style>${INFO_PANEL_CSS}</style>
<style>${SUBNAV_CSS}</style>
<style>${TABBAR_CSS}</style>
</head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>${spaceEncrypted ? '<span class="rstack-header__encrypted" title="End-to-end encrypted space">&#x1F512;</span>' : ''}<button class="rstack-header__history-btn" id="history-btn" title="History"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></button>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
<rstack-offline-indicator></rstack-offline-indicator>
<rstack-notification-bell></rstack-notification-bell>
<rstack-share-panel></rstack-share-panel>
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<rstack-identity></rstack-identity>
</div>
</header>
<rstack-space-settings space="${escapeAttr(spaceSlug)}" module-id="${escapeAttr(moduleId)}"></rstack-space-settings>
<rstack-history-panel></rstack-history-panel>
<div class="rstack-tab-row">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat">
<button class="rapp-info-btn" id="rapp-info-btn" title="About this rApp"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></button>
</rstack-tab-bar>
</div>
<div id="rapp-info-overlay" class="rapp-info-overlay" style="display:none"></div>
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
<div class="rapp-info-panel__header">
<div class="rapp-info-panel__header-left">
<span class="rapp-info-panel__icon" id="rapp-info-icon"></span>
<span class="rapp-info-panel__title" id="rapp-info-title">About this rApp</span>
</div>
<button class="rapp-info-panel__close" id="rapp-info-close">&times;</button>
</div>
<div class="rapp-info-panel__body" id="rapp-info-body"></div>
</div>
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
${renderModuleSubNav(moduleId, spaceSlug, visibleModules, opts.isSubdomain ?? IS_PRODUCTION)}
${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || ((opts.isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`)) : ''}
${body}
</main>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-collab-overlay>
${renderWelcomeOverlay()}
<script type="module">
import '/shell.js';
// ── Service worker registration ──
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
// ── Install prompt capture ──
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
window.dispatchEvent(new CustomEvent("rspace-install-available"));
});
// ── Settings panel toggle ──
document.getElementById('settings-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-space-settings');
if (panel) panel.toggle();
});
// ── History panel toggle ──
document.getElementById('history-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-history-panel');
if (panel) panel.toggle();
});
// Wire history panel to offline runtime doc (module pages)
{
const hp = document.querySelector('rstack-history-panel');
if (hp && window.__rspaceOfflineRuntime) {
const rt = window.__rspaceOfflineRuntime;
rt.init().then(() => {
const docs = rt.documentManager?.listAll() || [];
if (docs.length > 0) {
const doc = rt.documentManager.get(docs[0]);
if (doc) hp.setDoc(doc);
}
}).catch(() => {});
}
}
// ── Info popover: shows landing page content for the active rApp ──
(function() {
const infoBtn = document.getElementById('rapp-info-btn');
const infoPanel = document.getElementById('rapp-info-panel');
const infoBody = document.getElementById('rapp-info-body');
const infoClose = document.getElementById('rapp-info-close');
const infoOverlay = document.getElementById('rapp-info-overlay');
const infoIcon = document.getElementById('rapp-info-icon');
const infoTitle = document.getElementById('rapp-info-title');
if (!infoBtn || !infoPanel || !infoBody) return;
let infoPanelModuleId = '';
function showInfoPanel(moduleId) {
infoPanelModuleId = moduleId;
infoPanel.style.display = '';
if (infoOverlay) infoOverlay.style.display = '';
infoBtn.classList.add('rapp-info-btn--active');
// Lazy-load content
if (infoPanel.dataset.loadedModule !== moduleId) {
infoBody.innerHTML = '<div class="rapp-info-panel__loading"><div class="rapp-info-panel__spinner"></div>Loading…</div>';
fetch('/api/modules/' + encodeURIComponent(moduleId) + '/landing')
.then(r => r.json())
.then(data => {
if (infoPanelModuleId !== moduleId) return; // stale
infoBody.innerHTML = data.html || '<p>No info available.</p>';
infoPanel.dataset.loadedModule = moduleId;
// Update header with module icon + name
if (infoIcon && data.icon) infoIcon.textContent = data.icon;
if (infoTitle && data.name) infoTitle.textContent = data.name;
})
.catch(() => { infoBody.innerHTML = '<p>Failed to load info.</p>'; });
}
}
function hideInfoPanel() {
infoPanel.style.display = 'none';
if (infoOverlay) infoOverlay.style.display = 'none';
infoBtn.classList.remove('rapp-info-btn--active');
}
// Click overlay to dismiss
if (infoOverlay) infoOverlay.addEventListener('click', hideInfoPanel);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && infoPanel.style.display !== 'none') hideInfoPanel();
});
infoBtn.addEventListener('click', () => {
if (infoPanel.style.display !== 'none') { hideInfoPanel(); return; }
// Resolve the currently active module from the tab bar
const tb = document.querySelector('rstack-tab-bar');
const activeLayerId = tb?.getAttribute('active') || '';
const activeModuleId = activeLayerId.replace('layer-', '') || '${escapeAttr(moduleId)}';
showInfoPanel(activeModuleId);
});
if (infoClose) infoClose.addEventListener('click', hideInfoPanel);
// Reset when switching tabs
document.addEventListener('layer-view-mode', hideInfoPanel);
const tb = document.querySelector('rstack-tab-bar');
if (tb) {
tb.addEventListener('layer-switch', (e) => {
hideInfoPanel();
// Reset cached module so next open loads fresh content for new tab
infoPanel.dataset.loadedModule = '';
});
}
// Auto-show on first visit per rApp
const seenKey = 'rapp_info_seen_' + '${escapeAttr(moduleId)}';
if (!localStorage.getItem(seenKey)) {
// Only auto-show for logged-in users (not on first-ever visit)
try {
if (localStorage.getItem('encryptid_session')) {
setTimeout(() => { showInfoPanel('${escapeAttr(moduleId)}'); }, 800);
localStorage.setItem(seenKey, '1');
}
} catch(e) {}
}
// Expose for tour integration
window.__rspaceShowInfo = showInfoPanel;
window.__rspaceHideInfo = hideInfoPanel;
})();
// ── Invite acceptance on page load ──
(function() {
var params = new URLSearchParams(window.location.search);
var inviteToken = params.get('invite');
if (!inviteToken) return;
// Remove token from URL immediately
params.delete('invite');
var newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
history.replaceState(null, '', newUrl);
// Wait for auth then accept
function tryAccept() {
try {
var raw = localStorage.getItem('encryptid_session');
if (!raw) return;
var session = JSON.parse(raw);
if (!session || !session.accessToken) return;
fetch('/' + '${escapeAttr(spaceSlug)}' + '/invite/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
body: JSON.stringify({ inviteToken: inviteToken }),
}).then(function(res) { return res.json(); }).then(function(data) {
if (data.ok) { window.location.reload(); }
});
} catch(e) {}
}
tryAccept();
// Also try after auth-change (if user signs in after landing)
document.addEventListener('auth-change', tryAccept);
})();
// Restore saved theme preference across header / tab-row
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
// Provide module list to app switcher and offline runtime
window.__rspaceModuleList = ${moduleListJSON};
window.__rspaceAllModules = ${allModulesJSON};
const _switcher = document.querySelector('rstack-app-switcher');
_switcher?.setModules(window.__rspaceModuleList);
_switcher?.setAllModules(window.__rspaceAllModules);
// ── "Try Demo" button visibility ──
// Hidden when logged in. When logged out, shown everywhere except demo.rspace.online
// (bare rspace.online rewrites to demo internally but still shows the button).
(function() {
var btn = document.querySelector('.rstack-header__demo-btn');
if (!btn) return;
function update() {
var loggedIn = false;
try { loggedIn = !!localStorage.getItem('encryptid_session'); } catch(e) {}
if (loggedIn) { btn.setAttribute('data-hide', ''); return; }
var host = window.location.host.split(':')[0];
if (host === 'demo.rspace.online') { btn.setAttribute('data-hide', ''); }
else { btn.removeAttribute('data-hide'); }
}
update();
document.addEventListener('auth-change', update);
})();
// ── Welcome overlay (first visit to demo) ──
(function() {
var currentSpace = '${escapeAttr(spaceSlug)}';
if (currentSpace !== 'demo') return;
if (localStorage.getItem('rspace_welcomed')) return;
var el = document.getElementById('rspace-welcome');
if (el) el.style.display = 'flex';
})();
window.__rspaceDismissWelcome = function() {
localStorage.setItem('rspace_welcomed', '1');
var el = document.getElementById('rspace-welcome');
if (el) el.style.display = 'none';
};
// ── Save-gate: prompt sign-in on write when unauthenticated ──
window.__rspaceSaveGate = function(callback) {
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session && session.accessToken) return true; // authenticated
}
} catch(e) {}
// Not authenticated — show modal
var identity = document.querySelector('rstack-identity');
if (identity && identity.showAuthModal) {
identity.showAuthModal({
title: 'Sign in to save',
message: 'Sign in with EncryptID to save your work to your own rSpace.',
onSuccess: function() { if (typeof callback === 'function') callback(); }
});
}
return false;
};
// ── Private space access gate ──
// If the space is private and no session exists, show a sign-in gate
(function() {
var vis = document.body.getAttribute('data-space-visibility');
if (vis !== 'private') return;
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session && session.accessToken) return;
}
} catch(e) {}
// No valid session — gate the content
var main = document.getElementById('app');
if (main) main.style.display = 'none';
var gate = document.createElement('div');
gate.id = 'rspace-access-gate';
gate.innerHTML =
'<div class="access-gate__card">' +
'<div class="access-gate__icon">&#x1F512;</div>' +
'<h2 class="access-gate__title">Private Space</h2>' +
'<p class="access-gate__desc">This space is private. Sign in to continue.</p>' +
'<button class="access-gate__btn" id="gate-signin">Sign In</button>' +
'</div>';
document.body.appendChild(gate);
var btn = document.getElementById('gate-signin');
if (btn) btn.addEventListener('click', function() {
var identity = document.querySelector('rstack-identity');
if (identity && identity.showAuthModal) {
identity.showAuthModal({
onSuccess: function() {
gate.remove();
if (main) main.style.display = '';
}
});
}
});
})();
// ── Tab bar / Layer system initialization ──
// Tabs persist in localStorage so they survive full-page navigations.
// When a user opens a new rApp (via the app switcher or tab-add),
// the next page load reads the existing tabs and adds the new module.
const tabBar = document.querySelector('rstack-tab-bar');
const spaceSlug = '${escapeAttr(spaceSlug)}';
let currentModuleId = '${escapeAttr(moduleId)}';
const TABS_KEY = 'rspace_tabs_' + spaceSlug;
const moduleList = ${moduleListJSON};
if (tabBar) {
// Provide module list for the + add menu dropdown
tabBar.setModules(moduleList);
// Helper: look up a module's display name
function getModuleLabel(id) {
const m = moduleList.find(mod => mod.id === id);
return m ? m.name : id;
}
// Helper: deduplicate layers by moduleId (keeps first occurrence)
function deduplicateLayers(list) {
const seen = new Set();
return list.filter(l => {
if (seen.has(l.moduleId)) return false;
seen.add(l.moduleId);
return true;
});
}
// Helper: create a layer object
function makeLayer(id, order) {
return {
id: 'layer-' + id,
moduleId: id,
label: getModuleLabel(id),
order: order,
color: '',
visible: true,
createdAt: Date.now(),
};
}
// ── Restore tabs from localStorage ──
let layers;
try {
const saved = localStorage.getItem(TABS_KEY);
layers = saved ? JSON.parse(saved) : [];
if (!Array.isArray(layers)) layers = [];
} catch(e) { layers = []; }
// Ensure the current module is in the tab list
if (!layers.find(l => l.moduleId === currentModuleId)) {
layers.push(makeLayer(currentModuleId, layers.length));
}
// Persist immediately (includes the newly-added tab)
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
// Render all tabs with the current one active
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
// Track current module as recently used
if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId);
// Helper: save current tab list to localStorage + server
let _tabSaveTimer = null;
// Track tabs closed this session so server merge doesn't resurrect them
const _closedModuleIds = new Set();
function saveTabs(immediate) {
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
// Debounced server save for authenticated users
clearTimeout(_tabSaveTimer);
const doSave = () => {
try {
const raw = localStorage.getItem('encryptid_session');
if (!raw) return;
const session = JSON.parse(raw);
if (!session?.accessToken) return;
const url = '/api/user/tabs/' + encodeURIComponent(spaceSlug);
const body = JSON.stringify({ tabs: layers });
fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
body: body,
keepalive: immediate, // survive page navigation when flushing immediately
}).catch(() => {});
} catch(e) {}
};
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
// Broadcast to other same-browser tabs
if (_tabChannel) {
_tabChannel.postMessage({
type: 'tabs-sync',
layers: layers,
closed: [..._closedModuleIds],
});
}
}
// ── BroadcastChannel: same-browser cross-tab sync ──
const _tabChannel = (() => {
try { return new BroadcastChannel('rspace_tabs_' + spaceSlug); }
catch(e) { return null; }
})();
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
function reconcileRemoteLayers(remoteLayers) {
const prev = new Set(layers.map(l => l.moduleId));
const next = new Set(remoteLayers.map(l => l.moduleId));
// Remove cached panes for tabs that disappeared
for (const mid of prev) {
if (!next.has(mid) && tabCache) tabCache.removePane(mid);
}
layers = deduplicateLayers(remoteLayers);
if (currentModuleId && !layers.find(l => l.moduleId === currentModuleId)) {
// Active tab was closed remotely — switch to nearest
if (layers.length > 0) {
const nearest = layers[layers.length - 1];
currentModuleId = nearest.moduleId;
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
if (tabCache) {
tabCache.switchTo(currentModuleId).then(ok => {
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, currentModuleId);
});
}
} else {
// No tabs left — show dashboard
tabBar.setLayers([]);
tabBar.setAttribute('active', '');
currentModuleId = '';
if (tabCache) tabCache.hideAllPanes();
const dashboard = document.querySelector('rstack-user-dashboard');
if (dashboard) { dashboard.style.display = ''; if (dashboard.refresh) dashboard.refresh(); }
const app = document.getElementById('app');
if (app) app.classList.remove('canvas-layout');
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug);
}
} else {
tabBar.setLayers(layers);
}
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
}
if (_tabChannel) {
_tabChannel.onmessage = (e) => {
if (e.data?.type !== 'tabs-sync') return;
const remoteLayers = e.data.layers || [];
for (const mid of (e.data.closed || [])) _closedModuleIds.add(mid);
reconcileRemoteLayers(remoteLayers);
};
}
window.addEventListener('beforeunload', () => {
if (_tabChannel) _tabChannel.close();
});
// Fetch tabs from server for authenticated users (merge with localStorage)
(function syncTabsFromServer() {
try {
const raw = localStorage.getItem('encryptid_session');
if (!raw) return;
const session = JSON.parse(raw);
if (!session?.accessToken) return;
fetch('/api/user/tabs/' + encodeURIComponent(spaceSlug), {
headers: { 'Authorization': 'Bearer ' + session.accessToken },
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
// Server has nothing — push localStorage tabs up
saveTabs();
return;
}
// Merge: union of moduleIds, server order wins for shared tabs
// Skip tabs that were closed this session (prevents resurrection)
const serverMap = new Map(data.tabs.map(t => [t.moduleId, t]));
const localMap = new Map(layers.map(t => [t.moduleId, t]));
const merged = data.tabs.filter(t => !_closedModuleIds.has(t.moduleId));
for (const [mid, lt] of localMap) {
if (!serverMap.has(mid) && !_closedModuleIds.has(mid)) merged.push(lt);
}
merged.forEach((l, i) => { l.order = i; });
layers = merged;
// Ensure current module is present
if (!layers.find(l => l.moduleId === currentModuleId)) {
layers.push(makeLayer(currentModuleId, layers.length));
}
tabBar.setLayers(layers);
saveTabs();
})
.catch(() => {});
} catch(e) {}
})();
// ── Tab cache: instant switching via show/hide DOM panes ──
let tabCache = null;
try {
const TC = window.__RSpaceTabCache;
if (TC) {
tabCache = new TC(spaceSlug, currentModuleId);
if (!tabCache.init()) tabCache = null;
}
} catch(e) { tabCache = null; }
// ── Tab events ──
// Set active on tab bar ONLY after switchTo() confirms the pane is ready.
// This prevents the visual desync where the tab highlights before content loads.
tabBar.addEventListener('layer-switch', (e) => {
const { layerId, moduleId } = e.detail;
currentModuleId = moduleId;
saveTabs();
// Update settings panel to show config for the newly active module
const sp = document.querySelector('rstack-space-settings');
if (sp) sp.setAttribute('module-id', moduleId);
if (tabCache) {
tabCache.switchTo(moduleId).then(ok => {
if (ok) {
tabBar.setAttribute('active', layerId);
} else {
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
}
});
} else {
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
}
});
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
currentModuleId = moduleId;
if (!layers.find(l => l.moduleId === moduleId)) {
layers.push(makeLayer(moduleId, layers.length));
}
saveTabs();
tabBar.setLayers(layers);
if (tabCache) {
tabCache.switchTo(moduleId).then(ok => {
if (ok) {
tabBar.setAttribute('active', 'layer-' + moduleId);
} else {
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
}
});
} else {
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
}
});
tabBar.addEventListener('layer-close', (e) => {
const { layerId } = e.detail;
const closedLayer = layers.find(l => l.id === layerId);
const closedModuleId = layerId.replace('layer-', '');
const closedIdx = layers.findIndex(l => l.id === layerId);
// Use tabBar.active as source of truth (always updated by TabCache)
const wasActive = layerId === tabBar.getAttribute('active');
tabBar.removeLayer(layerId);
layers = layers.filter(l => l.id !== layerId);
// Track closed tab so server merge doesn't resurrect it
_closedModuleIds.add(closedModuleId);
saveTabs();
// If this was a space layer with a persisted SpaceRef, clean it up
if (closedLayer?.spaceSlug && closedLayer._spaceRefId) {
const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1];
if (token) {
fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest/' + encodeURIComponent(closedLayer._spaceRefId), {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
}).catch(() => { /* best-effort cleanup */ });
}
}
// Remove cached pane from DOM
if (tabCache) tabCache.removePane(closedModuleId);
if (layers.length === 0) {
// No tabs left — show the dashboard
if (tabCache) tabCache.hideAllPanes();
const dashboard = document.querySelector('rstack-user-dashboard');
if (dashboard) {
dashboard.style.display = '';
if (dashboard.refresh) dashboard.refresh();
}
const app = document.getElementById('app');
if (app) app.classList.remove('canvas-layout');
tabBar.setAttribute('active', '');
tabBar.setLayers([]);
currentModuleId = '';
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug);
} else if (wasActive) {
// Closed the active tab — switch to the nearest remaining tab
const nextLayer = layers[Math.min(closedIdx, layers.length - 1)] || layers[0];
const nextModuleId = nextLayer.moduleId;
currentModuleId = nextModuleId;
if (tabCache) {
tabCache.switchTo(nextModuleId).then(ok => {
if (!ok) {
saveTabs(true); // flush to server before navigation
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
}
});
} else {
saveTabs(true); // flush to server before navigation
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
}
}
});
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
const oldIdx = layers.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = layers.splice(oldIdx, 1);
layers.splice(newIndex, 0, moved);
layers.forEach((l, i) => l.order = i);
saveTabs();
tabBar.setLayers(layers);
});
// ── Dashboard navigate: user clicked a space/action on the dashboard ──
document.addEventListener('dashboard-navigate', (e) => {
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
const dashboard = document.querySelector('rstack-user-dashboard');
if (dashboard) dashboard.style.display = 'none';
// If navigating to a different space, do a full navigation
if (targetSpace && targetSpace !== spaceSlug) {
window.location.href = window.__rspaceNavUrl(targetSpace, targetModule || 'rspace');
return;
}
const modId = targetModule || 'rspace';
currentModuleId = modId;
// Add tab if not already present
if (!layers.find(l => l.moduleId === modId)) {
layers.push(makeLayer(modId, layers.length));
}
saveTabs();
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + modId);
if (tabCache) {
tabCache.switchTo(modId).then(ok => {
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, modId);
});
} else {
window.location.href = window.__rspaceNavUrl(spaceSlug, modId);
}
});
tabBar.addEventListener('view-toggle', (e) => {
const { mode } = e.detail;
document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } }));
});
// ── Space-switch: client-side space navigation ──
// When user clicks a space in the space-switcher, handle it client-side
// if tabCache is available. Updates URL, tabs, and shell chrome.
const spaceSwitcher = document.querySelector('rstack-space-switcher');
if (spaceSwitcher) {
spaceSwitcher.addEventListener('space-switch', (e) => {
const { space: newSpace, moduleId: targetModule, href } = e.detail;
if (!tabCache || !newSpace) return;
e.preventDefault(); // signal to space-switcher that we're handling it
const targetModuleId = targetModule || currentModuleId;
tabCache.switchSpace(newSpace, targetModuleId).then(ok => {
if (!ok) {
// Client-side switch failed — full navigation
window.location.href = href || window.__rspaceNavUrl(newSpace, targetModuleId);
return;
}
// Update runtime to switch space
const runtime = window.__rspaceOfflineRuntime;
if (runtime && runtime.switchSpace) runtime.switchSpace(newSpace);
// Update tab state for the new space
const newTabsKey = 'rspace_tabs_' + newSpace;
let newLayers;
try {
const saved = localStorage.getItem(newTabsKey);
newLayers = saved ? JSON.parse(saved) : [];
if (!Array.isArray(newLayers)) newLayers = [];
} catch(e) { newLayers = []; }
if (!newLayers.find(l => l.moduleId === targetModuleId)) {
newLayers.push(makeLayer(targetModuleId, newLayers.length));
}
localStorage.setItem(newTabsKey, JSON.stringify(newLayers));
// Update layers reference and tab bar
layers = newLayers;
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + targetModuleId);
tabBar.setAttribute('space', newSpace);
});
});
}
// ── App-switcher → tab system integration ──
// When user picks a module from the app-switcher, route through tabs
// instead of doing a full page navigation.
const appSwitcher = document.querySelector('rstack-app-switcher');
if (appSwitcher) {
appSwitcher.addEventListener('module-select', (e) => {
const { moduleId } = e.detail;
// Already on this module? No-op.
if (moduleId === currentModuleId && !tabCache) return;
if (moduleId === currentModuleId && tabCache) {
tabCache.switchTo(moduleId);
return;
}
currentModuleId = moduleId;
// Add tab if not already open
if (!layers.find(l => l.moduleId === moduleId)) {
const newLayer = makeLayer(moduleId, layers.length);
layers.push(newLayer);
// Sync to Automerge so other windows see this tab
if (communitySync) communitySync.addLayer(newLayer);
}
saveTabs();
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + moduleId);
if (tabCache) {
tabCache.switchTo(moduleId).then(ok => {
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
});
} else {
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
}
});
}
// ── Space Layer: cross-space data overlay ──
tabBar.addEventListener('space-layer-add', (e) => {
const { layer, spaceSlug: layerSpace, role } = e.detail;
if (!layers.find(l => l.id === layer.id)) {
layers.push(layer);
}
saveTabs();
// Persist as a SpaceRef via the nesting API (best-effort)
const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1];
if (token) {
fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({
sourceSlug: layerSpace,
label: layer.label || layerSpace,
permissions: { read: true, write: role !== 'viewer' },
}),
}).then(res => res.json()).then(data => {
if (data.ref) layer._spaceRefId = data.ref.id;
}).catch(() => { /* best-effort */ });
}
// Connect offline runtime to the source space for cross-space data
const runtime = window.__rspaceOfflineRuntime;
if (runtime && runtime.connectToSpace) {
runtime.connectToSpace(layerSpace).then(() => {
// Dispatch event for canvas and modules to pick up the new space layer
document.dispatchEvent(new CustomEvent('space-layer-added', {
detail: { spaceSlug: layerSpace, role, layerId: layer.id },
}));
}).catch(() => {
console.warn('[shell] Failed to connect to space layer:', layerSpace);
});
}
});
// Expose tabBar for CommunitySync integration
window.__rspaceTabBar = tabBar;
// Sync reference (set when CommunitySync connects)
let communitySync = null;
// ── CommunitySync: merge with Automerge once connected ──
// NOTE: The TAB LIST is shared via Automerge (which modules are open),
// but the ACTIVE TAB is always local (determined by the URL / currentModuleId).
// This prevents windows from fighting over which tab is highlighted.
document.addEventListener('community-sync-ready', (e) => {
const sync = e.detail?.sync;
if (!sync) return;
communitySync = sync;
const localActiveId = 'layer-' + currentModuleId;
// Merge: Automerge layers win if they exist, otherwise seed from localStorage
const remoteLayers = sync.getLayers();
if (remoteLayers.length > 0) {
// Ensure current module is also in the Automerge set
if (!remoteLayers.find(l => l.moduleId === currentModuleId)) {
const newLayer = makeLayer(currentModuleId, remoteLayers.length);
sync.addLayer(newLayer);
}
layers = deduplicateLayers(sync.getLayers());
tabBar.setLayers(layers);
tabBar.setFlows(sync.getFlows());
} else {
// First connection: push all localStorage tabs into Automerge
for (const l of layers) {
sync.addLayer(l);
}
}
// Active tab stays local — always matches the URL
tabBar.setAttribute('active', localActiveId);
// Keep localStorage in sync
saveTabs();
// Sync layer list changes to Automerge (not active tab)
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
const newLayer = makeLayer(moduleId, sync.getLayers().length);
sync.addLayer(newLayer);
});
tabBar.addEventListener('layer-close', (e) => {
sync.removeLayer(e.detail.layerId);
});
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
const all = sync.getLayers(); // already sorted by order
const oldIdx = all.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = all.splice(oldIdx, 1);
all.splice(newIndex, 0, moved);
all.forEach((l, i) => sync.updateLayer(l.id, { order: i }));
});
tabBar.addEventListener('flow-create', (e) => { sync.addFlow(e.detail.flow); });
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow(e.detail.flowId); });
tabBar.addEventListener('view-toggle', (e) => { sync.setLayerViewMode(e.detail.mode); });
// Listen for remote changes — sync tab list and flows only.
// Never touch the active tab: it's managed locally by TabCache
// and the tab-bar component via layer-switch events.
sync.addEventListener('change', () => {
reconcileRemoteLayers(sync.getLayers());
tabBar.setFlows(sync.getFlows());
const viewMode = sync.doc.layerViewMode;
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
});
});
// ── Keyboard shortcuts: Ctrl+19 (PWA) / Alt+19 (browser) ──
document.addEventListener('keydown', (e) => {
const digit = e.key >= '1' && e.key <= '9' ? e.key : null;
if (!digit) return;
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
if ((isStandalone && e.ctrlKey && !e.shiftKey && !e.altKey) ||
(!isStandalone && e.altKey && !e.ctrlKey && !e.shiftKey)) {
e.preventDefault();
try {
const shortcuts = JSON.parse(localStorage.getItem('rspace-shortcuts') || '{}');
const targetModule = shortcuts[digit];
if (targetModule) {
const sw = document.querySelector('rstack-app-switcher');
if (sw) {
sw.dispatchEvent(new CustomEvent('module-select', {
detail: { moduleId: targetModule },
bubbles: true, composed: true,
}));
}
}
} catch(ex) {}
}
});
// ── Swipe gestures on header: left/right to cycle rApps ──
(function() {
let touchStartX = 0, touchStartTime = 0;
const header = document.querySelector('.rstack-header');
if (!header) return;
header.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartTime = Date.now();
}, { passive: true });
header.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dt = Date.now() - touchStartTime;
if (Math.abs(dx) > 80 && dt < 300) {
try {
const shortcuts = JSON.parse(localStorage.getItem('rspace-shortcuts') || '{}');
const order = Object.entries(shortcuts)
.sort(([a],[b]) => a.localeCompare(b))
.map(([,m]) => m);
if (order.length < 2) return;
const current = document.body.dataset.moduleId || currentModuleId;
const idx = order.indexOf(current);
const next = dx < 0
? order[(idx + 1) % order.length]
: order[(idx - 1 + order.length) % order.length];
const sw = document.querySelector('rstack-app-switcher');
if (sw) {
sw.dispatchEvent(new CustomEvent('module-select', {
detail: { moduleId: next },
bubbles: true, composed: true,
}));
}
} catch(ex) {}
}
}, { passive: true });
})();
}
</script>
${scripts}
</body>
</html>`);
}
// ── External app iframe shell ──
export interface ExternalAppShellOptions {
/** Page <title> */
title: string;
/** Current module ID */
moduleId: string;
/** Current space slug */
spaceSlug: string;
/** Space display name */
spaceName?: string;
/** List of available modules */
modules: ModuleInfo[];
/** External app URL to embed */
appUrl: string;
/** External app display name */
appName: string;
/** Theme */
theme?: "dark" | "light";
}
export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
const {
title,
moduleId,
spaceSlug,
spaceName,
modules,
appUrl,
appName,
theme = "dark",
} = opts;
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `?view=demo`;
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.png">
${faviconScript(moduleId)}
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
html.rspace-embedded .rspace-iframe-wrap { top: 0 !important; height: 100vh !important; }
</style>
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
</head>
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<a href="${demoUrl}" class="rapp-nav__btn rapp-nav__btn--secondary" style="font-size:0.78rem;padding:5px 12px;">Back to Demo</a>
<rstack-identity></rstack-identity>
</div>
</header>
<div class="rstack-tab-row">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
</div>
<div class="rspace-iframe-wrap">
<div class="rspace-iframe-loading" id="iframe-loading">
<div class="rspace-iframe-spinner"></div>
<span>Loading ${escapeHtml(appName)}…</span>
</div>
<iframe
class="rspace-iframe"
src="${escapeAttr(appUrl)}"
title="${escapeAttr(appName)}"
allow="camera; microphone; display-capture; clipboard-read; clipboard-write; fullscreen"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"
onload="document.getElementById('iframe-loading').style.display='none'"
></iframe>
<a class="rspace-iframe-newtab" href="${escapeAttr(appUrl)}" target="_blank" rel="noopener">Open in new tab ↗</a>
</div>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}" mode="badge-only"></rstack-collab-overlay>
<script type="module">
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar');
const spaceSlug = '${escapeAttr(spaceSlug)}';
const currentModuleId = '${escapeAttr(moduleId)}';
const TABS_KEY = 'rspace_tabs_' + spaceSlug;
const moduleList = ${moduleListJSON};
if (tabBar) {
tabBar.setModules(moduleList);
function getModuleLabel(id) {
const m = moduleList.find(mod => mod.id === id);
return m ? m.name : id;
}
function makeLayer(id, order) {
return { id: 'layer-' + id, moduleId: id, label: getModuleLabel(id), order, color: '', visible: true, createdAt: Date.now() };
}
let layers;
try { const saved = localStorage.getItem(TABS_KEY); layers = saved ? JSON.parse(saved) : []; if (!Array.isArray(layers)) layers = []; } catch(e) { layers = []; }
if (!layers.find(l => l.moduleId === currentModuleId)) layers.push(makeLayer(currentModuleId, layers.length));
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId);
function saveTabs() { localStorage.setItem(TABS_KEY, JSON.stringify(layers)); }
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); });
}
</script>
</body>
</html>`);
}
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ──
function renderWelcomeOverlay(): string {
return `
<div id="rspace-welcome" class="rspace-welcome" style="display:none">
<div class="rspace-welcome__popup">
<button class="rspace-welcome__close" onclick="window.__rspaceDismissWelcome()">&times;</button>
<h2 class="rspace-welcome__title">Welcome to rSpace</h2>
<p class="rspace-welcome__text">
A collaborative, local-first community platform with 22+ interoperable tools.
You're viewing the <strong>demo space</strong> &mdash; sign in to access your own.
</p>
<div class="rspace-welcome__grid">
<span>🎨 Canvas</span><span>📝 Notes</span>
<span>🗳 Voting</span><span>💸 Funds</span>
<span>🗺 Maps</span><span>📁 Files</span>
<span>🔐 Passkeys</span><span>📡 Offline-First</span>
</div>
<div class="rspace-welcome__actions">
<a href="/create-space" class="rspace-welcome__btn rspace-welcome__btn--primary">Create a Space</a>
<button onclick="window.__rspaceDismissWelcome()" class="rspace-welcome__btn rspace-welcome__btn--secondary">Explore Demo</button>
</div>
<div class="rspace-welcome__footer">
<a href="/about" class="rspace-welcome__link">Learn more about rSpace</a>
<span class="rspace-welcome__dot">&middot;</span>
<a href="/rids" class="rspace-welcome__link">EncryptID</a>
</div>
</div>
</div>`;
}
const ACCESS_GATE_CSS = `
#rspace-access-gate {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: var(--rs-bg-overlay); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
.access-gate__card {
text-align: center; color: var(--rs-text-primary); max-width: 400px; padding: 2rem;
}
.access-gate__icon { font-size: 3rem; margin-bottom: 1rem; }
.access-gate__title {
font-size: 1.5rem; margin: 0 0 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.access-gate__desc { color: var(--rs-text-secondary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 1.5rem; }
.access-gate__btn {
padding: 12px 32px; border-radius: 8px; border: none;
font-size: 1rem; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
transition: opacity 0.15s, transform 0.15s;
}
.access-gate__btn:hover { opacity: 0.9; transform: translateY(-1px); }
`;
const WELCOME_CSS = `
.rspace-welcome {
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
display: none; align-items: flex-end; justify-content: flex-end;
}
.rspace-welcome__popup {
position: relative;
width: min(380px, 44vw); max-height: 50vh;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 24px 24px 18px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); color: var(--rs-text-primary);
overflow-y: auto; animation: rspace-welcome-in 0.3s ease-out;
}
@keyframes rspace-welcome-in {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.rspace-welcome__close {
position: absolute; top: 10px; right: 12px;
background: none; border: none; color: var(--rs-text-muted);
font-size: 1.4rem; cursor: pointer; line-height: 1;
padding: 4px; border-radius: 4px;
}
.rspace-welcome__close:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
.rspace-welcome__title {
font-size: 1.35rem; margin: 0 0 8px;
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.rspace-welcome__text {
font-size: 0.85rem; color: var(--rs-text-secondary); margin: 0 0 14px; line-height: 1.55;
}
.rspace-welcome__text strong { color: var(--rs-text-primary); }
.rspace-welcome__grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 5px; margin-bottom: 14px; font-size: 0.8rem; color: var(--rs-text-primary);
}
.rspace-welcome__grid span { padding: 3px 0; }
.rspace-welcome__actions {
display: flex; gap: 8px; margin-bottom: 12px;
}
.rspace-welcome__btn {
padding: 8px 16px; border-radius: 8px; font-size: 0.82rem;
font-weight: 600; text-decoration: none; cursor: pointer; border: none;
transition: transform 0.15s, box-shadow 0.15s;
}
.rspace-welcome__btn:hover { transform: translateY(-1px); }
.rspace-welcome__btn--primary {
background: var(--rs-gradient-cta); color: white;
box-shadow: 0 2px 8px rgba(20,184,166,0.3);
}
.rspace-welcome__btn--secondary {
background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary);
}
.rspace-welcome__btn--secondary:hover { color: var(--rs-text-primary); }
.rspace-welcome__footer {
display: flex; align-items: center; gap: 6px;
}
.rspace-welcome__link {
font-size: 0.72rem; color: var(--rs-text-muted); text-decoration: none;
transition: color 0.15s;
}
.rspace-welcome__link:hover { color: #c4b5fd; }
.rspace-welcome__dot { color: var(--rs-text-muted); font-size: 0.6rem; }
@media (max-width: 600px) {
.rspace-welcome { bottom: 12px; right: 12px; left: 12px; }
.rspace-welcome__popup { width: 100%; max-width: none; }
}
`;
const INFO_PANEL_CSS = `
/* ── Info button in tab bar ── */
.rapp-info-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; margin-right: 2px;
background: none; border: 1px solid transparent; border-radius: 6px;
color: var(--rs-text-muted); cursor: pointer; flex-shrink: 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-info-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); border-color: var(--rs-border); }
.rapp-info-btn--active { color: var(--rs-primary); background: var(--rs-bg-hover); border-color: var(--rs-primary); }
/* ── Backdrop overlay ── */
.rapp-info-overlay {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.45);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: rapp-overlay-in 0.2s ease-out;
}
@keyframes rapp-overlay-in { from { opacity: 0; } to { opacity: 1; } }
/* ── Panel — centered modal ── */
.rapp-info-panel {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
background: var(--rs-bg-surface);
border: 1px solid rgba(20,184,166,0.25);
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(20,184,166,0.1), 0 0 60px rgba(20,184,166,0.06);
display: flex; flex-direction: column; overflow: hidden;
animation: rapp-info-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes rapp-info-in {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.95); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
/* ── Header with icon + name ── */
.rapp-info-panel__header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, rgba(20,184,166,0.12), rgba(79,70,229,0.08));
border-bottom: 1px solid rgba(20,184,166,0.18);
}
.rapp-info-panel__header-left {
display: flex; align-items: center; gap: 10px;
}
.rapp-info-panel__icon {
font-size: 1.5rem; line-height: 1;
}
.rapp-info-panel__title {
font-size: 1.05rem; font-weight: 700; letter-spacing: 0.01em;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.rapp-info-panel__close {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer;
border-radius: 8px; line-height: 1;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-info-panel__close:hover {
color: var(--rs-text-primary); background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.15);
}
/* ── Body ── */
.rapp-info-panel__body {
padding: 0; overflow-y: auto; flex: 1;
color: var(--rs-text-secondary); font-size: 0.92rem; line-height: 1.65;
}
.rapp-info-panel__body h1, .rapp-info-panel__body h2, .rapp-info-panel__body h3 {
color: var(--rs-text-primary); margin: 0 0 8px;
}
.rapp-info-panel__body h1 { font-size: 1.3rem; }
.rapp-info-panel__body h2 { font-size: 1.1rem; }
.rapp-info-panel__body h3 { font-size: 0.95rem; }
.rapp-info-panel__body p { margin: 0 0 10px; }
.rapp-info-panel__body a { color: var(--rs-primary); }
/* ── Loading state ── */
.rapp-info-panel__loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; padding: 48px 32px; color: var(--rs-text-muted);
}
.rapp-info-panel__spinner {
width: 24px; height: 24px;
border: 2px solid rgba(20,184,166,0.2); border-top-color: #14b8a6;
border-radius: 50%; animation: rapp-spin 0.7s linear infinite;
}
@keyframes rapp-spin { to { transform: rotate(360deg); } }
/* ── Panel-scoped overrides for rich landing content ── */
.rapp-info-panel__body .rl-hero { padding: 1.75rem 1.5rem 1.25rem; text-align: center; }
.rapp-info-panel__body .rl-hero .rl-heading { font-size: 1.5rem !important; }
.rapp-info-panel__body .rl-heading { font-size: 1.2rem; margin-bottom: 0.5rem; }
.rapp-info-panel__body .rl-tagline { font-size: 0.65rem; padding: 0.3rem 0.85rem; margin-bottom: 1rem; }
.rapp-info-panel__body .rl-subtitle { font-size: 1rem !important; margin-bottom: 0.75rem; }
.rapp-info-panel__body .rl-subtext { font-size: 0.9rem !important; margin-bottom: 1.25rem; line-height: 1.7; }
.rapp-info-panel__body .rl-hero .rl-subtext { font-size: 0.92rem !important; }
.rapp-info-panel__body .rl-section { padding: 1.5rem 1.25rem; border-top: 1px solid var(--rs-border-subtle); }
.rapp-info-panel__body .rl-section--alt { background: rgba(20,184,166,0.03); }
.rapp-info-panel__body .rl-container { max-width: 100%; }
/* Grids: max 2 columns in panel */
.rapp-info-panel__body .rl-grid-2,
.rapp-info-panel__body .rl-grid-3,
.rapp-info-panel__body .rl-grid-4 { grid-template-columns: 1fr 1fr !important; gap: 0.75rem; }
/* Cards */
.rapp-info-panel__body .rl-card { padding: 1.15rem; border-radius: 0.75rem; }
.rapp-info-panel__body .rl-card h3 { font-size: 0.88rem; margin-bottom: 0.35rem; }
.rapp-info-panel__body .rl-card p { font-size: 0.82rem; line-height: 1.55; margin-bottom: 0; }
/* Icon boxes */
.rapp-info-panel__body .rl-icon-box { width: 2.5rem; height: 2.5rem; font-size: 1.25rem; border-radius: 0.6rem; margin-bottom: 0.65rem; }
.rapp-info-panel__body .rl-card--center .rl-icon-box { margin: 0 auto 0.65rem; }
/* Steps */
.rapp-info-panel__body .rl-step__num { width: 2.25rem; height: 2.25rem; font-size: 0.75rem; margin-bottom: 0.5rem; }
.rapp-info-panel__body .rl-step h3 { font-size: 0.88rem; }
.rapp-info-panel__body .rl-step p { font-size: 0.82rem; }
/* CTAs — bigger, bolder buttons */
.rapp-info-panel__body .rl-cta-row { margin-top: 1.5rem; gap: 0.625rem; display: flex; flex-wrap: wrap; justify-content: center; }
.rapp-info-panel__body .rl-cta-primary {
padding: 0.75rem 1.5rem; font-size: 0.92rem; font-weight: 600;
border-radius: 10px; text-decoration: none;
background: var(--rs-gradient-cta, linear-gradient(135deg, #14b8a6, #06b6d4));
color: #fff; box-shadow: 0 4px 16px rgba(20,184,166,0.3);
transition: transform 0.15s, box-shadow 0.15s;
}
.rapp-info-panel__body .rl-cta-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(20,184,166,0.4); }
.rapp-info-panel__body .rl-cta-secondary {
padding: 0.75rem 1.5rem; font-size: 0.92rem; font-weight: 600;
border-radius: 10px; text-decoration: none;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
color: var(--rs-text-secondary); transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
}
.rapp-info-panel__body .rl-cta-secondary:hover { transform: translateY(-1px); border-color: rgba(20,184,166,0.4); color: var(--rs-text-primary); background: rgba(20,184,166,0.08); }
/* Tour / guide links — promote to prominent buttons */
.rapp-info-panel__body a[onclick*="startTour"],
.rapp-info-panel__body a[href*="tour"] {
display: inline-flex; align-items: center; gap: 6px;
padding: 0.65rem 1.35rem; margin-top: 0.75rem;
font-size: 0.92rem; font-weight: 600; text-decoration: none;
background: linear-gradient(135deg, rgba(79,70,229,0.15), rgba(20,184,166,0.1));
border: 1px solid rgba(79,70,229,0.25); border-radius: 10px; color: #a78bfa;
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
}
.rapp-info-panel__body a[onclick*="startTour"]:hover,
.rapp-info-panel__body a[href*="tour"]:hover {
background: linear-gradient(135deg, rgba(79,70,229,0.25), rgba(20,184,166,0.15));
border-color: rgba(79,70,229,0.4); color: #c4b5fd; transform: translateY(-1px);
}
/* Badges */
.rapp-info-panel__body .rl-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; }
/* Integration cards */
.rapp-info-panel__body .rl-integration { padding: 1rem; border-radius: 0.75rem; gap: 0.75rem; }
.rapp-info-panel__body .rl-integration h3 { font-size: 0.88rem; }
.rapp-info-panel__body .rl-integration p { font-size: 0.82rem; }
/* Check list */
.rapp-info-panel__body .rl-check-list li { font-size: 0.82rem; padding: 0.3rem 0; }
/* Divider */
.rapp-info-panel__body .rl-divider { margin: 1rem 0; }
.rapp-info-panel__body .rl-divider span { font-size: 0.65rem; }
/* Back link */
.rapp-info-panel__body .rl-back { padding: 1.25rem 0 1.5rem; }
.rapp-info-panel__body .rl-back a { font-size: 0.82rem; }
/* Scrollbar styling */
.rapp-info-panel__body { scrollbar-width: thin; scrollbar-color: rgba(20,184,166,0.2) transparent; }
.rapp-info-panel__body::-webkit-scrollbar { width: 5px; }
.rapp-info-panel__body::-webkit-scrollbar-track { background: transparent; }
.rapp-info-panel__body::-webkit-scrollbar-thumb { background: rgba(20,184,166,0.2); border-radius: 9999px; }
.rapp-info-panel__body::-webkit-scrollbar-thumb:hover { background: rgba(20,184,166,0.35); }
@media (max-width: 600px) {
.rapp-info-panel {
top: auto; left: 8px; right: 8px; bottom: 8px;
transform: none; width: auto; max-height: 85vh;
animation: rapp-info-in-mobile 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes rapp-info-in-mobile {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.rapp-info-panel__body .rl-grid-2,
.rapp-info-panel__body .rl-grid-3,
.rapp-info-panel__body .rl-grid-4 { grid-template-columns: 1fr !important; }
}
`;
// ── Module sub-navigation ──
const SUBNAV_CSS = `
.rapp-subnav {
display: flex;
gap: 0.375rem;
padding: 0.5rem 1rem;
overflow-x: auto;
border-bottom: 1px solid var(--rs-border);
background: var(--rs-bg-surface);
scrollbar-width: none;
position: sticky;
top: 92px;
z-index: 100;
}
.rapp-subnav::-webkit-scrollbar { display: none; }
@media (max-width: 640px) {
.rapp-subnav {
position: relative;
top: auto;
padding: 0.375rem 0.75rem;
gap: 0.25rem;
}
}
@media (max-width: 480px) {
.rapp-nav-pill {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
}
.rapp-nav-pill {
padding: 0.3125rem 0.75rem;
border-radius: 999px;
font-size: 0.8125rem;
white-space: nowrap;
color: var(--rs-text-secondary);
text-decoration: none;
border: 1px solid transparent;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-nav-pill:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
.rapp-nav-pill--active {
color: var(--rs-text-primary);
background: var(--rs-bg-surface-raised);
border-color: var(--rs-border);
font-weight: 500;
}
.rapp-nav-pill--external {
margin-left: auto;
color: var(--rs-text-muted);
font-size: 0.75rem;
border: 1px dashed var(--rs-border);
}
.rapp-nav-pill--external:hover { color: var(--rs-text-primary); border-style: solid; }
`;
/** Build the module sub-nav bar from outputPaths + subPageInfos. */
function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: ModuleInfo[], isSubdomain?: boolean): string {
if (moduleId === 'rspace') return ''; // canvas page has its own chrome
const mod = modules.find(m => m.id === moduleId);
if (!mod) return '';
type NavItem = { path: string; label: string; icon?: string };
const items: NavItem[] = [];
if (mod.outputPaths) {
for (const op of mod.outputPaths) {
items.push({ path: op.path, label: op.name, icon: op.icon });
}
}
if (mod.subPageInfos) {
for (const sp of mod.subPageInfos) {
// Skip subPageInfos that overlap with outputPaths
if (!items.find(i => i.path === sp.path)) {
items.push({ path: sp.path, label: sp.title });
}
}
}
// Don't render if no sub-paths
if (items.length === 0) return '';
const base = (isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`;
const pills = [
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`,
...items.map(it =>
`<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
),
...(mod.externalApp ? [`<a class="rapp-nav-pill rapp-nav-pill--external" href="${escapeAttr(mod.externalApp.url)}" target="_blank" rel="noopener">Open ${escapeHtml(mod.externalApp.name)} &#8599;</a>`] : []),
];
return `<nav class="rapp-subnav" id="rapp-subnav">${pills.join('')}</nav>
<script>(function(){var ps=document.querySelectorAll('#rapp-subnav .rapp-nav-pill'),p=location.pathname.replace(/\\/$/,'');var matched=false;ps.forEach(function(a){var h=a.getAttribute('href');if(h&&h===p){a.classList.add('rapp-nav-pill--active');matched=true}else if(h&&p.startsWith(h+'/')&&!a.hasAttribute('data-subnav-root')){a.classList.add('rapp-nav-pill--active');matched=true}});if(!matched){var root=document.querySelector('[data-subnav-root]');if(root)root.classList.add('rapp-nav-pill--active')}})()</script>`;
}
// ── rApp tab bar (in-page tabs via URL subpaths) ──
const TABBAR_CSS = `
.rapp-tabbar {
display: flex;
gap: 0.375rem;
padding: 0.375rem 1rem;
overflow-x: auto;
border-bottom: 1px solid var(--rs-border);
background: var(--rs-bg-surface);
scrollbar-width: none;
position: sticky;
top: 92px;
z-index: 100;
}
.rapp-tabbar::-webkit-scrollbar { display: none; }
.rapp-subnav + .rapp-tabbar { top: 129px; }
@media (max-width: 640px) {
.rapp-tabbar {
position: relative;
top: auto;
padding: 0.25rem 0.75rem;
gap: 0.25rem;
}
.rapp-subnav + .rapp-tabbar { top: auto; }
}
`;
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>, activeTab: string | undefined, basePath: string): string {
if (tabs.length === 0) return '';
const active = activeTab || tabs[0]?.id || '';
const base = basePath;
const pills = tabs.map(t => {
const isActive = t.id === active;
return `<a class="rapp-nav-pill${isActive ? ' rapp-nav-pill--active' : ''}" href="${base}/${escapeAttr(t.id)}" data-tab-id="${escapeAttr(t.id)}">${t.icon ? escapeHtml(t.icon) + ' ' : ''}${escapeHtml(t.label)}</a>`;
}).join('');
return `<nav class="rapp-tabbar" id="rapp-tabbar">${pills}</nav>
<script>(function(){
// Dispatch initial tab on load so components can read it
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: '${escapeAttr(active)}' } }));
})()</script>`;
}
// ── Module landing page (bare-domain rspace.online/{moduleId}) ──
export interface ModuleLandingOptions {
/** The module to render a landing page for */
module: ModuleInfo;
/** All available modules (for app switcher) */
modules: ModuleInfo[];
/** Theme */
theme?: "dark" | "light";
/** Rich body HTML from a module's landing.ts (replaces generic hero) */
bodyHTML?: string;
}
export function renderModuleLanding(opts: ModuleLandingOptions): string {
const { module: mod, modules, theme = "dark", bodyHTML } = opts;
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `https://demo.rspace.online/${mod.id}`;
const cssBlock = bodyHTML
? `<style>${MODULE_LANDING_CSS}</style>\n <style>${RICH_LANDING_CSS}</style>`
: `<style>${MODULE_LANDING_CSS}</style>`;
const bodyContent = bodyHTML
? bodyHTML
: `<div class="ml-hero">
<div class="ml-container">
<span class="ml-icon">${mod.icon}</span>
<h1 class="ml-name">${escapeHtml(mod.name)}</h1>
<p class="ml-desc">${escapeHtml(mod.description)}</p>
<div class="ml-ctas">
<a href="${demoUrl}" class="ml-cta-primary" id="ml-primary">Try Demo</a>
<a href="/create-space" class="ml-cta-secondary">Create a Space</a>
</div>
</div>
</div>
<div class="ml-back">
<a href="/">← Back to rSpace</a>
</div>`;
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/favicon.png">
${faviconScript(mod.id)}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(mod.name)} — rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
${cssBlock}
<script defer src="/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(mod.id)}"></rstack-app-switcher>
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
<a class="rstack-header__demo-btn" href="${demoUrl}">Try Demo</a>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>
</header>
${bodyContent}
<rstack-collab-overlay module-id="${escapeAttr(mod.id)}" mode="badge-only"></rstack-collab-overlay>
<script type="module">
import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn');
if (!btn) return;
try {
var raw = localStorage.getItem('encryptid_session');
if (raw && JSON.parse(raw)?.accessToken) { btn.setAttribute('data-hide', ''); }
else { btn.removeAttribute('data-hide'); }
} catch(e) {}
}
_updateDemoBtn();
document.addEventListener('auth-change', _updateDemoBtn);
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session?.claims?.username) {
var username = session.claims.username.toLowerCase();
var primary = document.getElementById('ml-primary');
if (primary) {
primary.textContent = 'Go to My Space';
primary.href = 'https://' + username + '.rspace.online/${escapeAttr(mod.id)}';
}
}
}
} catch(e) {}
</script>
</body>
</html>`);
}
export const MODULE_LANDING_CSS = `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--rs-bg-page); color: var(--rs-text-primary);
min-height: 100vh;
display: flex; flex-direction: column; align-items: center;
padding-top: 56px;
}
.ml-hero {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: calc(80vh - 56px); width: 100%;
}
.ml-container { text-align: center; max-width: 560px; padding: 40px 20px; }
.ml-icon { font-size: 4rem; display: block; margin-bottom: 1rem; }
.ml-name {
font-size: 2.5rem; margin-bottom: 0.75rem;
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.ml-desc { font-size: 1.15rem; color: var(--rs-text-secondary); margin-bottom: 2.5rem; line-height: 1.6; }
.ml-ctas { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.ml-cta-primary {
display: inline-block; padding: 14px 32px; border-radius: 8px;
background: var(--rs-gradient-cta);
color: white; font-size: 1rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, box-shadow 0.2s;
}
.ml-cta-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(20,184,166,0.3); }
.ml-cta-secondary {
display: inline-block; padding: 14px 32px; border-radius: 8px;
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary); font-size: 1rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, border-color 0.2s, color 0.2s;
}
.ml-cta-secondary:hover { transform: translateY(-2px); border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.ml-back { padding: 2rem 0 3rem; text-align: center; }
.ml-back a { font-size: 0.85rem; color: var(--rs-text-muted); text-decoration: none; transition: color 0.2s; }
.ml-back a:hover { color: var(--rs-text-primary); }
@media (max-width: 600px) { .ml-name { font-size: 2rem; } .ml-icon { font-size: 3rem; } }
`;
export const RICH_LANDING_CSS = `
/* ── Rich Landing Page Utilities ── */
.rl-section {
border-top: 1px solid var(--rs-border-subtle);
padding: 4rem 1.5rem;
}
.rl-section--alt { background: var(--rs-bg-hover); }
.rl-container { max-width: 1100px; margin: 0 auto; }
.rl-hero {
text-align: center; padding: 5rem 1.5rem 3rem;
max-width: 820px; margin: 0 auto;
}
.rl-tagline {
display: inline-block; font-size: 0.7rem; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--rs-accent); background: rgba(20,184,166,0.1);
border: 1px solid rgba(20,184,166,0.2);
padding: 0.35rem 1rem; border-radius: 9999px; margin-bottom: 1.5rem;
}
.rl-heading {
font-size: 2rem; font-weight: 700; line-height: 1.15;
margin-bottom: 0.75rem; letter-spacing: -0.01em;
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.rl-hero .rl-heading { font-size: 2.5rem; }
@media (min-width: 640px) { .rl-hero .rl-heading { font-size: 3rem; } }
.rl-subtitle {
font-size: 1.25rem; font-weight: 500; color: var(--rs-text-primary);
margin-bottom: 1rem; letter-spacing: -0.005em;
}
.rl-hero .rl-subtitle { font-size: 1.35rem; }
@media (min-width: 640px) { .rl-hero .rl-subtitle { font-size: 1.5rem; } }
.rl-subtext {
font-size: 1.05rem; color: var(--rs-text-secondary); line-height: 1.65;
max-width: 640px; margin: 0 auto 2rem;
}
.rl-hero .rl-subtext { font-size: 1.15rem; }
/* Grids */
.rl-grid-2 { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
.rl-grid-3 { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
.rl-grid-4 { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
@media (min-width: 640px) {
.rl-grid-2 { grid-template-columns: repeat(2, 1fr); }
.rl-grid-3 { grid-template-columns: repeat(3, 1fr); }
.rl-grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.rl-grid-4 { grid-template-columns: repeat(4, 1fr); }
}
/* Card */
.rl-card {
background: var(--rs-card-bg); border: 1px solid var(--rs-card-border);
border-radius: 1rem; padding: 1.75rem;
transition: border-color 0.2s;
}
.rl-card:hover { border-color: rgba(20,184,166,0.3); }
.rl-card h3 { font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 0.5rem; }
.rl-card p { font-size: 0.875rem; color: var(--rs-text-secondary); line-height: 1.6; }
.rl-card--center { text-align: center; }
/* Step circles */
.rl-step {
display: flex; flex-direction: column; align-items: center; text-align: center;
}
.rl-step__num {
width: 2.5rem; height: 2.5rem; border-radius: 9999px;
background: rgba(20,184,166,0.1); color: var(--rs-accent);
display: flex; align-items: center; justify-content: center;
font-size: 0.8rem; font-weight: 700; margin-bottom: 0.75rem;
}
.rl-step h3 { font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 0.25rem; }
.rl-step p { font-size: 0.82rem; color: var(--rs-text-secondary); line-height: 1.55; }
/* CTA row */
.rl-cta-row {
display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;
margin-top: 2rem;
}
.rl-cta-primary {
display: inline-block; padding: 0.8rem 2rem; border-radius: 0.5rem;
background: var(--rs-gradient-cta);
color: white; font-size: 0.95rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, box-shadow 0.2s;
}
.rl-cta-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(20,184,166,0.3); }
.rl-cta-secondary {
display: inline-block; padding: 0.8rem 2rem; border-radius: 0.5rem;
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary); font-size: 0.95rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, border-color 0.2s, color 0.2s;
}
.rl-cta-secondary:hover { transform: translateY(-2px); border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
/* Check list */
.rl-check-list { list-style: none; padding: 0; margin: 0; }
.rl-check-list li {
display: flex; align-items: flex-start; gap: 0.5rem;
font-size: 0.875rem; color: var(--rs-text-secondary); line-height: 1.55;
padding: 0.35rem 0;
}
.rl-check-list li::before {
content: "✓"; color: var(--rs-accent); font-weight: 700; flex-shrink: 0; margin-top: 0.05em;
}
.rl-check-list li strong { color: var(--rs-text-primary); font-weight: 600; }
/* Badge */
.rl-badge {
display: inline-block; font-size: 0.65rem; font-weight: 700;
color: white; background: var(--rs-accent);
padding: 0.15rem 0.5rem; border-radius: 9999px;
}
/* Divider */
.rl-divider {
display: flex; align-items: center; gap: 0.75rem; margin: 1.5rem 0;
}
.rl-divider::before, .rl-divider::after {
content: ""; flex: 1; height: 1px; background: var(--rs-border-subtle);
}
.rl-divider span { font-size: 0.75rem; color: var(--rs-text-muted); white-space: nowrap; }
/* Icon box */
.rl-icon-box {
width: 3rem; height: 3rem; border-radius: 0.75rem;
background: rgba(20,184,166,0.12); color: var(--rs-accent);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; margin-bottom: 1rem;
}
.rl-card--center .rl-icon-box { margin: 0 auto 1rem; }
/* Integration (2-col with icon) */
.rl-integration {
display: flex; align-items: flex-start; gap: 1rem;
background: rgba(20,184,166,0.04); border: 1px solid rgba(20,184,166,0.15);
border-radius: 1rem; padding: 1.5rem;
}
.rl-integration h3 { font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 0.35rem; }
.rl-integration p { font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.55; }
/* Back link */
.rl-back { padding: 2rem 0 3rem; text-align: center; }
.rl-back a { font-size: 0.85rem; color: var(--rs-text-muted); text-decoration: none; transition: color 0.2s; }
.rl-back a:hover { color: var(--rs-text-primary); }
/* Progress bar */
.rl-progress { height: 0.5rem; border-radius: 9999px; background: var(--rs-border-subtle); overflow: hidden; }
.rl-progress__fill { height: 100%; border-radius: 9999px; background: var(--rs-accent); }
/* Tier row */
.rl-tier {
display: flex; gap: 0.5rem; margin: 1rem 0;
}
.rl-tier__item {
flex: 1; text-align: center; border-radius: 0.5rem;
border: 1px solid var(--rs-border-subtle); padding: 0.5rem; font-size: 0.75rem;
}
.rl-tier__item--active {
border-color: rgba(20,184,166,0.4); background: rgba(20,184,166,0.05); color: var(--rs-accent);
}
.rl-tier__item--active strong { color: var(--rs-accent); }
/* Temporal zoom bar */
.rl-zoom-bar { display: flex; flex-direction: column; gap: 0.5rem; }
.rl-zoom-bar__row { display: flex; align-items: center; gap: 0.75rem; }
.rl-zoom-bar__label { font-size: 0.7rem; color: var(--rs-text-muted); width: 1.2rem; text-align: right; font-family: monospace; }
.rl-zoom-bar__bar {
height: 1.5rem; border-radius: 0.375rem; background: rgba(99,102,241,0.15);
display: flex; align-items: center; padding: 0 0.75rem;
}
.rl-zoom-bar__name { font-size: 0.75rem; font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; }
.rl-zoom-bar__span { font-size: 0.6rem; color: var(--rs-text-muted); margin-left: auto; white-space: nowrap; }
/* Responsive helpers */
@media (max-width: 600px) {
.rl-hero { padding: 3rem 1rem 2rem; }
.rl-hero .rl-heading { font-size: 2rem; }
.rl-section { padding: 2.5rem 1rem; }
}
`;
// ── Sub-page info page (bare-domain rspace.online/{moduleId}/{subPage}) ──
export interface SubPageInfoOptions {
/** The sub-page info to render */
subPage: SubPageInfo;
/** The parent module info */
module: ModuleInfo;
/** All available modules (for app switcher) */
modules: ModuleInfo[];
}
export function renderSubPageInfo(opts: SubPageInfoOptions): string {
const { subPage, module: mod, modules } = opts;
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `https://demo.rspace.online/${mod.id}/${subPage.path}`;
const featuresGrid = subPage.features?.length
? `<div class="rl-section">
<div class="rl-container">
<div class="rl-grid-3">
${subPage.features.map(f => `<div class="rl-card rl-card--center">
<div class="rl-icon-box">${f.icon}</div>
<h3>${escapeHtml(f.title)}</h3>
<p>${escapeHtml(f.text)}</p>
</div>`).join("\n ")}
</div>
</div>
</div>`
: "";
const bodyContent = subPage.bodyHTML
? subPage.bodyHTML()
: `<div class="rl-hero">
<span class="rl-tagline">${escapeHtml(subPage.tagline)}</span>
<h1 class="rl-heading">${escapeHtml(subPage.title)}</h1>
<p class="rl-subtext">${escapeHtml(subPage.description)}</p>
<div class="rl-cta-row">
<a href="${demoUrl}" class="rl-cta-primary" id="sp-primary">Try Demo</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
${featuresGrid}
<div class="rl-back">
<a href="/${escapeAttr(mod.id)}">← Back to ${escapeHtml(mod.name)}</a>
</div>`;
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/favicon.png">
${faviconScript(mod.id)}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(subPage.title)}${escapeHtml(mod.name)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style>
<style>${RICH_LANDING_CSS}</style>
<script defer src="/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(mod.id)}"></rstack-app-switcher>
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
<a class="rstack-header__demo-btn" href="${demoUrl}">Try Demo</a>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>
</header>
${bodyContent}
<rstack-collab-overlay module-id="${escapeAttr(mod.id)}" mode="badge-only"></rstack-collab-overlay>
<script type="module">
import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn');
if (!btn) return;
try {
var raw = localStorage.getItem('encryptid_session');
if (raw && JSON.parse(raw)?.accessToken) { btn.setAttribute('data-hide', ''); }
else { btn.removeAttribute('data-hide'); }
} catch(e) {}
}
_updateDemoBtn();
document.addEventListener('auth-change', _updateDemoBtn);
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session?.claims?.username) {
var username = session.claims.username.toLowerCase();
var primary = document.getElementById('sp-primary');
if (primary) {
primary.textContent = 'Open in My Space';
primary.href = 'https://' + username + '.rspace.online/${escapeAttr(mod.id)}/${escapeAttr(subPage.path)}';
}
}
}
} catch(e) {}
</script>
</body>
</html>`);
}
// ── Onboarding page (empty rApp state) ──
export interface OnboardingOptions {
moduleId: string;
moduleName: string;
moduleIcon: string;
moduleDescription: string;
spaceSlug: string;
modules: ModuleInfo[];
/** Pre-rendered landing page HTML from the module (feature cards, etc.) */
landingHTML?: string;
/** Module-specific onboarding CTAs (import, connect, create) */
onboardingActions?: OnboardingAction[];
}
export function renderOnboarding(opts: OnboardingOptions): string {
const { moduleId, moduleName, moduleIcon, moduleDescription, spaceSlug, modules, landingHTML, onboardingActions } = opts;
const demoUrl = `/${moduleId}/demo`;
const templateUrl = `/${moduleId}/template`;
const featuresBlock = landingHTML
? `<div class="onboarding__features">${landingHTML}</div>`
: '';
// Render action cards if module declares them
let actionsBlock = '';
if (onboardingActions?.length) {
const cards = onboardingActions.map((a) => {
const resolvedHref = a.href?.replace(/\{space\}/g, spaceSlug) ?? '#';
const resolvedEndpoint = a.upload?.endpoint?.replace(/\{space\}/g, spaceSlug) ?? '';
if (a.type === 'upload') {
const inputId = `upload-${Math.random().toString(36).slice(2, 8)}`;
return `<button class="onboarding__action" data-upload-endpoint="${escapeAttr(resolvedEndpoint)}" data-upload-input="${inputId}" type="button">
<span class="onboarding__action-icon">${a.icon}</span>
<span class="onboarding__action-label">${escapeHtml(a.label)}</span>
<span class="onboarding__action-desc">${escapeHtml(a.description)}</span>
<input type="file" id="${inputId}" accept="${escapeAttr(a.upload!.accept)}" hidden>
</button>`;
}
return `<a href="${escapeAttr(resolvedHref)}" class="onboarding__action">
<span class="onboarding__action-icon">${a.icon}</span>
<span class="onboarding__action-label">${escapeHtml(a.label)}</span>
<span class="onboarding__action-desc">${escapeHtml(a.description)}</span>
</a>`;
}).join('\n');
actionsBlock = `
<div class="onboarding__divider"><span>or connect your data</span></div>
<div class="onboarding__actions">${cards}</div>`;
}
const uploadScript = onboardingActions?.some(a => a.type === 'upload') ? `
<script>
(function(){
document.querySelectorAll('[data-upload-endpoint]').forEach(function(card){
var inputId = card.getAttribute('data-upload-input');
var endpoint = card.getAttribute('data-upload-endpoint');
var input = document.getElementById(inputId);
if(!input) return;
card.addEventListener('click', function(){ input.click(); });
input.addEventListener('change', function(){
if(!input.files||!input.files.length) return;
var fd = new FormData();
fd.append('file', input.files[0]);
var headers = {};
try {
var raw = localStorage.getItem('encryptid_session');
if(raw){ var s=JSON.parse(raw); if(s&&s.accessToken) headers['Authorization']='Bearer '+s.accessToken; }
}catch(e){}
card.style.opacity='0.5'; card.style.pointerEvents='none';
fetch(endpoint, { method:'POST', body:fd, headers:headers })
.then(function(r){ if(!r.ok) throw new Error('Upload failed: '+r.status); return r.json(); })
.then(function(){ location.reload(); })
.catch(function(err){ alert(err.message); card.style.opacity='1'; card.style.pointerEvents=''; });
});
});
})();
</script>` : '';
const body = `
<div class="onboarding">
<div class="onboarding__card">
<div class="onboarding__glow"></div>
<span class="onboarding__icon">${moduleIcon}</span>
<h1 class="onboarding__title">${escapeHtml(moduleName)}</h1>
<p class="onboarding__desc">${escapeHtml(moduleDescription)}</p>
<p class="onboarding__hint">This app hasn't been used in <strong>${escapeHtml(spaceSlug)}</strong> yet. Load sample data to explore, or jump into the public demo.</p>
<div class="onboarding__ctas">
<a href="${escapeAttr(templateUrl)}" class="onboarding__btn onboarding__btn--primary">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M8 3v10M3 8h10"/></svg>
Load Sample Data
</a>
<a href="${escapeAttr(demoUrl)}" class="onboarding__btn onboarding__btn--secondary">View with Demo Data</a>
</div>
${actionsBlock}
</div>
${featuresBlock}
</div>
${uploadScript}`;
return renderShell({
title: `${moduleName}${spaceSlug} | rSpace`,
moduleId,
spaceSlug,
modules,
body,
styles: `<style>${ONBOARDING_CSS}</style>${landingHTML ? `<style>${RICH_LANDING_CSS}</style>` : ''}`,
});
}
const ONBOARDING_CSS = `
.onboarding {
display: flex; flex-direction: column; align-items: center;
min-height: calc(80vh - 56px); padding: 3rem 1.5rem 2rem;
}
.onboarding__card {
position: relative; text-align: center; max-width: 520px; width: 100%;
padding: 2.5rem 2rem; margin-bottom: 2rem;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
}
.onboarding__glow {
position: absolute; top: -60%; left: 50%; transform: translateX(-50%);
width: 300px; height: 300px; border-radius: 50%;
background: radial-gradient(circle, rgba(20,184,166,0.08) 0%, transparent 70%);
pointer-events: none;
}
.onboarding__icon { position: relative; font-size: 3.5rem; display: block; margin-bottom: 1rem; }
.onboarding__title {
position: relative;
font-size: 2rem; margin: 0 0 0.75rem; font-weight: 700;
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.onboarding__desc {
position: relative;
font-size: 1.05rem; color: var(--rs-text-secondary);
line-height: 1.6; margin: 0 0 1rem;
}
.onboarding__hint {
position: relative;
font-size: 0.8125rem; color: var(--rs-text-muted);
line-height: 1.5; margin: 0 0 1.75rem;
}
.onboarding__hint strong { color: var(--rs-text-secondary); font-weight: 600; }
.onboarding__ctas {
position: relative;
display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;
}
.onboarding__btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.75rem 1.75rem; border-radius: 0.5rem;
font-size: 0.95rem; font-weight: 600; text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.onboarding__btn:hover { transform: translateY(-2px); }
.onboarding__btn--primary {
background: var(--rs-gradient-cta); color: white;
box-shadow: 0 2px 8px rgba(20,184,166,0.3);
}
.onboarding__btn--primary:hover { box-shadow: 0 8px 20px rgba(20,184,166,0.3); }
.onboarding__btn--secondary {
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary);
}
.onboarding__btn--secondary:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.onboarding__features {
width: 100%; max-width: 1100px;
border-top: 1px solid var(--rs-border-subtle);
padding-top: 2rem; margin-top: 1rem;
}
.onboarding__divider {
position: relative; display: flex; align-items: center; gap: 1rem;
margin: 2rem 0 1.25rem; color: var(--rs-text-muted); font-size: 0.8125rem;
}
.onboarding__divider::before, .onboarding__divider::after {
content: ''; flex: 1; height: 1px; background: var(--rs-border);
}
.onboarding__actions {
position: relative;
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem; width: 100%;
}
.onboarding__action {
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
padding: 1.25rem 1rem; border-radius: 12px;
border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
text-decoration: none; color: var(--rs-text-primary); cursor: pointer;
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
font-family: inherit; font-size: inherit; text-align: center;
}
.onboarding__action:hover {
transform: translateY(-2px); border-color: var(--rs-border-strong);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.onboarding__action-icon { font-size: 1.75rem; margin-bottom: 0.25rem; }
.onboarding__action-label { font-size: 0.875rem; font-weight: 600; }
.onboarding__action-desc { font-size: 0.75rem; color: var(--rs-text-muted); line-height: 1.4; }
@media (max-width: 600px) {
.onboarding { padding: 2rem 1rem 1.5rem; }
.onboarding__title { font-size: 1.6rem; }
.onboarding__icon { font-size: 2.5rem; }
.onboarding__actions { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
}
`;
// ── Demo page CSS utilities (rd-* prefix, parallel to rl-* landing pages) ──
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}