diff --git a/server/index.ts b/server/index.ts index 3453217..7293711 100644 --- a/server/index.ts +++ b/server/index.ts @@ -30,6 +30,7 @@ import { } from "./community-store"; import type { NestPermissions, SpaceRefFilter } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; +import { seedTemplateShapes, ensureTemplateSeeding } from "./seed-template"; // Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts import type { SpaceVisibility } from "./community-store"; import { @@ -817,6 +818,27 @@ app.use("/:space/*", async (c, next) => { ); }); +// ── Template seeding route: /:space/:moduleId/template ── +// Triggers template seeding for empty spaces, then redirects to the module page. +app.get("/:space/:moduleId/template", async (c) => { + const space = c.req.param("space"); + const moduleId = c.req.param("moduleId"); + if (!space || space === "api" || space.includes(".")) return c.notFound(); + + try { + await loadCommunity(space); + const seeded = seedTemplateShapes(space); + if (seeded) { + broadcastAutomergeSync(space); + broadcastJsonSnapshot(space); + } + } catch (e) { + console.error(`[Template] On-demand seed failed for "${space}":`, e); + } + + return c.redirect(`/${space}/${moduleId}`, 302); +}); + // ── Mount module routes under /:space/:moduleId ── // Enforce enabledModules: if a space has an explicit list, only those modules route. // The 'rspace' (canvas) module is always allowed as the core module. @@ -1348,6 +1370,22 @@ const server = Bun.serve({ return app.fetch(req); } + // Template seeding: /{moduleId}/template → seed + redirect + if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") { + try { + await loadCommunity(subdomain); + const seeded = seedTemplateShapes(subdomain); + if (seeded) { + broadcastAutomergeSync(subdomain); + broadcastJsonSnapshot(subdomain); + } + } catch (e) { + console.error(`[Template] On-demand seed failed for "${subdomain}":`, e); + } + const withoutTemplate = pathSegments.slice(0, -1).join("/"); + return Response.redirect(`${url.protocol}//${url.host}/${withoutTemplate}`, 302); + } + // Normalize module ID to lowercase (rTrips → rtrips) const normalizedPath = "/" + pathSegments.map((seg, i) => i === 0 ? seg.toLowerCase() : seg @@ -1698,7 +1736,9 @@ const server = Bun.serve({ })(); ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); -loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e)); +loadAllDocs(syncServer) + .then(() => ensureTemplateSeeding()) + .catch((e) => console.error("[DocStore] Startup load failed:", e)); // Restore relay mode for encrypted spaces (async () => { diff --git a/server/seed-template.ts b/server/seed-template.ts new file mode 100644 index 0000000..1833d81 --- /dev/null +++ b/server/seed-template.ts @@ -0,0 +1,454 @@ +/** + * Template seeder for new rSpace communities. + * + * Every non-demo space gets generic "Getting Started" content covering + * all 25+ modules so users immediately see what each rApp can do. + * + * Shape IDs use a `tmpl-` prefix so they never collide with `demo-` or + * user-created shapes. Seeding is idempotent — only spaces with 0 shapes + * are touched, and the demo space is always skipped. + */ + +import { + addShapes, + getDocumentData, + listCommunities, + loadCommunity, +} from "./community-store"; + +// ── Template Shapes ───────────────────────────────────────────────── + +const TEMPLATE_SHAPES: Record[] = [ + // ─── rTrips: Itinerary ────────────────────────────────────── + { + id: "tmpl-itinerary", + type: "folk-itinerary", + x: 50, y: 50, width: 400, height: 300, rotation: 0, + tripTitle: "My Trip", + startDate: "", + endDate: "", + travelers: [], + items: [ + { date: "Day 1", activity: "Arrive & settle in", category: "travel" }, + { date: "Day 2", activity: "Explore the area", category: "adventure" }, + { date: "Day 3", activity: "Head home", category: "travel" }, + ], + }, + + // ─── rTrips: Destination ──────────────────────────────────── + { + id: "tmpl-destination", + type: "folk-destination", + x: 500, y: 50, width: 300, height: 200, rotation: 0, + destName: "My Destination", + country: "", + lat: 0, + lng: 0, + arrivalDate: "", + departureDate: "", + notes: "Add your destination details here.", + }, + + // ─── rNotes: Notebook ─────────────────────────────────────── + { + id: "tmpl-notebook", + type: "folk-notebook", + x: 850, y: 50, width: 300, height: 180, rotation: 0, + notebookTitle: "My Notebook", + description: "A shared notebook for your space. Add notes, ideas, and reference material.", + noteCount: 0, + collaborators: [], + }, + + // ─── rNotes: Welcome Note ─────────────────────────────────── + { + id: "tmpl-note-welcome", + type: "folk-note", + x: 850, y: 260, width: 300, height: 250, rotation: 0, + noteTitle: "Welcome to rSpace", + content: "## Getting Started\n\nThis is your space! Here's what you can do:\n\n- **Explore** the canvas — drag, zoom, and click shapes\n- **Create** new items using the module sidebar\n- **Collaborate** in real-time with your team\n- **Customize** by rearranging or removing these starter shapes\n\nEach shape represents an rApp module. Click one to dive in!", + tags: ["getting-started"], + editor: "", + editedAt: "", + }, + + // ─── rNotes: Ideas Note ───────────────────────────────────── + { + id: "tmpl-note-ideas", + type: "folk-note", + x: 1200, y: 50, width: 300, height: 200, rotation: 0, + noteTitle: "Ideas & Brainstorms", + content: "## Ideas\n\nCapture your ideas here.\n\n- Idea 1\n- Idea 2\n- Idea 3\n\nUse tags to organize your notes across the space.", + tags: ["ideas"], + editor: "", + editedAt: "", + }, + + // ─── rVote: Poll ──────────────────────────────────────────── + { + id: "tmpl-poll", + type: "demo-poll", + x: 50, y: 400, width: 350, height: 200, rotation: 0, + question: "What should we work on first?", + options: [ + { label: "Set up the space", votes: 0 }, + { label: "Invite team members", votes: 0 }, + { label: "Plan our first project", votes: 0 }, + ], + totalVoters: 0, + status: "active", + endsAt: "", + }, + + // ─── rCart: Item ──────────────────────────────────────────── + { + id: "tmpl-cart-item", + type: "demo-cart-item", + x: 50, y: 630, width: 320, height: 60, rotation: 0, + name: "Example Item", + price: 0, + funded: 0, + status: "In Cart", + requestedBy: "", + store: "", + }, + + // ─── rFunds: Budget ───────────────────────────────────────── + { + id: "tmpl-budget", + type: "folk-budget", + x: 450, y: 400, width: 350, height: 250, rotation: 0, + budgetTitle: "Space Budget", + currency: "USD", + budgetTotal: 0, + spent: 0, + categories: [ + { name: "Operations", budget: 0, spent: 0 }, + { name: "Marketing", budget: 0, spent: 0 }, + { name: "Development", budget: 0, spent: 0 }, + ], + }, + + // ─── rFunds: Expense ──────────────────────────────────────── + { + id: "tmpl-expense", + type: "demo-expense", + x: 450, y: 680, width: 320, height: 60, rotation: 0, + description: "Example expense", + amount: 0, + currency: "USD", + paidBy: "", + split: "equal", + category: "operations", + date: "", + }, + + // ─── rMaps: Location Marker ───────────────────────────────── + { + id: "tmpl-map-marker", + type: "demo-map-marker", + x: 500, y: 280, width: 40, height: 40, rotation: 0, + name: "Home Base", + lat: 0, + lng: 0, + emoji: "📍", + category: "home", + status: "", + }, + + // ─── rTokens: Mint ────────────────────────────────────────── + { + id: "tmpl-mint", + type: "folk-token-mint", + x: 1550, y: 50, width: 320, height: 280, rotation: 0, + tokenName: "Contribution Token", + tokenSymbol: "CONTRIB", + description: "Track and reward contributions to your space. Customize the name, symbol, and supply.", + totalSupply: 1000, + issuedSupply: 0, + tokenColor: "#6d28d9", + tokenIcon: "⭐", + createdBy: "", + createdAt: "", + }, + + // ─── rTokens: Ledger ──────────────────────────────────────── + { + id: "tmpl-ledger", + type: "folk-token-ledger", + x: 1940, y: 50, width: 380, height: 400, rotation: 0, + mintId: "tmpl-mint", + entries: [], + }, + + // ─── rTokens: Arrow ───────────────────────────────────────── + { + id: "tmpl-arrow-tokens", + type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: "tmpl-mint", + targetId: "tmpl-ledger", + color: "#6d28d9", + }, + + // ─── rFiles: File ─────────────────────────────────────────── + { + id: "tmpl-file-readme", + type: "folk-file", + x: 1550, y: 500, width: 280, height: 80, rotation: 0, + fileName: "README.md", + fileSize: "0 KB", + mimeType: "text/markdown", + uploadedBy: "", + uploadedAt: "", + tags: ["getting-started"], + }, + + // ─── rForum: Thread ───────────────────────────────────────── + { + id: "tmpl-forum-intro", + type: "folk-forum-thread", + x: 1550, y: 620, width: 320, height: 160, rotation: 0, + threadTitle: "Introductions", + author: "", + createdAt: "", + replyCount: 0, + lastReply: "", + preview: "Introduce yourself to the space! Share who you are and what you're excited to work on.", + tags: ["introductions"], + }, + + // ─── rBooks: Book ─────────────────────────────────────────── + { + id: "tmpl-book", + type: "folk-book", + x: 1940, y: 500, width: 280, height: 200, rotation: 0, + bookTitle: "Getting Started Guide", + author: "rSpace", + coverColor: "#3b82f6", + pageCount: 0, + currentPage: 0, + readers: [], + status: "want-to-read", + }, + + // ─── rPubs: Publication ───────────────────────────────────── + { + id: "tmpl-pub", + type: "folk-pub", + x: 50, y: 780, width: 300, height: 180, rotation: 0, + pubTitle: "My First Publication", + pubType: "zine", + creator: "", + format: "", + status: "draft", + copies: 0, + price: 0, + currency: "USD", + description: "A placeholder publication. Edit this to create your first zine, booklet, or print artifact.", + }, + + // ─── rSwag: Item ──────────────────────────────────────────── + { + id: "tmpl-swag", + type: "folk-swag", + x: 400, y: 780, width: 280, height: 120, rotation: 0, + swagTitle: "Space Sticker", + swagType: "sticker", + designer: "", + sizes: [], + price: 0, + currency: "USD", + status: "draft", + orderCount: 0, + }, + + // ─── rProviders: Provider ─────────────────────────────────── + { + id: "tmpl-provider", + type: "folk-provider", + x: 400, y: 930, width: 300, height: 160, rotation: 0, + providerName: "Local Print Shop", + location: "", + capabilities: ["print", "stickers"], + substrates: ["paper", "vinyl"], + turnaround: "", + rating: 0, + ordersFulfilled: 0, + }, + + // ─── rWork: Task Board ────────────────────────────────────── + { + id: "tmpl-work-board", + type: "folk-work-board", + x: 750, y: 780, width: 500, height: 280, rotation: 0, + boardTitle: "Getting Started Tasks", + columns: [ + { + name: "To Do", + tasks: [ + { title: "Customize your space", assignee: "", priority: "high" }, + { title: "Invite collaborators", assignee: "", priority: "medium" }, + ], + }, + { + name: "In Progress", + tasks: [ + { title: "Explore the modules", assignee: "", priority: "medium" }, + ], + }, + { + name: "Done", + tasks: [], + }, + ], + }, + + // ─── rCal: Calendar ───────────────────────────────────────── + { + id: "tmpl-calendar", + type: "folk-calendar", + x: 50, y: 1000, width: 350, height: 250, rotation: 0, + calTitle: "My Calendar", + month: "", + events: [ + { date: "Today", title: "Space Created", color: "#22c55e" }, + ], + }, + + // ─── rNetwork: Graph ──────────────────────────────────────── + { + id: "tmpl-network", + type: "folk-network", + x: 1300, y: 780, width: 400, height: 280, rotation: 0, + networkTitle: "My Network", + nodes: [ + { id: "me", label: "Me", role: "organizer" }, + ], + edges: [], + }, + + // ─── rTube: Video ─────────────────────────────────────────── + { + id: "tmpl-video", + type: "folk-video", + x: 1750, y: 780, width: 300, height: 180, rotation: 0, + videoTitle: "Welcome Video", + duration: "", + creator: "", + uploadedAt: "", + views: 0, + thumbnail: "", + }, + + // ─── rInbox: Inbox ────────────────────────────────────────── + { + id: "tmpl-inbox", + type: "folk-inbox", + x: 1750, y: 990, width: 300, height: 160, rotation: 0, + inboxTitle: "Inbox", + messages: [ + { from: "rSpace", text: "Welcome to your new space! Start by exploring the canvas.", time: "" }, + ], + }, + + // ─── rData: Dashboard ─────────────────────────────────────── + { + id: "tmpl-dashboard", + type: "folk-dashboard", + x: 50, y: 1280, width: 320, height: 220, rotation: 0, + dashTitle: "Space Dashboard", + metrics: [ + { label: "Members", value: "1", trend: "neutral" }, + { label: "Shapes", value: "0", trend: "neutral" }, + { label: "Tasks", value: "0/3", trend: "neutral" }, + ], + }, + + // ─── rChoices: Decision Matrix ────────────────────────────── + { + id: "tmpl-choices", + type: "folk-choice-matrix", + x: 420, y: 1280, width: 320, height: 200, rotation: 0, + choiceTitle: "Decision Matrix", + options: [ + { name: "Option A", score: 0, criteria: {} }, + { name: "Option B", score: 0, criteria: {} }, + ], + decidedBy: "", + status: "open", + winner: "", + }, + + // ─── rSplat: 3D Capture ───────────────────────────────────── + { + id: "tmpl-splat", + type: "folk-splat", + x: 790, y: 1280, width: 300, height: 160, rotation: 0, + splatTitle: "My 3D Capture", + pointCount: "0", + capturedBy: "", + capturedAt: "", + fileSize: "0 KB", + status: "pending", + }, + + // ─── rNotes: Packing List ─────────────────────────────────── + { + id: "tmpl-packing", + type: "folk-packing-list", + x: 1140, y: 1280, width: 300, height: 300, rotation: 0, + listTitle: "My Packing List", + items: [ + { name: "Item 1", packed: false, category: "general" }, + { name: "Item 2", packed: false, category: "general" }, + { name: "Item 3", packed: false, category: "general" }, + ], + }, +]; + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Seed template shapes into a space if it has 0 shapes. + * Skips the demo space. Returns true if shapes were added. + */ +export function seedTemplateShapes(slug: string): boolean { + if (slug === "demo") return false; + + const data = getDocumentData(slug); + const shapeCount = data ? Object.keys(data.shapes || {}).length : 0; + + if (shapeCount > 0) { + return false; + } + + addShapes(slug, TEMPLATE_SHAPES); + console.log(`[Template] Seeded ${TEMPLATE_SHAPES.length} template shapes into "${slug}"`); + return true; +} + +/** + * Iterate all existing communities on startup and seed any + * empty (0-shape) spaces with template content. + */ +export async function ensureTemplateSeeding(): Promise { + const slugs = await listCommunities(); + let seededCount = 0; + + for (const slug of slugs) { + if (slug === "demo") continue; + try { + await loadCommunity(slug); + if (seedTemplateShapes(slug)) { + seededCount++; + } + } catch (e) { + console.error(`[Template] Failed to seed "${slug}":`, e); + } + } + + if (seededCount > 0) { + console.log(`[Template] Retroactively seeded ${seededCount} space(s)`); + } +} diff --git a/server/spaces.ts b/server/spaces.ts index 050a90a..9060b32 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -46,6 +46,7 @@ import type { EncryptIDClaims } from "@encryptid/sdk/server"; import { getAllModules, getModule } from "../shared/module"; import type { SpaceLifecycleContext } from "../shared/module"; import { syncServer } from "./sync-instance"; +import { seedTemplateShapes } from "./seed-template"; // ── Unified space creation ── @@ -98,6 +99,13 @@ export async function createSpace(opts: CreateSpaceOpts): Promise