From ebdda1e443aa61a63c4c0150fcf6ae7a4d1d0175 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 22:05:08 -0800 Subject: [PATCH] feat: enhanced rinbox multisig approval UI, module landing pages, help guide - Rinbox: visual multisig approval cards with signer avatars, progress bars, email previews, status-colored borders, and compose-for-approval form - Rinbox: help/guide popout with feature cards, how-it-works steps, use cases - Rinbox: rich demo data with threaded comments, signer lists, multiple mailboxes - Module landing pages: improved UX descriptions for rBooks, rCal, rNotes, rTrips, rVote, rWork with proper feature descriptions - Added landingPage support to RSpaceModule interface and server routing Co-Authored-By: Claude Opus 4.6 --- modules/rbooks/mod.ts | 46 ++ modules/rcal/mod.ts | 1 + .../rinbox/components/folk-inbox-client.ts | 495 +++++++++++++++--- modules/rinbox/landing.ts | 239 ++++----- modules/rnotes/mod.ts | 1 + modules/rtrips/mod.ts | 71 +++ modules/rvote/mod.ts | 23 +- modules/rwork/mod.ts | 15 +- server/index.ts | 9 +- shared/module.ts | 3 + 10 files changed, 676 insertions(+), 227 deletions(-) diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index 8a5a3cc..d4ed66a 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -372,6 +372,51 @@ routes.get("/read/:id", async (c) => { return c.html(html); }); +// ── Seed template data ── + +function seedTemplateBooks(space: string) { + if (!_syncServer) return; + const docId = booksCatalogDocId(space); + const doc = ensureDoc(space); + if (Object.keys(doc.items).length > 0) return; + + const now = Date.now(); + const books: Array<{ title: string; author: string; desc: string; tags: string[]; color: string }> = [ + { + title: 'Governing the Commons', author: 'Elinor Ostrom', + desc: 'Nobel Prize-winning analysis of how communities self-organize to manage shared resources without privatization or state control.', + tags: ['commons', 'governance', 'economics'], color: '#6366f1', + }, + { + title: 'Doughnut Economics', author: 'Kate Raworth', + desc: 'A framework for 21st-century economics that meets the needs of all within the means of the planet.', + tags: ['economics', 'sustainability', 'systems-thinking'], color: '#10b981', + }, + { + title: 'Sacred Economics', author: 'Charles Eisenstein', + desc: 'Tracing the history of money from ancient gift economies to modern capitalism, and envisioning a more connected economy.', + tags: ['economics', 'gift-economy', 'philosophy'], color: '#f59e0b', + }, + ]; + + _syncServer.changeDoc(docId, 'seed template books', (d) => { + for (const b of books) { + const id = crypto.randomUUID(); + const slug = b.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + d.items[id] = { + id, slug, title: b.title, author: b.author, description: b.desc, + pdfPath: '', pdfSizeBytes: 0, pageCount: 0, tags: b.tags, + license: 'CC BY-SA 4.0', coverColor: b.color, + contributorId: 'did:demo:seed', contributorName: 'Community Library', + status: 'published', featured: false, viewCount: 0, downloadCount: 0, + createdAt: now, updatedAt: now, + }; + } + }); + + console.log(`[Books] Template seeded for "${space}": 3 book entries`); +} + // ── Module export ── export const booksModule: RSpaceModule = { @@ -384,6 +429,7 @@ export const booksModule: RSpaceModule = { routes, standaloneDomain: "rbooks.online", landingPage: renderLanding, + seedTemplate: seedTemplateBooks, async onInit(ctx) { _syncServer = ctx.syncServer; console.log("[Books] Module initialized (Automerge storage)"); diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 411fe81..f68852a 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -663,6 +663,7 @@ export const calModule: RSpaceModule = { routes, standaloneDomain: "rcal.online", landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; // Seed demo data for the default space diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index 145304a..139ccf6 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -2,7 +2,7 @@ * folk-inbox-client — Collaborative email client. * * Shows mailbox list, thread inbox, thread detail with comments, - * and approval workflow interface. + * and approval workflow interface. Includes a help/guide popout. */ class FolkInboxClient extends HTMLElement { @@ -15,6 +15,41 @@ class FolkInboxClient extends HTMLElement { private currentThread: any = null; private approvals: any[] = []; private filter: "all" | "open" | "snoozed" | "closed" = "all"; + private helpOpen = false; + private composeOpen = false; + private demoApprovals: any[] = [ + { + id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING", + to_addresses: ["finance@example.org"], required_signatures: 3, signature_count: 1, + body_text: "Approved. Please proceed with the cloud infrastructure upgrade as outlined. Budget: $4,200/quarter.", + created_at: new Date(Date.now() - 4 * 3600000).toISOString(), + signers: [ + { name: "Alice Chen", vote: "APPROVE", signed_at: new Date(Date.now() - 3 * 3600000).toISOString() }, + { name: "Bob Martinez", vote: null, signed_at: null }, + { name: "Carol Wu", vote: null, signed_at: null }, + ], + }, + { + id: "a2", subject: "Partnership agreement with Acme Corp", status: "PENDING", + to_addresses: ["legal@acmecorp.com", "partnerships@example.org"], required_signatures: 2, signature_count: 1, + body_text: "We've reviewed the terms and are ready to proceed. Please find the signed MOU attached.", + created_at: new Date(Date.now() - 12 * 3600000).toISOString(), + signers: [ + { name: "Dave Park", vote: "APPROVE", signed_at: new Date(Date.now() - 10 * 3600000).toISOString() }, + { name: "Alice Chen", vote: null, signed_at: null }, + ], + }, + { + id: "a3", subject: "Re: Security incident disclosure", status: "APPROVED", + to_addresses: ["security@example.com"], required_signatures: 2, signature_count: 2, + body_text: "Disclosure notice prepared and co-signed. Sending to affected parties.", + created_at: new Date(Date.now() - 48 * 3600000).toISOString(), + signers: [ + { name: "Bob Martinez", vote: "APPROVE", signed_at: new Date(Date.now() - 46 * 3600000).toISOString() }, + { name: "Carol Wu", vote: "APPROVE", signed_at: new Date(Date.now() - 44 * 3600000).toISOString() }, + ], + }, + ]; private demoThreads: Record = { team: [ { id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] }, @@ -42,8 +77,8 @@ class FolkInboxClient extends HTMLElement { private loadDemoData() { this.mailboxes = [ - { slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications" }, - { slug: "support", name: "Support", email: "support@rspace.online", description: "Community support requests with multi-sig approval workflows" }, + { slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 }, + { slug: "support", name: "Support", email: "support@rspace.online", description: "Community support requests with multi-sig approval workflows", thread_count: 3, unread_count: 1 }, ]; this.render(); } @@ -100,7 +135,7 @@ class FolkInboxClient extends HTMLElement { } private async loadApprovals() { - if (this.space === "demo") { this.approvals = []; this.render(); return; } + if (this.space === "demo") { this.approvals = this.demoApprovals; this.render(); return; } try { const base = window.location.pathname.replace(/\/$/, ""); const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : ""; @@ -123,65 +158,165 @@ class FolkInboxClient extends HTMLElement { return `${days}d ago`; } + private snippet(text: string, len = 80): string { + if (!text) return ""; + const s = text.replace(/\n+/g, " ").trim(); + return s.length > len ? s.slice(0, len) + "..." : s; + } + private render() { this.shadow.innerHTML = `
${this.renderNav()} ${this.renderView()} + ${this.helpOpen ? this.renderHelp() : ""}
`; this.bindEvents(); @@ -199,6 +334,8 @@ class FolkInboxClient extends HTMLElement {
${this.view !== "mailboxes" ? `` : ""} ${items.map((i) => ``).join("")} + +
`; } @@ -215,17 +352,32 @@ class FolkInboxClient extends HTMLElement { private renderMailboxes(): string { if (this.mailboxes.length === 0) { - return `

📨

No mailboxes yet

Create a shared mailbox to get started

`; + return ` +
+

📨

+

No mailboxes yet

+

+ Create a shared mailbox for your team. Members can triage, comment on, and co-sign outgoing emails. +

+ +
`; } - return this.mailboxes.map((m) => ` -
-
-
${m.name}
- ${m.email} + return `
${this.mailboxes.map((m) => { + const demoThreads = this.demoThreads[m.slug] || []; + const threadCount = m.thread_count ?? demoThreads.length; + const unreadCount = m.unread_count ?? demoThreads.filter((t: any) => !t.is_read).length; + return ` +
+
📨
+
${m.name}
+
${m.email}
+
${m.description || "Shared mailbox"}
+
+ ${threadCount} thread${threadCount !== 1 ? "s" : ""} + ${unreadCount > 0 ? `${unreadCount} unread` : ""}
-
${m.description || "Shared mailbox"}
-
- `).join(""); +
`; + }).join("")}
`; } private renderThreads(): string { @@ -236,17 +388,23 @@ class FolkInboxClient extends HTMLElement { ${filters.map((f) => ``).join("")}
${this.threads.length === 0 - ? `
No threads
` + ? `

No threads${this.filter !== "all" ? ` with status "${this.filter}"` : ""}

` : this.threads.map((t) => `
- ${t.is_read ? "" : ``} - ${t.from_name || t.from_address || "Unknown"} - ${t.subject} ${t.comment_count > 0 ? `(${t.comment_count})` : ""} - - ${t.is_starred ? `` : ""} - ${t.status} - - ${this.timeAgo(t.received_at)} + +
+
+ ${t.from_name || t.from_address || "Unknown"} + ${this.timeAgo(t.received_at)} +
+
${t.subject}
+
${this.snippet(t.body_text)}
+
+ ${t.is_starred ? `` : ""} + ${t.status} + ${t.comment_count > 0 ? `💬 ${t.comment_count}` : ""} +
+
`).join("")} @@ -262,49 +420,193 @@ class FolkInboxClient extends HTMLElement {
${t.subject}
- From: ${t.from_name || ""} <${t.from_address || "unknown"}> - · ${this.timeAgo(t.received_at)} - · ${t.status} + From: ${t.from_name || ""} <${t.from_address || "unknown"}> + · + ${this.timeAgo(t.received_at)} + · + ${t.status} + ${t.is_starred ? `` : ""}
${t.body_text || t.body_html || "(no content)"}
-
Comments (${comments.length})
+
💬 Internal Comments (${comments.length})
${comments.map((cm: any) => `
- ${cm.username || "Anonymous"} - ${this.timeAgo(cm.created_at)} +
+ ${cm.username || "Anonymous"} + ${this.timeAgo(cm.created_at)} +
${cm.body}
`).join("")} - ${comments.length === 0 ? `
No comments yet
` : ""} + ${comments.length === 0 ? `
No comments yet — discuss this thread with your team before replying.
` : ""}
`; } private renderApprovals(): string { - if (this.approvals.length === 0) { - return `

No pending approvals

`; + const composeForm = this.composeOpen ? ` +
+

✎ Draft for Multi-Sig Approval

+
+ + +
+
+ + +
+
+ + +
+
+ 🔒 This email requires 2 of 3 team member signatures before it will be sent. +
+
+ + +
+
+ ` : ""; + + if (this.approvals.length === 0 && !this.composeOpen) { + return ` + +
+

+

No pending approvals

+

+ When a team member drafts an outgoing email, it appears here for multi-sig approval. + Collect the required signatures before it sends. +

+
`; } - return this.approvals.map((a) => ` -
-
-
${a.subject}
- ${a.status} + + const cards = this.approvals.map((a) => { + const pct = a.required_signatures > 0 ? Math.min(100, Math.round((a.signature_count / a.required_signatures) * 100)) : 0; + const statusClass = a.status.toLowerCase(); + const signers = (a.signers || []).map((s: any) => { + const initials = (s.name || "?").split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase(); + const isSigned = s.vote === "APPROVE"; + return ` +
+
${initials}
+ ${s.name} + + ${isSigned ? "✓ Signed " + this.timeAgo(s.signed_at) : "Awaiting signature"} + +
`; + }).join(""); + + return ` +
+
+
${a.subject}
+ ${a.status}
-
- ${a.signature_count || 0} / ${a.required_signatures} signatures - · ${this.timeAgo(a.created_at)} +
To: ${(a.to_addresses || []).map((e: string) => `${e}`).join(", ")}
+
${this.timeAgo(a.created_at)}
+ ${a.body_text ? `
${this.snippet(a.body_text, 200)}
` : ""} +
+
+ ${a.signature_count || 0} / ${a.required_signatures} signatures
+ ${signers ? `
${signers}
` : ""} ${a.status === "PENDING" ? `
- - + +
` : ""} +
`; + }).join(""); + + return ` + + ${composeForm} + ${cards} + `; + } + + private renderHelp(): string { + return ` +
+
+ +
📨 rInbox Guide
+
+ Collaborative email with multi-sig approval workflows. + Shared mailboxes for your team where outgoing emails require collective sign-off. +
+ +
+
+
📨
+

Shared Mailboxes

+

Create team mailboxes that multiple members can read, triage, and respond to with role-based access.

+
+
+
💬
+

Threaded Comments

+

Discuss emails internally before replying. @mention teammates and coordinate responses.

+
+
+
+

Multi-Sig Approval

+

Outgoing emails require M-of-N signatures. Board votes and treasury approvals built into email.

+
+
+
🔄
+

IMAP Sync

+

Connect any IMAP mailbox. Server-side polling syncs emails automatically every 30 seconds.

+
+
+ +
+

How It Works

+
+
+ 1 + Create a mailbox — set up a shared inbox for your team with an email address and invite members. +
+
+ 2 + Triage & discuss — incoming emails sync via IMAP. Team members triage threads, leave comments, and star important messages. +
+
+ 3 + Approve & send — draft a reply, then collect the required signatures. Once the threshold is met, the email sends. +
+
+
+ +
+

Use Cases

+
+
+ + Governance — board decisions require 3-of-5 signers before the email sends. +
+
+ 💰 + Treasury — 2-of-3 finance team co-sign payment authorizations. Bridges email to on-chain wallets. +
+
+ 🔎 + Audit Trails — every email is co-signed with cryptographic proof of who approved what. +
+
+
+ + +
- `).join(""); + `; } private bindEvents() { @@ -341,6 +643,23 @@ class FolkInboxClient extends HTMLElement { }); } + // Help + this.shadow.querySelectorAll("[data-action='help']").forEach((btn) => { + btn.addEventListener("click", () => { this.helpOpen = true; this.render(); }); + }); + const helpOverlay = this.shadow.querySelector("[data-action='close-help-overlay']"); + if (helpOverlay) { + helpOverlay.addEventListener("click", (e) => { + if ((e.target as HTMLElement).dataset.action === "close-help-overlay") { + this.helpOpen = false; this.render(); + } + }); + } + const helpClose = this.shadow.querySelector("[data-action='close-help']"); + if (helpClose) { + helpClose.addEventListener("click", () => { this.helpOpen = false; this.render(); }); + } + // Mailbox click this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => { card.addEventListener("click", () => { @@ -368,6 +687,40 @@ class FolkInboxClient extends HTMLElement { }); }); + // Compose + const composeBtn = this.shadow.querySelector("[data-action='compose']"); + if (composeBtn) { + composeBtn.addEventListener("click", () => { this.composeOpen = !this.composeOpen; this.render(); }); + } + const cancelCompose = this.shadow.querySelector("[data-action='cancel-compose']"); + if (cancelCompose) { + cancelCompose.addEventListener("click", () => { this.composeOpen = false; this.render(); }); + } + const submitCompose = this.shadow.querySelector("[data-action='submit-compose']"); + if (submitCompose) { + submitCompose.addEventListener("click", async () => { + if (this.space === "demo") { + alert("Compose is disabled in demo mode. In a live space, this would submit the email for team approval."); + return; + } + const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim(); + const subject = (this.shadow.getElementById("compose-subject") as HTMLInputElement)?.value.trim(); + const body = (this.shadow.getElementById("compose-body") as HTMLTextAreaElement)?.value.trim(); + if (!to || !subject || !body) { alert("Please fill all fields."); return; } + try { + const base = window.location.pathname.replace(/\/$/, ""); + const mailbox = this.currentMailbox?.slug || ""; + await fetch(`${base}/api/approvals`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ to_addresses: [to], subject, body_text: body, mailbox }), + }); + this.composeOpen = false; + this.loadApprovals(); + } catch { alert("Failed to submit. Please try again."); } + }); + } + // Approval actions this.shadow.querySelectorAll("[data-approve]").forEach((btn) => { btn.addEventListener("click", async () => { diff --git a/modules/rinbox/landing.ts b/modules/rinbox/landing.ts index 113f965..e4c86c1 100644 --- a/modules/rinbox/landing.ts +++ b/modules/rinbox/landing.ts @@ -1,183 +1,146 @@ /** - * rInbox (rChats) landing page — encrypted community chat. - * Ported from rchats-online Next.js page.tsx (shadcn/ui + Lucide). + * rInbox landing page — collaborative email with multisig approval. */ export function renderLanding(): string { return `
- Part of the rSpace Ecosystem -

Encrypted
- Community Chat + rInbox +

Collaborative Email
+ with Multi-Sig Approval

- Secure messaging powered by EncryptID passkeys. - No passwords, no seed phrases — just hardware-backed encryption and - full data ownership for your community. + Shared mailboxes for your team with threaded comments, + M-of-N approval workflows, and + IMAP sync — all powered by EncryptID passkeys.

- -
+ +
-
- How It Works -

Secure Chat in 30 Seconds

-

- Create a passkey on your device, - join a community, and - chat with end-to-end encryption. -

-
- +

What rInbox Does

-
-
-
- -
-

1. Create a Passkey

-
-

- Register with EncryptID using your device's biometrics or security key. - No passwords to remember or leak. - Your keys never leave your device. -

+
+
📨
+

Shared Mailboxes

+

Create team and project mailboxes that multiple members can read, triage, and respond to. Role-based access controls who sees what.

- -
-
-
- -
-

2. Join a Community

-
-

- Create or join a community chat space. Invite members with role-based - access: viewers, participants, moderators, and admins. - One identity across all r* apps. -

+
+
💬
+

Threaded Comments

+

Discuss emails internally before replying. @mention teammates, leave notes on threads, and coordinate responses as a team.

- -
-
-
- -
-

3. Chat Securely

-
-

- Messages are encrypted with keys derived from your passkey. - Only community members can read them. - True end-to-end encryption. -

+
+
+

Multi-Sig Approval

+

Outgoing emails require M-of-N signatures before sending. Board votes, treasury approvals, and governance decisions built into email.

- -
+ +
-

Built on Self-Sovereign Identity

-

Security without compromise, powered by EncryptID

- -
-
-
- -
-

End-to-End Encryption

-

AES-256-GCM encryption with keys derived from your passkey. Messages are unreadable to the server.

+

How It Works

+
+
+
1
+

Create a Mailbox

+

Set up a shared mailbox (e.g. team@yourspace.rspace.online) and invite your team members with the roles they need.

- -
-
- -
-

Passkey Authentication

-

WebAuthn passkeys backed by biometrics or hardware security keys. Phishing-resistant by design.

+
+
2
+

Triage & Discuss

+

Incoming emails sync via IMAP. Team members triage threads, leave internal comments, star important messages, and assign owners.

- -
-
- -
-

Community Spaces

-

Create isolated chat spaces with role-based access. Viewer, participant, moderator, and admin roles.

-
- -
-
- -
-

Cross-App SSO

-

One identity across rSpace, rVote, rMaps, rWork, and the full r* ecosystem via EncryptID.

+
+
3
+

Approve & Send

+

Draft a reply, then collect the required number of signatures. Once the threshold is met, the email sends automatically.

+
+
- -
-
-
- -
-

Social Recovery

-

No seed phrases. Designate trusted guardians who can help you recover access to your account.

+ +
+
+

What Can You Do With a Multi-Sig Inbox?

+

When outbound email requires collective approval, new coordination patterns become possible.

+
+
+

Governance & Resolutions

+

Board decisions require 3-of-5 signers before the email sends. The message is the vote — no separate tooling needed.

- -
-
- -
-

No Passwords

-

Passkeys replace passwords entirely. Nothing to leak, nothing to forget, nothing to phish.

+
+

Escrow & Conditional Release

+

Hold sensitive documents in an inbox that only unlocks when N parties agree. Mediation where neither side can act alone.

- -
-
- -
-

Self-Sovereign

-

You own your identity and encryption keys. No platform lock-in, no central authority.

+
+

Treasury & Payments

+

Invoice arrives, 2-of-3 finance team co-sign the reply authorizing payment. Bridges email to on-chain wallets via Gnosis Safe.

+
+

Tamper-Proof Audit Trails

+

Every email read and sent is co-signed. Cryptographic proof of who approved what, when. Built for compliance.

+
+
+

Whistleblower Coordination

+

Evidence requires M-of-N co-signers before release. Nobody goes first alone.

+
+
+

Social Key Recovery

+

Lost access? 3-of-5 trusted contacts co-sign your restoration. No phone number, no backup email — a trust network.

+
+
+
+
+ +
+
+

Under the Hood

+
-
- -
-

rSpace Ecosystem

-

Chat integrates with rSpace canvas, rVote governance, rFunds treasury, and more.

+
🔒
+

EncryptID Auth

+

Passkey-based authentication. No passwords, no seed phrases. Hardware-backed biometric login.

+
+
+
🔄
+

IMAP Sync

+

Connect any IMAP mailbox. Server-side polling syncs emails every 30 seconds with UID tracking.

+
+
+
🔗
+

Gnosis Safe

+

Approval workflows can tie to on-chain Gnosis Safe transactions for verifiable multi-sig.

+
+
+
🌐
+

Local-First

+

Automerge CRDT sync keeps mailbox state available offline. Changes merge automatically.

-
-
-
-
-
-
- Try EncryptID -

See it in action

-

- Try the interactive EncryptID demo — register a passkey, derive encryption keys, - and test signing and encryption right in your browser. No account needed. -

- -
+
+
+

Email, reimagined for teams.

+

Try the demo or create a space to get started.

+
diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index dda8054..6fdd868 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -545,6 +545,7 @@ export const notesModule: RSpaceModule = { }, ], + seedTemplate: seedDemoIfEmpty, async onInit({ syncServer }) { _syncServer = syncServer; diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 208100e..04cd377 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -450,6 +450,76 @@ routes.get("/", (c) => { })); }); +// ── Seed template data ── + +function seedTemplateTrips(space: string) { + if (!_syncServer) return; + // Skip if space already has trips + const prefix = `${space}:trips:trips:`; + const existing = _syncServer.listDocs().filter((id) => id.startsWith(prefix)); + if (existing.length > 0) return; + + const tripId = crypto.randomUUID(); + const docId = tripDocId(space, tripId); + const now = Date.now(); + + const dest1Id = crypto.randomUUID(); + const dest2Id = crypto.randomUUID(); + + const doc = Automerge.change(Automerge.init(), 'seed template trip', (d) => { + d.meta = { module: 'trips', collection: 'trips', version: 1, spaceSlug: space, createdAt: now }; + d.trip = { + id: tripId, title: 'Mediterranean Design Sprint', slug: 'mediterranean-design-sprint', + description: 'A 5-day design sprint visiting Barcelona and Athens to prototype cosmolocal tools with local communities.', + startDate: '2026-04-14', endDate: '2026-04-18', budgetTotal: 3200, budgetCurrency: 'EUR', + status: 'planning', createdBy: 'did:demo:seed', createdAt: now, updatedAt: now, + }; + d.destinations = {}; + d.destinations[dest1Id] = { + id: dest1Id, tripId, name: 'Barcelona', country: 'Spain', + lat: 41.3874, lng: 2.1686, arrivalDate: '2026-04-14', departureDate: '2026-04-16', + notes: 'Meet with local maker space — prototype cosmolocal print workflow.', sortOrder: 0, createdAt: now, + }; + d.destinations[dest2Id] = { + id: dest2Id, tripId, name: 'Athens', country: 'Greece', + lat: 37.9838, lng: 23.7275, arrivalDate: '2026-04-16', departureDate: '2026-04-18', + notes: 'Commons Fest workshop — present rFunds river visualization.', sortOrder: 1, createdAt: now, + }; + d.itinerary = {}; + const itin = [ + { destId: dest1Id, title: 'Maker Space Visit', category: 'meeting', date: '2026-04-14', start: '10:00', end: '13:00', notes: 'Tour facilities, discuss print capabilities' }, + { destId: dest1Id, title: 'Prototype Session', category: 'workshop', date: '2026-04-15', start: '09:00', end: '17:00', notes: 'Full-day sprint on cosmolocal order flow' }, + { destId: dest2Id, title: 'Commons Fest Presentation', category: 'conference', date: '2026-04-17', start: '14:00', end: '16:00', notes: 'Present rFunds + rVote governance tools' }, + ]; + for (let i = 0; i < itin.length; i++) { + const iId = crypto.randomUUID(); + d.itinerary[iId] = { + id: iId, tripId, destinationId: itin[i].destId, title: itin[i].title, + category: itin[i].category, date: itin[i].date, startTime: itin[i].start, + endTime: itin[i].end, notes: itin[i].notes, sortOrder: i, createdAt: now, + }; + } + d.bookings = {}; + const book = [ + { type: 'flight', provider: 'Vueling', conf: 'VY-4821', cost: 180, start: '2026-04-14', end: '2026-04-14', notes: 'BCN arrival 09:30' }, + { type: 'hotel', provider: 'Hotel Catalonia', conf: 'HC-9912', cost: 320, start: '2026-04-14', end: '2026-04-16', notes: '2 nights, old town' }, + ]; + for (const b of book) { + const bId = crypto.randomUUID(); + d.bookings[bId] = { + id: bId, tripId, type: b.type, provider: b.provider, confirmationNumber: b.conf, + cost: b.cost, currency: 'EUR', startDate: b.start, endDate: b.end, + status: 'confirmed', notes: b.notes, createdAt: now, + }; + } + d.expenses = {}; + d.packingItems = {}; + }); + + _syncServer.setDoc(docId, doc); + console.log(`[Trips] Template seeded for "${space}": 1 trip, 2 destinations, 3 itinerary, 2 bookings`); +} + export const tripsModule: RSpaceModule = { id: "rtrips", name: "rTrips", @@ -459,6 +529,7 @@ export const tripsModule: RSpaceModule = { docSchemas: [{ pattern: '{space}:trips:trips:{tripId}', description: 'Trip with destinations and itinerary', init: tripSchema.init }], routes, landingPage: renderLanding, + seedTemplate: seedTemplateTrips, async onInit(ctx) { _syncServer = ctx.syncServer; }, diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index b507e7b..a37dd6c 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -185,13 +185,15 @@ function newId(): string { } // ── Seed demo data into Automerge ── -function seedDemoIfEmpty() { - const existingSpaces = listSpaceConfigDocs(); - if (existingSpaces.length > 0) return; +function seedDemoIfEmpty(space: string = 'community') { + if (!_syncServer) return; + // If this space already has proposals, skip + if (listProposalDocs(space).length > 0) return; - // Create demo space - const spaceDoc = ensureSpaceConfigDoc('community'); - _syncServer!.changeDoc(spaceConfigDocId('community'), 'seed space config', (d) => { + // Ensure space config exists + ensureSpaceConfigDoc(space); + _syncServer!.changeDoc(spaceConfigDocId(space), 'seed space config', (d) => { + if (d.spaceConfig!.name) return; // already configured d.spaceConfig!.name = 'Community Governance'; d.spaceConfig!.description = 'Proposals for the rSpace ecosystem'; d.spaceConfig!.ownerDid = 'did:demo:seed'; @@ -215,14 +217,14 @@ function seedDemoIfEmpty() { for (const p of proposals) { const pid = newId(); - const docId = proposalDocId('community', pid); + const docId = proposalDocId(space, pid); let doc = Automerge.change(Automerge.init(), 'seed proposal', (d) => { const init = proposalSchema.init(); Object.assign(d, init); - d.meta.spaceSlug = 'community'; + d.meta.spaceSlug = space; d.proposal.id = pid; - d.proposal.spaceSlug = 'community'; + d.proposal.spaceSlug = space; d.proposal.authorId = demoUserId; d.proposal.title = p.title; d.proposal.description = p.desc; @@ -255,7 +257,7 @@ function seedDemoIfEmpty() { _syncServer!.setDoc(docId, doc); } - console.log("[Vote] Demo data seeded: 1 space, 5 proposals"); + console.log(`[Vote] Demo data seeded for "${space}": 1 space config, 5 proposals`); } // ── Spaces API ── @@ -529,6 +531,7 @@ export const voteModule: RSpaceModule = { routes, standaloneDomain: "rvote.online", landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty(); diff --git a/modules/rwork/mod.ts b/modules/rwork/mod.ts index d2ef0c5..45fa09f 100644 --- a/modules/rwork/mod.ts +++ b/modules/rwork/mod.ts @@ -55,14 +55,14 @@ function getBoardDocIds(space: string): string[] { } /** - * Seed demo data if no boards exist yet. + * Seed demo data if no boards exist for the given space. */ -function seedDemoIfEmpty() { - // Check if any work boards exist at all - const allWorkDocs = _syncServer!.getDocIds().filter((id) => id.includes(':work:boards:')); - if (allWorkDocs.length > 0) return; +function seedDemoIfEmpty(space: string = 'rspace-dev') { + if (!_syncServer) return; + // Check if this space already has work boards + const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`)); + if (spaceWorkDocs.length > 0) return; - const space = 'rspace-dev'; const docId = boardDocId(space, space); const doc = Automerge.change(Automerge.init(), 'seed demo board', (d) => { @@ -109,7 +109,7 @@ function seedDemoIfEmpty() { }); _syncServer!.setDoc(docId, doc); - console.log("[Work] Demo data seeded: 1 board, 11 tasks"); + console.log(`[Work] Demo data seeded for "${space}": 1 board, 11 tasks`); } // ── API: Spaces (Boards) ── @@ -415,6 +415,7 @@ export const workModule: RSpaceModule = { routes, standaloneDomain: "rwork.online", landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty(); diff --git a/server/index.ts b/server/index.ts index d96acdd..eb7bf3e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -42,7 +42,7 @@ import { import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; // ── Module system ── -import { registerModule, getAllModules, getModuleInfoList } from "../shared/module"; +import { registerModule, getAllModules, getModuleInfoList, getModule } from "../shared/module"; import { canvasModule } from "../modules/rspace/mod"; import { booksModule } from "../modules/rbooks/mod"; import { pubsModule } from "../modules/rpubs/mod"; @@ -1191,6 +1191,9 @@ app.get("/:space/:moduleId/template", async (c) => { broadcastAutomergeSync(space); broadcastJsonSnapshot(space); } + // Seed module-specific demo data (calendar events, notes, tasks, etc.) + const mod = getModule(moduleId); + if (mod?.seedTemplate) mod.seedTemplate(space); } catch (e) { console.error(`[Template] On-demand seed failed for "${space}":`, e); } @@ -1731,6 +1734,7 @@ const server = Bun.serve({ // Template seeding: /{moduleId}/template → seed + redirect if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") { + const moduleId = pathSegments[0].toLowerCase(); try { await loadCommunity(subdomain); const seeded = seedTemplateShapes(subdomain); @@ -1738,6 +1742,9 @@ const server = Bun.serve({ broadcastAutomergeSync(subdomain); broadcastJsonSnapshot(subdomain); } + // Seed module-specific demo data + const mod = getModule(moduleId); + if (mod?.seedTemplate) mod.seedTemplate(subdomain); } catch (e) { console.error(`[Template] On-demand seed failed for "${subdomain}":`, e); } diff --git a/shared/module.ts b/shared/module.ts index ed12f28..13b6214 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -105,6 +105,9 @@ export interface RSpaceModule { url: string; name: string; }; + + /** Seed template/demo data for a space. Called by /template route. */ + seedTemplate?: (space: string) => void; } /** Registry of all loaded modules */