From db078d315245e1cfc1ab924d0a02665069864b2a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 08:56:08 +0000 Subject: [PATCH] feat: embed external apps via iframe in rSpace shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/design/mod.ts | 60 ++++++++ modules/docs/mod.ts | 60 ++++++++ modules/files/mod.ts | 20 ++- modules/forum/mod.ts | 20 ++- modules/network/mod.ts | 20 ++- modules/rsocials/mod.ts | 22 ++- modules/splat/components/folk-splat-viewer.ts | 12 ++ server/index.ts | 4 + server/shell.ts | 128 +++++++++++++++++- shared/components/rstack-mi.ts | 4 +- shared/module.ts | 10 ++ website/public/shell.css | 111 ++++++++++++++- 12 files changed, 454 insertions(+), 17 deletions(-) create mode 100644 modules/design/mod.ts create mode 100644 modules/docs/mod.ts diff --git a/modules/design/mod.ts b/modules/design/mod.ts new file mode 100644 index 0000000..bbe7b8a --- /dev/null +++ b/modules/design/mod.ts @@ -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: `
+
🎯
+

rDesign

+

Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban — all in one tool for your community.

+ Open Affine +
`, + })); + } + + // 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" }, +}; diff --git a/modules/docs/mod.ts b/modules/docs/mod.ts new file mode 100644 index 0000000..10906ac --- /dev/null +++ b/modules/docs/mod.ts @@ -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: `
+
📝
+

rDocs

+

Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.

+ Open Docmost +
`, + })); + } + + // 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" }, +}; diff --git a/modules/files/mod.ts b/modules/files/mod.ts index c1f52fd..0dfe8e1 100644 --- a/modules/files/mod.ts +++ b/modules/files/mod.ts @@ -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: ``, + body: ` +`, scripts: ``, styles: ``, })); @@ -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", diff --git a/modules/forum/mod.ts b/modules/forum/mod.ts index 9c8f0e2..8e4653f 100644 --- a/modules/forum/mod.ts +++ b/modules/forum/mod.ts @@ -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: ``, + body: ` +`, scripts: ``, styles: ``, })); @@ -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", diff --git a/modules/network/mod.ts b/modules/network/mod.ts index 5c528d1..39be73d 100644 --- a/modules/network/mod.ts +++ b/modules/network/mod.ts @@ -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: ``, + body: ` +`, scripts: ``, styles: ``, })); @@ -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", diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 0583437..a806c6d 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -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 `
-

Social Feed DEMO

+
+

Social Feed DEMO

+ Open Full App +

A preview of your community's social timeline

@@ -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", diff --git a/modules/splat/components/folk-splat-viewer.ts b/modules/splat/components/folk-splat-viewer.ts index 1fcd518..260a6b2 100644 --- a/modules/splat/components/folk-splat-viewer.ts +++ b/modules/splat/components/folk-splat-viewer.ts @@ -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 {} diff --git a/server/index.ts b/server/index.ts index f7a2b90..c6e0f26 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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; diff --git a/server/shell.ts b/server/shell.ts index 3ce98fa..2d1a32d 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -54,7 +54,7 @@ export function renderShell(opts: ShellOptions): string { ${escapeHtml(title)} - + + + + +
+
+ + + +
+
+ +
+
+ Back to Demo + +
+
+
+ +
+
+
+
+ Loading ${escapeHtml(appName)}… +
+ + Open in new tab ↗ +
+ + + +`; +} + // ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ── function renderWelcomeOverlay(): string { @@ -461,7 +581,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { ${escapeHtml(mod.name)} — rSpace - + ${cssBlock} @@ -481,7 +601,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { ${bodyContent}