diff --git a/backlog/tasks/task-high.1 - Bare-domain-module-routing-—-rspace.online-moduleId-as-default.md b/backlog/tasks/task-high.1 - Bare-domain-module-routing-—-rspace.online-moduleId-as-default.md index 00070ff..a216ef8 100644 --- a/backlog/tasks/task-high.1 - Bare-domain-module-routing-—-rspace.online-moduleId-as-default.md +++ b/backlog/tasks/task-high.1 - Bare-domain-module-routing-—-rspace.online-moduleId-as-default.md @@ -4,7 +4,7 @@ title: 'Bare-domain module routing — rspace.online/{moduleId} as default' status: Done assignee: [] created_date: '2026-02-26 03:34' -updated_date: '2026-02-26 03:34' +updated_date: '2026-02-26 06:20' labels: [] dependencies: [] parent_task_id: TASK-HIGH @@ -29,4 +29,6 @@ App dropdown links go to rspace.online/r* (bare domain) instead of demo.rspace.o Implemented in commit a732478. Changed url-helpers.ts (isBareDomain, rspaceNavUrl), server/index.ts (fetch handler rewrite), server/shell.ts (data-hide + client JS for Try Demo). Merged to main. + +Follow-up fix (c15fc15): app switcher fallback was 'personal' → 'demo', landing page demo links & ecosystem app links updated to use rspace.online/r* bare domain pattern diff --git a/server/index.ts b/server/index.ts index ef3f556..7054ddf 100644 --- a/server/index.ts +++ b/server/index.ts @@ -64,7 +64,7 @@ import { dataModule } from "../modules/data/mod"; import { splatModule } from "../modules/splat/mod"; import { photosModule } from "../modules/photos/mod"; import { spaces } from "./spaces"; -import { renderShell } from "./shell"; +import { renderShell, renderModuleLanding } from "./shell"; import { syncServer } from "./sync-instance"; import { loadAllDocs } from "./local-first/doc-persistence"; @@ -779,16 +779,29 @@ const server = Bun.serve({ return app.fetch(rewrittenReq); } - // ── Bare-domain module routes: rspace.online/{moduleId} → internal rewrite ── - // When on the bare domain (no subdomain), if the first path segment is a - // known module ID, rewrite internally to /demo/{moduleId}/... so Hono's - // /:space/:moduleId routes handle it. The browser URL stays as-is. + // ── Bare-domain module routes: rspace.online/{moduleId} ── + // Exact module path → serve module landing page. + // Sub-paths (API, assets) → rewrite to /demo/{moduleId}/... for backward compat. if (!subdomain && hostClean.includes("rspace.online")) { const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length >= 1) { const firstSegment = pathSegments[0]; const knownModuleIds = new Set(getAllModules().map((m) => m.id)); if (knownModuleIds.has(firstSegment)) { + // Exact module path → landing page + if (pathSegments.length === 1) { + const modInfo = getModuleInfoList().find((m) => m.id === firstSegment); + if (modInfo) { + return new Response( + renderModuleLanding({ + module: modInfo, + modules: getModuleInfoList(), + }), + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ); + } + } + // Sub-paths → rewrite to demo space const rewrittenPath = `/demo${url.pathname}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); diff --git a/server/shell.ts b/server/shell.ts index f28671c..d4f2318 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -410,6 +410,208 @@ const WELCOME_CSS = ` `; +// ── 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"; +} + +export function renderModuleLanding(opts: ModuleLandingOptions): string { + const { module: mod, modules, theme = "dark" } = opts; + const moduleListJSON = JSON.stringify(modules); + const demoUrl = `https://demo.rspace.online/${mod.id}`; + + const standaloneLinkHtml = mod.standaloneDomain + ? `Also available at ${escapeHtml(mod.standaloneDomain)} ↗` + : ""; + + let feedsHtml = ""; + if (mod.feeds && mod.feeds.length > 0) { + feedsHtml = ` +
+

Capabilities

+
+ ${mod.feeds + .map( + (f) => ` +
+
${escapeHtml(f.name)}
+
${escapeHtml(f.description)}
+ ${escapeHtml(f.kind)} +
`, + ) + .join("")} +
+
`; + } + + return ` + + + + + + ${escapeHtml(mod.name)} — rSpace + + + + + +
+
+ +
+
+
+ Try Demo + +
+
+ +
+
+ ${mod.icon} +

${escapeHtml(mod.name)}

+

${escapeHtml(mod.description)}

+ + ${standaloneLinkHtml} +
+
+ + ${feedsHtml} + +
+ ← Back to rSpace +
+ + + +`; +} + +const MODULE_LANDING_CSS = ` +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + color: white; 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: linear-gradient(135deg, #14b8a6, #22d3ee); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.ml-desc { + font-size: 1.15rem; color: #94a3b8; 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: linear-gradient(135deg, #14b8a6, #0d9488); + 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: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.2); + color: #94a3b8; 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: rgba(255,255,255,0.4); color: white; +} +.ml-standalone { + display: inline-block; margin-top: 1.5rem; + font-size: 0.85rem; color: #64748b; text-decoration: none; + transition: color 0.2s; +} +.ml-standalone:hover { color: #22d3ee; } +.ml-feeds { + width: 100%; max-width: 640px; padding: 0 20px 3rem; + margin: 0 auto; +} +.ml-feeds-title { + font-size: 1rem; font-weight: 600; color: #94a3b8; + text-transform: uppercase; letter-spacing: 0.05em; + margin-bottom: 1rem; text-align: center; +} +.ml-feeds-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.75rem; +} +.ml-feed-card { + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); + border-radius: 10px; padding: 1rem; +} +.ml-feed-name { + font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; color: #e2e8f0; +} +.ml-feed-desc { + font-size: 0.8rem; color: #94a3b8; line-height: 1.5; margin-bottom: 0.5rem; +} +.ml-feed-kind { + display: inline-block; font-size: 0.65rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + padding: 2px 8px; border-radius: 4px; + background: rgba(20,184,166,0.15); color: #14b8a6; +} +.ml-back { + padding: 2rem 0 3rem; text-align: center; +} +.ml-back a { + font-size: 0.85rem; color: #64748b; text-decoration: none; transition: color 0.2s; +} +.ml-back a:hover { color: #e2e8f0; } +@media (max-width: 600px) { + .ml-name { font-size: 2rem; } + .ml-icon { font-size: 3rem; } + .ml-feeds-grid { grid-template-columns: 1fr; } +} +`; + function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } diff --git a/website/index.html b/website/index.html index 30adefd..b0c2628 100644 --- a/website/index.html +++ b/website/index.html @@ -369,7 +369,7 @@
- Try Demo + Try Demo
@@ -380,7 +380,7 @@
Create a Space - Try the Demo + Try the Demo