feat: embed external apps via iframe in rSpace shell
Add ?view=app iframe integration for 4 existing modules (rNetwork→Twenty CRM, rSocials→Postiz, rForum→Discourse, rFiles→Seafile) and 2 new modules (rDocs→Docmost, rDesign→Affine). Each module shows its demo view by default with an "Open Full App" button to switch to the iframe-embedded external app. Also includes: splat demo data seeding, MI search bar mobile layout fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e55e56bc06
commit
db078d3152
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Design module — collaborative design workspace via Affine.
|
||||
*
|
||||
* Wraps the Affine instance as an external app embedded in the rSpace shell.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const AFFINE_URL = "https://affine.cosmolocal.world";
|
||||
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "rdesign" });
|
||||
});
|
||||
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "demo") {
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Design | rSpace`,
|
||||
moduleId: "rdesign",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">🎯</div>
|
||||
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban — all in one tool for your community.</p>
|
||||
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Affine</a>
|
||||
</div>`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Default: show the external app directly
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Affine | rSpace`,
|
||||
moduleId: "rdesign",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: AFFINE_URL,
|
||||
appName: "Affine",
|
||||
theme: "dark",
|
||||
}));
|
||||
});
|
||||
|
||||
export const designModule: RSpaceModule = {
|
||||
id: "rdesign",
|
||||
name: "rDesign",
|
||||
icon: "🎯",
|
||||
description: "Collaborative design workspace with whiteboard and docs",
|
||||
routes,
|
||||
standaloneDomain: "rdesign.online",
|
||||
externalApp: { url: AFFINE_URL, name: "Affine" },
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Docs module — collaborative documentation via Docmost.
|
||||
*
|
||||
* Wraps the Docmost instance as an external app embedded in the rSpace shell.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const DOCMOST_URL = "https://docs.cosmolocal.world";
|
||||
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "rdocs" });
|
||||
});
|
||||
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "demo") {
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Docs | rSpace`,
|
||||
moduleId: "rdocs",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">📝</div>
|
||||
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDocs</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.</p>
|
||||
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Docmost</a>
|
||||
</div>`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Default: show the external app directly
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Docmost | rSpace`,
|
||||
moduleId: "rdocs",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: DOCMOST_URL,
|
||||
appName: "Docmost",
|
||||
theme: "dark",
|
||||
}));
|
||||
});
|
||||
|
||||
export const docsModule: RSpaceModule = {
|
||||
id: "rdocs",
|
||||
name: "rDocs",
|
||||
icon: "📝",
|
||||
description: "Collaborative documentation and knowledge base",
|
||||
routes,
|
||||
standaloneDomain: "rdocs.online",
|
||||
externalApp: { url: DOCMOST_URL, name: "Docmost" },
|
||||
};
|
||||
|
|
@ -9,7 +9,7 @@ import { resolve } from "node:path";
|
|||
import { mkdir, writeFile, unlink } from "node:fs/promises";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
|
@ -367,13 +367,28 @@ routes.delete("/api/cards/:id", async (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${spaceSlug} — Seafile | rSpace`,
|
||||
moduleId: "rfiles",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://files.rfiles.online",
|
||||
appName: "Seafile",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Files | rSpace`,
|
||||
moduleId: "rfiles",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
|
||||
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
|
||||
}));
|
||||
|
|
@ -387,6 +402,7 @@ export const filesModule: RSpaceModule = {
|
|||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rfiles.online",
|
||||
externalApp: { url: "https://files.rfiles.online", name: "Seafile" },
|
||||
feeds: [
|
||||
{
|
||||
id: "file-activity",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Hono } from "hono";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { provisionInstance, destroyInstance } from "./lib/provisioner";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
|
@ -158,13 +158,28 @@ routes.get("/api/health", (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${spaceSlug} — Discourse | rSpace`,
|
||||
moduleId: "rforum",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://commons.rforum.online",
|
||||
appName: "Discourse",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Forum | rSpace`,
|
||||
moduleId: "rforum",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
|
||||
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
|
||||
}));
|
||||
|
|
@ -178,6 +193,7 @@ export const forumModule: RSpaceModule = {
|
|||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rforum.online",
|
||||
externalApp: { url: "https://commons.rforum.online", name: "Discourse" },
|
||||
feeds: [
|
||||
{
|
||||
id: "threads",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { renderLanding } from "./landing";
|
||||
|
|
@ -217,13 +217,28 @@ routes.get("/api/workspaces", (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Twenty CRM | rSpace`,
|
||||
moduleId: "rnetwork",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://demo.rnetwork.online",
|
||||
appName: "Twenty CRM",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Network | rSpace`,
|
||||
moduleId: "rnetwork",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
|
||||
}));
|
||||
|
|
@ -237,6 +252,7 @@ export const networkModule: RSpaceModule = {
|
|||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rnetwork.online",
|
||||
externalApp: { url: "https://demo.rnetwork.online", name: "Twenty CRM" },
|
||||
feeds: [
|
||||
{
|
||||
id: "trust-graph",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { renderLanding } from "./landing";
|
||||
|
|
@ -153,7 +153,10 @@ function renderDemoFeedHTML(): string {
|
|||
return `
|
||||
<div class="rsocials-app rsocials-demo">
|
||||
<div class="rsocials-header">
|
||||
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
|
||||
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
|
||||
<a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a>
|
||||
</div>
|
||||
<p class="rsocials-subtitle">A preview of your community's social timeline</p>
|
||||
</div>
|
||||
<div class="rsocials-feed">
|
||||
|
|
@ -166,6 +169,20 @@ function renderDemoFeedHTML(): string {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Postiz | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://social.jeffemmett.com",
|
||||
appName: "Postiz",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
const isDemo = space === "demo";
|
||||
|
||||
const body = isDemo
|
||||
|
|
@ -310,6 +327,7 @@ export const socialsModule: RSpaceModule = {
|
|||
routes,
|
||||
standaloneDomain: "rsocials.online",
|
||||
landingPage: renderLanding,
|
||||
externalApp: { url: "https://social.jeffemmett.com", name: "Postiz" },
|
||||
feeds: [
|
||||
{
|
||||
id: "social-feed",
|
||||
|
|
|
|||
|
|
@ -54,10 +54,22 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
if (this._mode === "viewer") {
|
||||
this.renderViewer();
|
||||
} else {
|
||||
if (this._spaceSlug === "demo") this.loadDemoData();
|
||||
this.renderGallery();
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this._splats = [
|
||||
{ id: "s1", slug: "matterhorn-scan", title: "Matterhorn Summit", description: "Photogrammetry capture of the Matterhorn peak from drone footage, 42 source images.", file_format: "splat", file_size_bytes: 18_874_368, view_count: 284, contributor_name: "Alpine Explorer Team", processing_status: "ready", created_at: "2026-02-10" },
|
||||
{ id: "s2", slug: "community-garden", title: "Community Garden Plot", description: "3D scan of the shared garden space — beds, paths, tool shed.", file_format: "ply", file_size_bytes: 24_117_248, view_count: 156, contributor_name: "Garden Collective", processing_status: "ready", created_at: "2026-02-15" },
|
||||
{ id: "s3", slug: "print-shop-interior", title: "Print Shop Interior", description: "Interior scan of Druckwerkstatt Berlin — letterpress, risograph, binding station.", file_format: "spz", file_size_bytes: 31_457_280, view_count: 93, contributor_name: "Druckwerkstatt", processing_status: "ready", created_at: "2026-02-18" },
|
||||
{ id: "s4", slug: "chamonix-trailhead", title: "Chamonix Trailhead", description: "360° capture of the Lac Blanc trailhead parking area and signage.", file_format: "splat", file_size_bytes: 12_582_912, view_count: 67, processing_status: "ready", created_at: "2026-02-20" },
|
||||
{ id: "s5", slug: "zermatt-bridge", title: "Zermatt Suspension Bridge", description: "Charles Kuonen bridge scan from 18 photos. Processing complete.", file_format: "ply", file_size_bytes: 0, view_count: 0, source_file_count: 18, processing_status: "processing", created_at: "2026-02-25" },
|
||||
{ id: "s6", slug: "mycorrhiza-sculpture", title: "Mycorrhiza Sculpture", description: "Uploaded for 3D reconstruction from video. Queued.", file_format: "splat", file_size_bytes: 0, view_count: 0, source_file_count: 1, processing_status: "pending", created_at: "2026-02-27" },
|
||||
];
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._viewer) {
|
||||
try { this._viewer.dispose(); } catch {}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ import { dataModule } from "../modules/data/mod";
|
|||
import { splatModule } from "../modules/splat/mod";
|
||||
import { photosModule } from "../modules/photos/mod";
|
||||
import { socialsModule } from "../modules/rsocials/mod";
|
||||
import { docsModule } from "../modules/docs/mod";
|
||||
import { designModule } from "../modules/design/mod";
|
||||
import { spaces } from "./spaces";
|
||||
import { renderShell, renderModuleLanding } from "./shell";
|
||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||
|
|
@ -94,6 +96,8 @@ registerModule(dataModule);
|
|||
registerModule(splatModule);
|
||||
registerModule(photosModule);
|
||||
registerModule(socialsModule);
|
||||
registerModule(docsModule);
|
||||
registerModule(designModule);
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
|
|
|||
128
server/shell.ts
128
server/shell.ts
|
|
@ -54,7 +54,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/shell.css?v=3">
|
||||
<link rel="stylesheet" href="/shell.css?v=4">
|
||||
<style>
|
||||
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
|
||||
hide the shell chrome — the parent rSpace page already provides it. */
|
||||
|
|
@ -94,7 +94,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
${renderWelcomeOverlay()}
|
||||
|
||||
<script type="module">
|
||||
import '/shell.js';
|
||||
import '/shell.js?v=4';
|
||||
// Provide module list to app switcher
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
|
||||
|
|
@ -312,6 +312,126 @@ export function renderShell(opts: ShellOptions): string {
|
|||
</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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/shell.css?v=4">
|
||||
<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 data-theme="${theme}">
|
||||
<header class="rstack-header" data-theme="${theme}">
|
||||
<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" data-theme="${theme}">
|
||||
<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="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>
|
||||
|
||||
<script type="module">
|
||||
import '/shell.js?v=4';
|
||||
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);
|
||||
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); });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ──
|
||||
|
||||
function renderWelcomeOverlay(): string {
|
||||
|
|
@ -461,7 +581,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${mod.icon}</text></svg>">
|
||||
<title>${escapeHtml(mod.name)} — rSpace</title>
|
||||
<link rel="stylesheet" href="/shell.css?v=3">
|
||||
<link rel="stylesheet" href="/shell.css?v=4">
|
||||
${cssBlock}
|
||||
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
|
||||
</head>
|
||||
|
|
@ -481,7 +601,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
|||
</header>
|
||||
${bodyContent}
|
||||
<script type="module">
|
||||
import '/shell.js';
|
||||
import '/shell.js?v=4';
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
try {
|
||||
var raw = localStorage.getItem('encryptid_session');
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ const STYLES = `
|
|||
:host-context([data-theme="light"]) .mi-tool-chip:hover { background: rgba(0,0,0,0.1); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mi { max-width: 200px; }
|
||||
.mi-panel { min-width: 300px; left: -60px; }
|
||||
.mi { max-width: none; width: 100%; }
|
||||
.mi-panel { min-width: 0; width: 100%; left: 0; right: 0; }
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ export interface RSpaceModule {
|
|||
hidden?: boolean;
|
||||
/** Optional: render rich landing page body HTML */
|
||||
landingPage?: () => string;
|
||||
/** Optional: external app to embed via iframe when ?view=app */
|
||||
externalApp?: {
|
||||
url: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Registry of all loaded modules */
|
||||
|
|
@ -63,6 +68,10 @@ export interface ModuleInfo {
|
|||
acceptsFeeds?: FlowKind[];
|
||||
hidden?: boolean;
|
||||
hasLandingPage?: boolean;
|
||||
externalApp?: {
|
||||
url: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function getModuleInfoList(): ModuleInfo[] {
|
||||
|
|
@ -77,5 +86,6 @@ export function getModuleInfoList(): ModuleInfo[] {
|
|||
...(m.feeds ? { feeds: m.feeds } : {}),
|
||||
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
|
||||
...(m.landingPage ? { hasLandingPage: true } : {}),
|
||||
...(m.externalApp ? { externalApp: m.externalApp } : {}),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,88 @@ body[data-theme="light"] {
|
|||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* ── Iframe embed for external apps ── */
|
||||
|
||||
.rspace-iframe-wrap {
|
||||
position: fixed;
|
||||
top: 92px; /* header 56px + tab row 36px */
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rspace-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.rspace-iframe-loading {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rspace-iframe-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: rspace-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rspace-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.rspace-iframe-newtab {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
z-index: 3;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.rspace-iframe-newtab:hover {
|
||||
color: #e2e8f0;
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* "Open Full App" button used in module demo views */
|
||||
.rapp-nav__btn--app-toggle {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
padding: 5px 14px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.rapp-nav__btn--app-toggle:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* ── Mobile adjustments ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
|
@ -243,17 +325,33 @@ body[data-theme="light"] {
|
|||
gap: 0;
|
||||
}
|
||||
.rstack-header__left {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
}
|
||||
.rstack-header__center {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
padding: 4px 0 2px;
|
||||
justify-content: stretch;
|
||||
display: flex;
|
||||
}
|
||||
.rstack-header__right {
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.rstack-header__demo-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.rstack-header__brand {
|
||||
font-size: 1rem;
|
||||
gap: 6px;
|
||||
}
|
||||
.rstack-header__logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.rstack-tab-row {
|
||||
position: sticky;
|
||||
|
|
@ -264,6 +362,13 @@ body[data-theme="light"] {
|
|||
#app.canvas-layout {
|
||||
padding-top: 0;
|
||||
}
|
||||
.rspace-iframe-wrap {
|
||||
position: relative;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: calc(100vh - 80px);
|
||||
min-height: 400px;
|
||||
}
|
||||
.rapp-nav {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue