feat: add outputPaths to module interface and browsable list pages

Add OutputPath type to RSpaceModule so each module declares what content
types it produces (e.g. notebooks, routes, campaigns). Auto-generate
browsable list pages at /:space/:moduleId/:path that render a card grid
inside the standard shell, fetching items from the module's API.

Declares outputPaths across 23 modules (rwallet/rinbox skipped).

Move campaign demo from standalone campaign-demo space to
/rsocials/campaign route with a dedicated timeline view and
/api/campaigns endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 13:55:29 -08:00
parent 4979c3d80c
commit 5408eb0376
25 changed files with 540 additions and 18 deletions

View File

@ -319,6 +319,10 @@ export const booksModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "resource"],
outputPaths: [
{ path: "books", name: "Books", icon: "📚", description: "Community PDF library" },
{ path: "collections", name: "Collections", icon: "📑", description: "Curated book collections" },
],
async onSpaceCreate(spaceSlug: string) {
// Books are global, not space-scoped (for now). No-op.

View File

@ -493,4 +493,8 @@ export const cartModule: RSpaceModule = {
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "products", name: "Products", icon: "🛍️", description: "Print-on-demand product catalog" },
{ path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" },
],
};

View File

@ -79,4 +79,8 @@ export const choicesModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "governance"],
outputPaths: [
{ path: "polls", name: "Polls", icon: "☑️", description: "Active and closed polls" },
{ path: "results", name: "Results", icon: "📊", description: "Poll and scoring results" },
],
};

View File

@ -157,4 +157,8 @@ export const dataModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "economic"],
outputPaths: [
{ path: "datasets", name: "Datasets", icon: "📊", description: "Collected analytics datasets" },
{ path: "dashboards", name: "Dashboards", icon: "📈", description: "Analytics dashboards and reports" },
],
};

View File

@ -57,4 +57,8 @@ export const designModule: RSpaceModule = {
routes,
standaloneDomain: "rdesign.online",
externalApp: { url: AFFINE_URL, name: "Affine" },
outputPaths: [
{ path: "designs", name: "Designs", icon: "🎯", description: "Design files and mockups" },
{ path: "templates", name: "Templates", icon: "📐", description: "Reusable design templates" },
],
};

View File

@ -57,4 +57,8 @@ export const docsModule: RSpaceModule = {
routes,
standaloneDomain: "rdocs.online",
externalApp: { url: DOCMOST_URL, name: "Docmost" },
outputPaths: [
{ path: "documents", name: "Documents", icon: "📝", description: "Collaborative documents" },
{ path: "wikis", name: "Wikis", icon: "📖", description: "Knowledge base wikis" },
],
};

View File

@ -419,4 +419,8 @@ export const filesModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "resource"],
outputPaths: [
{ path: "files", name: "Files", icon: "📁", description: "Uploaded files and documents" },
{ path: "shares", name: "Shares", icon: "🔗", description: "Public share links" },
],
};

View File

@ -210,4 +210,8 @@ export const forumModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "governance"],
outputPaths: [
{ path: "threads", name: "Threads", icon: "💬", description: "Forum discussion threads" },
{ path: "categories", name: "Categories", icon: "📂", description: "Forum categories and topics" },
],
};

View File

@ -279,4 +279,8 @@ export const fundsModule: RSpaceModule = {
},
],
acceptsFeeds: ["governance", "data"],
outputPaths: [
{ path: "budgets", name: "Budgets", icon: "💰", description: "Budget allocations and funnels" },
{ path: "flows", name: "Flows", icon: "🌊", description: "Revenue and resource flow visualizations" },
],
};

View File

@ -186,4 +186,8 @@ export const mapsModule: RSpaceModule = {
},
],
acceptsFeeds: ["data"],
outputPaths: [
{ path: "routes", name: "Routes", icon: "🛤️", description: "Saved navigation routes" },
{ path: "places", name: "Places", icon: "📍", description: "Saved locations and points of interest" },
],
};

View File

@ -269,4 +269,8 @@ export const networkModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "trust", "governance"],
outputPaths: [
{ path: "connections", name: "Connections", icon: "🤝", description: "Community member connections" },
{ path: "groups", name: "Groups", icon: "👥", description: "Relationship groups and circles" },
],
};

View File

@ -415,4 +415,9 @@ export const notesModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "resource"],
outputPaths: [
{ path: "notebooks", name: "Notebooks", icon: "📓", description: "Rich-text collaborative notebooks" },
{ path: "transcripts", name: "Transcripts", icon: "🎙️", description: "Voice transcription records" },
{ path: "articles", name: "Articles", icon: "📰", description: "Published articles and posts" },
],
};

View File

@ -140,4 +140,8 @@ export const photosModule: RSpaceModule = {
},
],
acceptsFeeds: ["data"],
outputPaths: [
{ path: "albums", name: "Albums", icon: "📸", description: "Photo albums and collections" },
{ path: "galleries", name: "Galleries", icon: "🖼️", description: "Public photo galleries" },
],
};

View File

@ -360,4 +360,8 @@ export const pubsModule: RSpaceModule = {
},
],
acceptsFeeds: ["data"],
outputPaths: [
{ path: "publications", name: "Publications", icon: "📖", description: "Published pocket books and zines" },
{ path: "drafts", name: "Drafts", icon: "📝", description: "Work-in-progress documents" },
],
};

View File

@ -0,0 +1,172 @@
/**
* Campaign demo data "MycoFi Earth Launch" campaign.
*
* Extracted from server/seed-campaign.ts. This data drives the
* /campaign page in rSocials and the /api/campaigns endpoint.
*/
export interface CampaignPost {
id: string;
platform: string;
postType: string;
stepNumber: number;
content: string;
scheduledAt: string;
status: string;
hashtags: string[];
phase: number;
phaseLabel: string;
}
export interface Campaign {
id: string;
title: string;
description: string;
duration: string;
platforms: string[];
phases: { name: string; label: string; days: string }[];
posts: CampaignPost[];
}
const PLATFORM_ICONS: Record<string, string> = {
x: "𝕏",
linkedin: "in",
instagram: "📷",
youtube: "▶️",
threads: "🧵",
bluesky: "🦋",
};
const PLATFORM_COLORS: Record<string, string> = {
x: "#000000",
linkedin: "#0A66C2",
instagram: "#E4405F",
youtube: "#FF0000",
threads: "#000000",
bluesky: "#0085FF",
};
export { PLATFORM_ICONS, PLATFORM_COLORS };
export const MYCOFI_CAMPAIGN: Campaign = {
id: "mycofi-earth-launch",
title: "MycoFi Earth Launch Campaign",
description: "Multi-platform product launch campaign for MycoFi Earth — a regenerative finance platform modeled on mycelial networks.",
duration: "Feb 2125, 2026 (5 days)",
platforms: ["X", "LinkedIn", "Instagram", "YouTube", "Threads", "Bluesky"],
phases: [
{ name: "pre-launch", label: "Pre-Launch Hype", days: "Day -3 to -1" },
{ name: "launch", label: "Launch Day", days: "Day 0" },
{ name: "amplification", label: "Amplification", days: "Day +1" },
],
posts: [
{
id: "post-x-teaser",
platform: "x",
postType: "thread",
stepNumber: 1,
content: "Something is growing in the mycelium... 🍄\n\nFor the past 2 years, we've been building the infrastructure for a regenerative economy.\n\nOn Feb 24, we reveal everything.\n\nA thread on why the old financial system is composting itself 🧵👇",
scheduledAt: "2026-02-21T09:00:00",
status: "scheduled",
hashtags: ["MycoFi", "RegenFinance", "Web3", "ComingSoon"],
phase: 1,
phaseLabel: "Pre-Launch Hype",
},
{
id: "post-linkedin-thought",
platform: "linkedin",
postType: "article",
stepNumber: 2,
content: "The regenerative finance movement isn't just about returns — it's about redesigning incentive structures from the ground up.\n\nIn this article, I break down why mycelial network theory offers the best model for decentralized economic coordination.\n\n3 key insights from 2 years of building MycoFi Earth...",
scheduledAt: "2026-02-22T11:00:00",
status: "scheduled",
hashtags: ["RegenerativeFinance", "DeFi", "SystemsThinking", "Leadership"],
phase: 1,
phaseLabel: "Pre-Launch Hype",
},
{
id: "post-ig-carousel",
platform: "instagram",
postType: "carousel",
stepNumber: 3,
content: "5 Ways Mycelium Networks Mirror the Future of Finance 🌍🍄\n\nSlide 1: The problem with extractive finance\nSlide 2: How mycelium redistributes nutrients\nSlide 3: Token-weighted funding circles\nSlide 4: Community governance that actually works\nSlide 5: Join the launch — Feb 24",
scheduledAt: "2026-02-23T14:00:00",
status: "scheduled",
hashtags: ["MycoFi", "RegenerativeEconomy", "Infographic", "Web3Education"],
phase: 1,
phaseLabel: "Pre-Launch Hype",
},
{
id: "post-yt-launch",
platform: "youtube",
postType: "video",
stepNumber: 4,
content: "MycoFi Earth — Official Launch Video\n\nThe regenerative economy starts here. Watch how mycelial intelligence is reshaping finance, governance, and community coordination.\n\nFeaturing interviews with 12 builders from the ecosystem.\n\n[18:42]",
scheduledAt: "2026-02-24T10:00:00",
status: "draft",
hashtags: ["MycoFiLaunch", "RegenerativeFinance", "Documentary", "Web3"],
phase: 2,
phaseLabel: "Launch Day",
},
{
id: "post-x-launch",
platform: "x",
postType: "thread",
stepNumber: 5,
content: "🍄 MycoFi Earth is LIVE 🍄\n\nAfter 2 years of building, the regenerative finance platform is here.\n\nWhat is it?\n• Token-weighted funding circles\n• Mycelial governance (no whales)\n• Composting mechanism for failed proposals\n• 100% on-chain, 100% community-owned\n\n👇 Full breakdown thread",
scheduledAt: "2026-02-24T10:15:00",
status: "draft",
hashtags: ["MycoFi", "Launch", "RegenFinance", "DeFi", "DAO"],
phase: 2,
phaseLabel: "Launch Day",
},
{
id: "post-linkedin-launch",
platform: "linkedin",
postType: "text",
stepNumber: 6,
content: "Today we're launching MycoFi Earth — a regenerative finance platform modeled on mycelial networks.\n\nWhy it matters for the future of organizational design:\n\n1. Composting mechanism: Failed proposals return resources to the network\n2. Nutrient routing: Funds flow to where they're needed most\n3. No single point of failure: True decentralization\n\nFull video + docs in comments ↓",
scheduledAt: "2026-02-24T11:00:00",
status: "draft",
hashtags: ["Launch", "RegenerativeFinance", "Innovation", "FutureOfWork"],
phase: 2,
phaseLabel: "Launch Day",
},
{
id: "post-ig-reel",
platform: "instagram",
postType: "reel",
stepNumber: 7,
content: "60-second explainer: How MycoFi Earth works 🍄✨\n\nVisual breakdown of the token flow from contributor → funding circle → project → compost.\n\nSet to lo-fi beats with mycelium time-lapse footage.\n\nCTA: Link in bio to join the first funding circle.",
scheduledAt: "2026-02-25T12:00:00",
status: "draft",
hashtags: ["MycoFi", "RegenFinance", "Explainer", "Web3", "Reels"],
phase: 3,
phaseLabel: "Amplification",
},
{
id: "post-threads-xpost",
platform: "threads",
postType: "text",
stepNumber: 8,
content: "We just launched MycoFi Earth and the response has been incredible 🌟\n\nThe idea is simple: what if finance worked like mycelium?\n\nMycelium doesn't hoard — it redistributes. MycoFi applies that logic to community funding.\n\nEarly access is open. Come grow with us 🌱",
scheduledAt: "2026-02-25T14:00:00",
status: "draft",
hashtags: ["MycoFi", "RegenEconomy", "Community"],
phase: 3,
phaseLabel: "Amplification",
},
{
id: "post-bluesky-launch",
platform: "bluesky",
postType: "text",
stepNumber: 9,
content: "MycoFi Earth just went live 🍄\n\nIt's a regenerative finance platform where funding flows like nutrients through a mycelial network.\n\nNo VCs. No whales. Just communities funding what matters.\n\nmycofi.earth",
scheduledAt: "2026-02-25T15:00:00",
status: "draft",
hashtags: ["MycoFi", "RegenFinance", "Bluesky"],
phase: 3,
phaseLabel: "Amplification",
},
],
};

View File

@ -6,10 +6,11 @@
*/
import { Hono } from "hono";
import { renderShell, renderExternalAppShell } from "../../server/shell";
import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from "./campaign-data";
const routes = new Hono();
@ -73,6 +74,26 @@ routes.get("/api/feed", (c) => {
});
});
// ── API: Campaigns list ──
routes.get("/api/campaigns", (c) => {
const space = c.req.param("space") || "demo";
const campaign = MYCOFI_CAMPAIGN;
return c.json({
campaigns: [
{
id: campaign.id,
title: campaign.title,
description: campaign.description,
duration: campaign.duration,
platforms: campaign.platforms,
postCount: campaign.posts.length,
updated_at: "2026-02-21T09:00:00Z",
url: `/${space}/rsocials/campaign`,
},
],
});
});
// ── Demo feed data (server-rendered, no API calls) ──
const DEMO_FEED = [
{
@ -166,6 +187,111 @@ function renderDemoFeedHTML(): string {
</div>`;
}
// ── Campaign page route ──
function renderCampaignPage(space: string): string {
const c = MYCOFI_CAMPAIGN;
const phases = [1, 2, 3];
const phaseIcons = ["📣", "🚀", "📡"];
const phaseHTML = phases.map((phaseNum, i) => {
const phasePosts = c.posts.filter((p) => p.phase === phaseNum);
const phaseInfo = c.phases[i];
const postsHTML = phasePosts.map((post) => {
const icon = PLATFORM_ICONS[post.platform] || post.platform;
const color = PLATFORM_COLORS[post.platform] || "#64748b";
const statusClass = post.status === "scheduled" ? "campaign-status--scheduled" : "campaign-status--draft";
const date = new Date(post.scheduledAt);
const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
const contentPreview = escapeHtml(post.content.length > 180 ? post.content.substring(0, 180) + "..." : post.content);
const tags = post.hashtags.map((h) => `<span class="campaign-tag">#${escapeHtml(h)}</span>`).join(" ");
return `
<div class="campaign-post" data-platform="${escapeHtml(post.platform)}">
<div class="campaign-post__header">
<span class="campaign-post__platform" style="background:${color}">${icon}</span>
<div class="campaign-post__meta">
<strong>${escapeHtml(post.platform)} ${escapeHtml(post.postType)}</strong>
<span class="campaign-post__date">${dateStr}</span>
</div>
<span class="campaign-status ${statusClass}">${escapeHtml(post.status)}</span>
</div>
<div class="campaign-post__step">Step ${post.stepNumber}</div>
<p class="campaign-post__content">${contentPreview.replace(/\n/g, "<br>")}</p>
<div class="campaign-post__tags">${tags}</div>
</div>`;
}).join("\n");
return `
<div class="campaign-phase">
<h3 class="campaign-phase__title">${phaseIcons[i]} Phase ${phaseNum}: ${escapeHtml(phaseInfo.label)} <span class="campaign-phase__days">${escapeHtml(phaseInfo.days)}</span></h3>
<div class="campaign-phase__posts">${postsHTML}</div>
</div>`;
}).join("\n");
return `
<div class="campaign-page">
<div class="campaign-page__header">
<span class="campaign-page__icon">🍄</span>
<div>
<h1 class="campaign-page__title">${escapeHtml(c.title)}</h1>
<p class="campaign-page__desc">${escapeHtml(c.description)}</p>
<div class="campaign-page__stats">
<span>📅 ${escapeHtml(c.duration)}</span>
<span>📱 ${c.platforms.join(", ")}</span>
<span>📝 ${c.posts.length} posts across ${c.phases.length} phases</span>
</div>
</div>
</div>
${phaseHTML}
</div>`;
}
const CAMPAIGN_CSS = `
.campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #334155; }
.campaign-page__icon { font-size: 3rem; }
.campaign-page__title { margin: 0; font-size: 1.5rem; color: #f1f5f9; }
.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: #94a3b8; font-size: 0.9rem; line-height: 1.5; }
.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: #64748b; }
.campaign-phase { margin-bottom: 2rem; }
.campaign-phase__title { font-size: 1.15rem; color: #e2e8f0; margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; }
.campaign-phase__days { font-size: 0.8rem; color: #64748b; font-weight: 400; }
.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.campaign-post {
background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1rem;
transition: border-color 0.15s;
}
.campaign-post:hover { border-color: #6366f1; }
.campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.campaign-post__platform {
width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0;
}
.campaign-post__meta { flex: 1; min-width: 0; }
.campaign-post__meta strong { display: block; font-size: 0.8rem; color: #e2e8f0; text-transform: capitalize; }
.campaign-post__date { font-size: 0.7rem; color: #64748b; }
.campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; }
.campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; }
.campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; }
.campaign-post__content { font-size: 0.8rem; color: #94a3b8; line-height: 1.5; margin: 0 0 0.5rem; }
.campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.campaign-tag { font-size: 0.65rem; color: #7dd3fc; }
`;
routes.get("/campaign", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Campaign — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: renderCampaignPage(space),
styles: `<style>${CAMPAIGN_CSS}</style>`,
}));
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
@ -337,4 +463,8 @@ export const socialsModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "trust"],
outputPaths: [
{ path: "campaigns", name: "Campaigns", icon: "📢", description: "Social media campaigns" },
{ path: "posts", name: "Posts", icon: "📱", description: "Social feed posts across platforms" },
],
};

View File

@ -71,4 +71,7 @@ export const canvasModule: RSpaceModule = {
},
],
acceptsFeeds: ["economic", "trust", "data", "attention", "governance", "resource"],
outputPaths: [
{ path: "canvases", name: "Canvases", icon: "🎨", description: "Collaborative infinite canvases" },
],
};

View File

@ -543,6 +543,9 @@ export const splatModule: RSpaceModule = {
landingPage: renderLanding,
standaloneDomain: "rsplat.online",
hidden: true,
outputPaths: [
{ path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" },
],
async onSpaceCreate(_spaceSlug: string) {
// Splats are scoped by space_slug column. No per-space setup needed.

View File

@ -266,4 +266,7 @@ export const swagModule: RSpaceModule = {
},
],
acceptsFeeds: ["economic", "resource"],
outputPaths: [
{ path: "merchandise", name: "Merchandise", icon: "👕", description: "Print-ready swag designs" },
],
};

View File

@ -305,4 +305,7 @@ export const tripsModule: RSpaceModule = {
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "itineraries", name: "Itineraries", icon: "🗓️", description: "Trip itineraries with bookings and activities" },
],
};

View File

@ -242,4 +242,8 @@ export const tubeModule: RSpaceModule = {
},
],
acceptsFeeds: ["data", "resource"],
outputPaths: [
{ path: "videos", name: "Videos", icon: "🎬", description: "Hosted videos and recordings" },
{ path: "playlists", name: "Playlists", icon: "📺", description: "Video playlists and channels" },
],
};

View File

@ -379,4 +379,8 @@ export const voteModule: RSpaceModule = {
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" },
{ path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" },
],
};

View File

@ -254,4 +254,8 @@ export const workModule: RSpaceModule = {
},
],
acceptsFeeds: ["governance", "data"],
outputPaths: [
{ path: "projects", name: "Projects", icon: "📋", description: "Kanban project boards" },
{ path: "tasks", name: "Tasks", icon: "✅", description: "Task cards across all boards" },
],
};

View File

@ -30,7 +30,7 @@ import {
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { ensureCampaignDemo } from "./seed-campaign";
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
@ -69,6 +69,7 @@ import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { spaces } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
@ -394,21 +395,7 @@ app.post("/api/communities/demo/reset", async (c) => {
return c.json({ ok: true, message: "Demo community reset to seed data" });
});
// POST /api/communities/campaign-demo/reset
app.post("/api/communities/campaign-demo/reset", async (c) => {
const now = Date.now();
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
return c.json({ error: `Reset on cooldown. Try again in ${remaining}s` }, 429);
}
lastDemoReset = now;
await loadCommunity("campaign-demo");
clearShapes("campaign-demo");
await ensureCampaignDemo();
broadcastAutomergeSync("campaign-demo");
broadcastJsonSnapshot("campaign-demo");
return c.json({ ok: true, message: "Campaign demo reset to seed data" });
});
// Campaign demo reset removed — campaign is now at /:space/rsocials/campaign
// GET /api/communities/:slug/shapes
app.get("/api/communities/:slug/shapes", async (c) => {
@ -834,6 +821,16 @@ app.use("/:space/*", async (c, next) => {
// ── Mount module routes under /:space/:moduleId ──
for (const mod of getAllModules()) {
app.route(`/:space/${mod.id}`, mod.routes);
// Auto-mount browsable output list pages
if (mod.outputPaths) {
for (const op of mod.outputPaths) {
app.get(`/:space/${mod.id}/${op.path}`, (c) => {
return c.html(renderOutputListPage(
c.req.param("space"), mod, op, getModuleInfoList()
));
});
}
}
}
// ── Page routes ──
@ -1536,7 +1533,6 @@ const server = Bun.serve<WSData>({
// ── Startup ──
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
ensureCampaignDemo().then(() => console.log("[Campaign] Campaign demo ready")).catch((e) => console.error("[Campaign] Failed:", e));
loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e));
console.log(`rSpace unified server running on http://localhost:${PORT}`);

143
server/output-list.ts Normal file
View File

@ -0,0 +1,143 @@
/**
* Output list page renderer.
*
* Generates a browsable list page for a module's output type.
* Fetches items from the module's API and renders them as a card grid
* inside the standard rSpace shell.
*/
import type { ModuleInfo, OutputPath } from "../shared/module";
import { renderShell, escapeHtml, escapeAttr } from "./shell";
export function renderOutputListPage(
space: string,
mod: { id: string; name: string; icon: string; description: string },
outputPath: OutputPath,
modules: ModuleInfo[],
): string {
const body = `
<div class="output-list">
<div class="output-list__header">
<span class="output-list__icon">${escapeHtml(outputPath.icon)}</span>
<div>
<h1 class="output-list__title">${escapeHtml(outputPath.name)}</h1>
<p class="output-list__desc">${escapeHtml(outputPath.description)}</p>
</div>
</div>
<div class="output-list__grid" id="output-grid">
<div class="output-list__loading">Loading</div>
</div>
</div>
<script>
(function() {
var grid = document.getElementById('output-grid');
var space = ${JSON.stringify(space)};
var moduleId = ${JSON.stringify(mod.id)};
var outputPath = ${JSON.stringify(outputPath.path)};
var outputName = ${JSON.stringify(outputPath.name)};
function timeAgo(dateStr) {
if (!dateStr) return '';
var diff = Date.now() - new Date(dateStr).getTime();
var s = Math.floor(diff / 1000);
if (s < 60) return 'just now';
var m = Math.floor(s / 60);
if (m < 60) return m + (m === 1 ? ' minute ago' : ' minutes ago');
var h = Math.floor(m / 60);
if (h < 24) return h + (h === 1 ? ' hour ago' : ' hours ago');
var d = Math.floor(h / 24);
if (d < 30) return d + (d === 1 ? ' day ago' : ' days ago');
var mo = Math.floor(d / 30);
return mo + (mo === 1 ? ' month ago' : ' months ago');
}
function renderCards(items) {
if (!items || items.length === 0) {
grid.innerHTML =
'<div class="output-list__empty">' +
'<div class="output-list__empty-icon">${escapeHtml(outputPath.icon)}</div>' +
'<p>No ' + outputName.toLowerCase() + ' yet</p>' +
'</div>';
return;
}
grid.innerHTML = items.map(function(item) {
var title = item.title || item.name || 'Untitled';
var desc = item.description || item.content_plain || '';
if (desc.length > 120) desc = desc.substring(0, 120) + '…';
var date = item.updated_at || item.created_at || '';
var href = item.url || ('/' + space + '/' + moduleId + '?item=' + (item.id || item.slug || ''));
return '<a class="output-card" href="' + href + '">' +
'<div class="output-card__title">' + escapeText(title) + '</div>' +
(desc ? '<div class="output-card__desc">' + escapeText(desc) + '</div>' : '') +
(date ? '<div class="output-card__date">Updated ' + timeAgo(date) + '</div>' : '') +
'</a>';
}).join('');
}
function escapeText(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
fetch('/' + space + '/' + moduleId + '/api/' + outputPath)
.then(function(r) {
if (!r.ok) throw new Error(r.status);
return r.json();
})
.then(function(data) {
// Try common response shapes: { items: [] }, { [key]: [] }, or array
var items = Array.isArray(data) ? data
: data.items ? data.items
: data[outputPath] ? data[outputPath]
: Object.values(data).find(function(v) { return Array.isArray(v); })
|| [];
renderCards(items);
})
.catch(function() {
renderCards([]);
});
})();
</script>`;
const styles = `<style>
.output-list { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }
.output-list__header { display: flex; align-items: center; gap: 1rem; margin-bottom: 2rem; }
.output-list__icon { font-size: 2.5rem; }
.output-list__title { margin: 0; font-size: 1.5rem; color: #f1f5f9; }
.output-list__desc { margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.95rem; }
.output-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.output-list__loading { color: #64748b; text-align: center; padding: 3rem 0; grid-column: 1 / -1; }
.output-list__empty { text-align: center; padding: 4rem 1rem; grid-column: 1 / -1; color: #64748b; }
.output-list__empty-icon { font-size: 3rem; margin-bottom: 0.5rem; }
.output-card {
display: block;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.75rem;
padding: 1.25rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, transform 0.15s;
}
.output-card:hover { border-color: #6366f1; transform: translateY(-2px); }
.output-card__title { font-size: 1.05rem; font-weight: 600; color: #f1f5f9; margin-bottom: 0.5rem; }
.output-card__desc { font-size: 0.875rem; color: #94a3b8; line-height: 1.4; margin-bottom: 0.5rem; }
.output-card__date { font-size: 0.75rem; color: #64748b; }
</style>`;
return renderShell({
title: `${outputPath.name}${mod.name} | rSpace`,
moduleId: mod.id,
spaceSlug: space,
body,
modules,
theme: "dark",
styles,
});
}