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:
parent
4979c3d80c
commit
5408eb0376
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 21–25, 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -266,4 +266,7 @@ export const swagModule: RSpaceModule = {
|
|||
},
|
||||
],
|
||||
acceptsFeeds: ["economic", "resource"],
|
||||
outputPaths: [
|
||||
{ path: "merchandise", name: "Merchandise", icon: "👕", description: "Print-ready swag designs" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -305,4 +305,7 @@ export const tripsModule: RSpaceModule = {
|
|||
},
|
||||
],
|
||||
acceptsFeeds: ["economic", "data"],
|
||||
outputPaths: [
|
||||
{ path: "itineraries", name: "Itineraries", icon: "🗓️", description: "Trip itineraries with bookings and activities" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue