Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-04 12:16:29 -08:00
commit 58c4aaa4b9
11 changed files with 308 additions and 3 deletions

View File

@ -455,6 +455,20 @@ export const booksModule: RSpaceModule = {
{ path: "collections", name: "Collections", icon: "📑", description: "Curated book collections" },
],
subPageInfos: [
{
path: "read",
title: "Flipbook Reader",
icon: "📖",
tagline: "rBooks Tool",
description: "Read PDFs in a beautiful flipbook layout with page-turn animations. Bookmark pages, adjust display, and read collaboratively.",
features: [
{ icon: "📄", title: "Flipbook View", text: "Page-turning animations bring PDFs to life in a book-like reading experience." },
{ icon: "🔖", title: "Bookmarks & Notes", text: "Mark your place and add annotations that sync across devices." },
{ icon: "👥", title: "Shared Library", text: "Upload PDFs to your community library for everyone to discover and read." },
],
},
],
async onSpaceCreate(ctx: SpaceLifecycleContext) {
// Books are global, not space-scoped (for now). No-op.
},

View File

@ -355,4 +355,18 @@ export const flowsModule: RSpaceModule = {
{ path: "budgets", name: "Budgets", icon: "💰", description: "Budget allocations and funnels" },
{ path: "flows", name: "Flows", icon: "🌊", description: "Revenue and resource flow visualizations" },
],
subPageInfos: [
{
path: "flow",
title: "Flow Viewer",
icon: "🌊",
tagline: "rFlows Tool",
description: "Visualize a single budget flow — deposits, withdrawals, funnel allocations, and real-time balance. Drill into transactions and manage outcomes.",
features: [
{ icon: "📈", title: "River Visualization", text: "See funds flow through funnels and outcomes as an animated river diagram." },
{ icon: "💸", title: "Deposits & Withdrawals", text: "Track every transaction with full history and on-chain verification." },
{ icon: "🎯", title: "Outcome Tracking", text: "Define funding outcomes and monitor how capital reaches its destination." },
],
},
],
};

View File

@ -1081,4 +1081,18 @@ export const inboxModule: RSpaceModule = {
},
],
acceptsFeeds: ["data"],
subPageInfos: [
{
path: "about",
title: "About rInbox",
icon: "📮",
tagline: "rInbox",
description: "Collaborative email for communities — shared mailboxes with multisig approval, threaded discussions, and team workflows.",
features: [
{ icon: "📬", title: "Shared Mailboxes", text: "Create shared inboxes that multiple team members can read and respond from." },
{ icon: "✅", title: "Multisig Approval", text: "Require multiple approvals before sending sensitive emails on behalf of the group." },
{ icon: "💬", title: "Internal Comments", text: "Discuss emails privately with your team before crafting a response." },
],
},
],
};

View File

@ -343,4 +343,18 @@ export const networkModule: RSpaceModule = {
{ path: "connections", name: "Connections", icon: "🤝", description: "Community member connections" },
{ path: "groups", name: "Groups", icon: "👥", description: "Relationship groups and circles" },
],
subPageInfos: [
{
path: "crm",
title: "Community CRM",
icon: "📇",
tagline: "rNetwork Tool",
description: "Full-featured CRM for community relationship management — contacts, companies, deals, and pipelines powered by Twenty CRM.",
features: [
{ icon: "👤", title: "Contact Management", text: "Track people, organizations, and their roles in your community." },
{ icon: "🔗", title: "Relationship Graph", text: "Visualize how members connect and identify key connectors." },
{ icon: "📊", title: "Pipeline Tracking", text: "Manage opportunities and partnerships through customizable stages." },
],
},
],
};

View File

@ -161,4 +161,18 @@ export const photosModule: RSpaceModule = {
{ path: "albums", name: "Albums", icon: "📸", description: "Photo albums and collections" },
{ path: "galleries", name: "Galleries", icon: "🖼️", description: "Public photo galleries" },
],
subPageInfos: [
{
path: "album",
title: "Photo Album",
icon: "🖼️",
tagline: "rPhotos Tool",
description: "Browse a curated photo album with lightbox viewing, metadata display, and easy sharing. Powered by Immich.",
features: [
{ icon: "🔍", title: "Lightbox Viewer", text: "Full-screen photo viewing with EXIF data, zoom, and slideshow mode." },
{ icon: "📤", title: "Easy Sharing", text: "Share individual photos or entire albums with a single link." },
{ icon: "🏷️", title: "Smart Tags", text: "AI-powered face recognition and object tagging for easy discovery." },
],
},
],
};

View File

@ -894,6 +894,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
// ── Draft management ──
async function saveDraft() {
if (window.__rspaceSaveGate && !window.__rspaceSaveGate(saveDraft)) return;
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
if (!tweets.length) return;
@ -1807,4 +1808,37 @@ export const socialsModule: RSpaceModule = {
{ path: "campaigns", name: "Campaigns", icon: "📢", description: "Social media campaigns" },
{ path: "posts", name: "Posts", icon: "📱", description: "Social feed posts across platforms" },
],
subPageInfos: [
{
path: "thread",
title: "Thread Builder",
icon: "🧵",
tagline: "rSocials Tool",
description: "Compose, preview, and schedule tweet threads with a live card-by-card preview. Save drafts, generate share images, and publish when ready.",
features: [
{ icon: "✍️", title: "Live Preview", text: "See your thread as tweet cards in real time as you type, with character counts and thread numbering." },
{ icon: "💾", title: "Save & Edit Drafts", text: "Save thread drafts to your space, revisit and refine them before publishing." },
{ icon: "🖼️", title: "Share Images", text: "Auto-generate a branded share image of your thread for cross-posting." },
],
},
{
path: "campaign",
title: "Campaign Manager",
icon: "📢",
tagline: "rSocials Tool",
description: "Plan and track multi-platform social media campaigns with scheduling, analytics, and team collaboration.",
features: [
{ icon: "📅", title: "Schedule Posts", text: "Queue posts across platforms with a visual calendar timeline." },
{ icon: "📊", title: "Track Performance", text: "Monitor engagement metrics and campaign reach in one dashboard." },
{ icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." },
],
},
{
path: "threads",
title: "Thread Gallery",
icon: "📋",
tagline: "rSocials Tool",
description: "Browse all saved thread drafts in your community. Find inspiration, remix threads, or pick up where you left off.",
},
],
};

View File

@ -733,6 +733,20 @@ export const splatModule: RSpaceModule = {
outputPaths: [
{ path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" },
],
subPageInfos: [
{
path: "view",
title: "Splat Viewer",
icon: "🔮",
tagline: "rSplat Tool",
description: "Explore 3D Gaussian splat captures in an interactive WebGL viewer. Orbit, zoom, and inspect photorealistic 3D scenes.",
features: [
{ icon: "🖱️", title: "Interactive 3D", text: "Orbit, pan, and zoom through photorealistic 3D captures in your browser." },
{ icon: "📷", title: "Multi-Angle Capture", text: "View scenes reconstructed from hundreds of photos using Gaussian splatting." },
{ icon: "📤", title: "Upload & Share", text: "Upload .ply or .splat files and share interactive 3D views with anyone." },
],
},
],
async onInit(ctx) {
_syncServer = ctx.syncServer;
console.log("[Splat] Automerge document store ready");

View File

@ -554,4 +554,18 @@ export const tripsModule: RSpaceModule = {
outputPaths: [
{ path: "itineraries", name: "Itineraries", icon: "🗓️", description: "Trip itineraries with bookings and activities" },
],
subPageInfos: [
{
path: "routes",
title: "Route Planner",
icon: "🗺️",
tagline: "rTrips Tool",
description: "Plan multi-stop routes with distance and duration estimates. Optimize waypoints and export routes for navigation.",
features: [
{ icon: "📍", title: "Multi-Stop Planning", text: "Add waypoints, reorder stops, and calculate the optimal route." },
{ icon: "⏱️", title: "Time & Distance", text: "See real-time estimates for travel time and distances between stops." },
{ icon: "🗺️", title: "Map Visualization", text: "View your full route on an interactive map with turn-by-turn preview." },
],
},
],
};

View File

@ -71,7 +71,7 @@ import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderModuleLanding, renderOnboarding } from "./shell";
import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
@ -1911,7 +1911,29 @@ const server = Bun.serve<WSData>({
});
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
// rspace.online/{moduleId}/sub-path → rewrite to demo space internally
// rspace.online/{moduleId}/sub-path
const secondSegment = pathSegments[1]?.toLowerCase();
// 1. API routes always pass through to demo
if (secondSegment === "api") {
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
const rewrittenPath = `/demo${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
// 2. If sub-path matches a subPageInfo → render info page
const subPageInfo = mod.subPageInfos?.find(sp => sp.path === secondSegment);
if (subPageInfo) {
const html = renderSubPageInfo({
subPage: subPageInfo,
module: mod,
modules: getModuleInfoList(),
});
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
// 3. Fallback: rewrite to demo space (backward compat)
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
const rewrittenPath = `/demo${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);

View File

@ -5,7 +5,7 @@
* switchers + identity, <main> with module content, shell script + styles.
*/
import type { ModuleInfo } from "../shared/module";
import type { ModuleInfo, SubPageInfo } from "../shared/module";
import { getDocumentData } from "./community-store";
/** Extract enabledModules and encryption status from a loaded space. */
@ -201,6 +201,27 @@ export function renderShell(opts: ShellOptions): string {
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() {
@ -1043,6 +1064,114 @@ export const RICH_LANDING_CSS = `
}
`;
// ── 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 `<!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'>${subPage.icon}</text></svg>">
<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)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=8">
<style>${MODULE_LANDING_CSS}</style>
<style>${RICH_LANDING_CSS}</style>
<script defer src="https://rdata.online/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>
<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}
<script type="module">
import '/shell.js?v=8';
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 {

View File

@ -34,6 +34,24 @@ export interface DocSchema<T = unknown> {
init: () => T;
}
/** Info for a sub-page shown on bare-domain instead of the functional app. */
export interface SubPageInfo {
/** URL segment (e.g. "thread", "flow", "crm") */
path: string;
/** Display title (e.g. "Thread Builder") */
title: string;
/** Emoji icon */
icon: string;
/** Short tagline shown as a pill above the title */
tagline: string;
/** 1-2 sentence description */
description: string;
/** Optional feature cards for a grid section */
features?: Array<{ icon: string; title: string; text: string }>;
/** Optional: fully custom body HTML (replaces generic template) */
bodyHTML?: () => string;
}
/** A browsable content type that a module produces. */
export interface OutputPath {
/** URL segment: "notebooks" */
@ -100,6 +118,8 @@ export interface RSpaceModule {
outputPaths?: OutputPath[];
/** Optional: render rich landing page body HTML */
landingPage?: () => string;
/** Info pages for sub-paths on bare domain (replaces demo rewrite with marketing page) */
subPageInfos?: SubPageInfo[];
/** Optional: external app to embed via iframe when ?view=app */
externalApp?: {
url: string;
@ -146,6 +166,7 @@ export interface ModuleInfo {
name: string;
};
outputPaths?: OutputPath[];
subPageInfos?: Array<{ path: string; title: string }>;
}
export function getModuleInfoList(): ModuleInfo[] {
@ -163,5 +184,6 @@ export function getModuleInfoList(): ModuleInfo[] {
...(m.landingPage ? { hasLandingPage: true } : {}),
...(m.externalApp ? { externalApp: m.externalApp } : {}),
...(m.outputPaths ? { outputPaths: m.outputPaths } : {}),
...(m.subPageInfos ? { subPageInfos: m.subPageInfos.map(s => ({ path: s.path, title: s.title })) } : {}),
}));
}