Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-02 22:05:52 -08:00
commit 2c95c8c57c
10 changed files with 676 additions and 227 deletions

View File

@ -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<BooksCatalogDoc>(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)");

View File

@ -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

View File

@ -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<string, any[]> = {
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 = `
<style>
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: #e2e8f0; }
.container { max-width: 1000px; margin: 0 auto; }
/* Nav */
.rapp-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.nav-btn { padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; }
.nav-btn { padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; }
.nav-btn.active { background: #6366f1; color: white; border-color: #6366f1; }
.card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; cursor: pointer; transition: border-color 0.2s; }
.card:hover { border-color: #6366f1; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; }
.card-title { font-weight: 600; font-size: 0.95rem; }
.card-meta { font-size: 0.75rem; color: #64748b; }
.card-desc { font-size: 0.85rem; color: #94a3b8; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
.nav-btn:hover:not(.active) { border-color: #475569; color: #e2e8f0; }
.nav-spacer { flex: 1; }
.help-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(99,102,241,0.3); background: rgba(99,102,241,0.1); color: #818cf8; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; }
.help-btn:hover { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.5); }
/* Mailbox cards */
.mailbox-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 0.75rem; }
.mailbox-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; cursor: pointer; transition: all 0.2s; }
.mailbox-card:hover { border-color: #6366f1; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99,102,241,0.15); }
.mailbox-icon { font-size: 1.5rem; margin-bottom: 0.75rem; }
.mailbox-name { font-weight: 600; font-size: 1rem; margin-bottom: 0.25rem; }
.mailbox-email { font-size: 0.8rem; color: #6366f1; margin-bottom: 0.5rem; font-family: monospace; }
.mailbox-desc { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; margin-bottom: 0.75rem; }
.mailbox-stats { display: flex; gap: 1rem; font-size: 0.75rem; color: #64748b; }
.mailbox-stats .unread { color: #818cf8; font-weight: 600; }
/* Thread list */
.inbox-list { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; overflow: hidden; }
.filter-bar { display: flex; gap: 0.25rem; padding: 0.75rem 1rem; border-bottom: 1px solid #1e293b; }
.filter-btn { padding: 0.25rem 0.75rem; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; }
.filter-btn:hover { color: #94a3b8; }
.filter-btn.active { background: rgba(99,102,241,0.15); color: #818cf8; }
.thread-row { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.85rem 1rem; border-bottom: 1px solid rgba(30,41,59,0.5); cursor: pointer; transition: background 0.15s; }
.thread-row:last-child { border-bottom: none; }
.thread-row:hover { background: rgba(51,65,85,0.25); }
.thread-row.unread .thread-subject { font-weight: 600; color: #f1f5f9; }
.thread-dot { width: 8px; height: 8px; border-radius: 50%; background: #6366f1; flex-shrink: 0; margin-top: 0.45rem; }
.thread-dot.read { background: transparent; }
.thread-content { flex: 1; min-width: 0; }
.thread-top { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; margin-bottom: 0.15rem; }
.thread-from { font-size: 0.85rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread-time { font-size: 0.7rem; color: #64748b; flex-shrink: 0; }
.thread-subject { font-size: 0.85rem; color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 0.15rem; }
.thread-snippet { font-size: 0.75rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread-tags { display: flex; gap: 0.35rem; margin-top: 0.35rem; align-items: center; flex-wrap: wrap; }
/* Badges */
.badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 4px; font-size: 0.65rem; font-weight: 600; }
.badge-open { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-snoozed { background: rgba(251,191,36,0.15); color: #fbbf24; }
.badge-closed { background: rgba(100,116,139,0.15); color: #94a3b8; }
.badge-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; }
.thread-row { display: flex; gap: 1rem; align-items: flex-start; padding: 0.75rem 1rem; border-bottom: 1px solid #1e293b; cursor: pointer; }
.thread-row:hover { background: rgba(51,65,85,0.3); }
.thread-row.unread { font-weight: 600; }
.thread-from { width: 180px; font-size: 0.85rem; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread-subject { flex: 1; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread-time { font-size: 0.75rem; color: #64748b; flex-shrink: 0; }
.thread-indicators { display: flex; gap: 0.25rem; flex-shrink: 0; }
.star { color: #fbbf24; font-size: 0.75rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #6366f1; flex-shrink: 0; margin-top: 0.35rem; }
.inbox-list { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; overflow: hidden; }
.filter-bar { display: flex; gap: 0.25rem; padding: 0.75rem 1rem; border-bottom: 1px solid #1e293b; }
.filter-btn { padding: 0.25rem 0.75rem; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 0.75rem; }
.filter-btn.active { background: rgba(99,102,241,0.15); color: #818cf8; }
.badge-comments { background: rgba(99,102,241,0.15); color: #818cf8; }
.star { color: #fbbf24; font-size: 0.7rem; }
/* Thread detail */
.detail-panel { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.5rem; }
.detail-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #1e293b; }
.detail-subject { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
.detail-meta { font-size: 0.8rem; color: #64748b; }
.detail-meta { font-size: 0.8rem; color: #64748b; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.detail-body { font-size: 0.9rem; line-height: 1.6; color: #cbd5e1; margin-bottom: 1.5rem; white-space: pre-wrap; }
.comments-section { border-top: 1px solid #1e293b; padding-top: 1rem; }
.comments-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: #94a3b8; }
.comment { padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 0.5rem; }
.comment { padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 0.5rem; border-left: 3px solid #334155; }
.comment-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.25rem; }
.comment-author { font-size: 0.8rem; font-weight: 600; color: #818cf8; }
.comment-body { font-size: 0.85rem; color: #cbd5e1; margin-top: 0.25rem; }
.comment-time { font-size: 0.7rem; color: #475569; margin-top: 0.25rem; }
.comment-body { font-size: 0.85rem; color: #cbd5e1; line-height: 1.5; }
.comment-time { font-size: 0.7rem; color: #475569; }
.empty { text-align: center; padding: 3rem; color: #64748b; }
.approval-card { padding: 1rem; margin-bottom: 0.75rem; }
/* Approval cards */
.approval-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; border-left: 4px solid #334155; transition: border-color 0.2s; }
.approval-card.status-pending { border-left-color: #fbbf24; }
.approval-card.status-approved { border-left-color: #22c55e; }
.approval-card.status-rejected { border-left-color: #ef4444; }
.approval-to { font-size: 0.8rem; color: #64748b; margin-bottom: 0.5rem; }
.approval-to code { font-size: 0.75rem; color: #818cf8; background: rgba(99,102,241,0.1); padding: 0.1rem 0.4rem; border-radius: 4px; }
.approval-body-preview { font-size: 0.8rem; color: #94a3b8; line-height: 1.5; padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin: 0.5rem 0; border-left: 3px solid #334155; max-height: 4.5em; overflow: hidden; }
.approval-progress { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; }
.progress-bar { flex: 1; height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
.progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.progress-fill.approved { background: linear-gradient(90deg, #22c55e, #16a34a); }
.progress-text { font-size: 0.75rem; color: #94a3b8; flex-shrink: 0; }
.signer-list { margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.35rem; }
.signer-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
.signer-avatar { width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 700; color: white; flex-shrink: 0; }
.signer-avatar.signed { background: #22c55e; }
.signer-avatar.waiting { background: #334155; }
.signer-name { flex: 1; }
.signer-name.signed { color: #e2e8f0; }
.signer-name.waiting { color: #64748b; }
.signer-status { font-size: 0.7rem; font-weight: 600; }
.signer-status.signed { color: #4ade80; }
.signer-status.waiting { color: #64748b; }
.approval-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn-approve { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #22c55e; color: white; cursor: pointer; font-size: 0.8rem; }
.btn-reject { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #ef4444; color: white; cursor: pointer; font-size: 0.8rem; }
.btn-approve { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #22c55e; color: white; cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: background 0.15s; }
.btn-approve:hover { background: #16a34a; }
.btn-reject { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #ef4444; color: white; cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: background 0.15s; }
.btn-reject:hover { background: #dc2626; }
.btn-compose { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #6366f1; background: rgba(99,102,241,0.1); color: #818cf8; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.15s; margin-bottom: 1rem; }
.btn-compose:hover { background: rgba(99,102,241,0.2); border-color: #818cf8; }
/* Compose form */
.compose-panel { background: rgba(15,23,42,0.5); border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
.compose-panel h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
.compose-field { margin-bottom: 0.75rem; }
.compose-field label { display: block; font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.25rem; font-weight: 500; }
.compose-field input, .compose-field textarea { width: 100%; background: rgba(0,0,0,0.3); border: 1px solid #334155; border-radius: 8px; padding: 0.5rem 0.75rem; color: #e2e8f0; font-size: 0.85rem; font-family: inherit; outline: none; transition: border-color 0.15s; box-sizing: border-box; }
.compose-field input:focus, .compose-field textarea:focus { border-color: #6366f1; }
.compose-field textarea { resize: vertical; min-height: 100px; }
.compose-threshold { font-size: 0.8rem; color: #94a3b8; padding: 0.6rem 0.75rem; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; margin-bottom: 0.75rem; }
.compose-actions { display: flex; gap: 0.5rem; }
.btn-submit { padding: 0.5rem 1.25rem; border-radius: 8px; border: none; background: linear-gradient(135deg, #6366f1, #0891b2); color: white; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: opacity 0.15s; }
.btn-submit:hover { opacity: 0.9; }
.btn-cancel { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; }
.btn-cancel:hover { border-color: #475569; color: #e2e8f0; }
/* Help panel */
.help-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.help-panel { background: #0f172a; border: 1px solid #334155; border-radius: 16px; max-width: 640px; width: 100%; max-height: 80vh; overflow-y: auto; padding: 2rem; position: relative; }
.help-close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; color: #64748b; cursor: pointer; font-size: 1.25rem; padding: 4px 8px; border-radius: 6px; }
.help-close:hover { color: #e2e8f0; background: rgba(255,255,255,0.05); }
.help-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; }
.help-subtitle { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; line-height: 1.5; }
.help-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1.5rem; }
.help-card { background: rgba(30,41,59,0.5); border: 1px solid #334155; border-radius: 10px; padding: 1rem; }
.help-card-icon { font-size: 1.25rem; margin-bottom: 0.5rem; }
.help-card h4 { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.25rem; }
.help-card p { font-size: 0.75rem; color: #94a3b8; line-height: 1.4; }
.help-section { margin-bottom: 1.25rem; }
.help-section h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; color: #e2e8f0; }
.help-steps { display: flex; flex-direction: column; gap: 0.5rem; }
.help-step { display: flex; gap: 0.75rem; align-items: flex-start; }
.help-step-num { width: 24px; height: 24px; border-radius: 50%; background: #6366f1; color: white; font-size: 0.7rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.help-step-text { font-size: 0.8rem; color: #cbd5e1; line-height: 1.5; }
.help-cta { display: inline-block; padding: 0.6rem 1.5rem; background: linear-gradient(135deg, #6366f1, #0891b2); color: white; border-radius: 8px; text-decoration: none; font-size: 0.85rem; font-weight: 600; }
@media (max-width: 600px) {
.thread-from { width: auto; max-width: 100px; }
.mailbox-grid { grid-template-columns: 1fr; }
.thread-from { max-width: 100px; }
.thread-row { flex-wrap: wrap; gap: 4px; }
.help-grid { grid-template-columns: 1fr; }
}
</style>
<div class="container">
${this.renderNav()}
${this.renderView()}
${this.helpOpen ? this.renderHelp() : ""}
</div>
`;
this.bindEvents();
@ -199,6 +334,8 @@ class FolkInboxClient extends HTMLElement {
<div class="rapp-nav">
${this.view !== "mailboxes" ? `<button class="rapp-nav__back" data-action="back">&larr;</button>` : ""}
${items.map((i) => `<button class="nav-btn ${this.view === i.id ? "active" : ""}" data-nav="${i.id}">${i.label}</button>`).join("")}
<span class="nav-spacer"></span>
<button class="help-btn" data-action="help">? Guide</button>
</div>
`;
}
@ -215,17 +352,32 @@ class FolkInboxClient extends HTMLElement {
private renderMailboxes(): string {
if (this.mailboxes.length === 0) {
return `<div class="empty"><p style="font-size:2rem;margin-bottom:1rem">&#128232;</p><p>No mailboxes yet</p><p style="font-size:0.8rem;color:#475569;margin-top:0.5rem">Create a shared mailbox to get started</p></div>`;
return `
<div class="empty">
<p style="font-size:2.5rem;margin-bottom:1rem">&#128232;</p>
<p style="font-size:1.1rem;color:#e2e8f0;font-weight:500;margin-bottom:0.5rem">No mailboxes yet</p>
<p style="font-size:0.85rem;color:#64748b;max-width:400px;margin:0 auto 1.5rem;line-height:1.5">
Create a shared mailbox for your team. Members can triage, comment on, and co-sign outgoing emails.
</p>
<button class="help-btn" data-action="help" style="font-size:0.85rem;padding:0.5rem 1.25rem">Learn how rInbox works</button>
</div>`;
}
return this.mailboxes.map((m) => `
<div class="card" data-mailbox="${m.slug}">
<div class="card-header">
<div class="card-title">${m.name}</div>
<span class="card-meta">${m.email}</span>
return `<div class="mailbox-grid">${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 `
<div class="mailbox-card" data-mailbox="${m.slug}">
<div class="mailbox-icon">&#128232;</div>
<div class="mailbox-name">${m.name}</div>
<div class="mailbox-email">${m.email}</div>
<div class="mailbox-desc">${m.description || "Shared mailbox"}</div>
<div class="mailbox-stats">
<span>${threadCount} thread${threadCount !== 1 ? "s" : ""}</span>
${unreadCount > 0 ? `<span class="unread">${unreadCount} unread</span>` : ""}
</div>
<div class="card-desc">${m.description || "Shared mailbox"}</div>
</div>
`).join("");
</div>`;
}).join("")}</div>`;
}
private renderThreads(): string {
@ -236,17 +388,23 @@ class FolkInboxClient extends HTMLElement {
${filters.map((f) => `<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${f.charAt(0).toUpperCase() + f.slice(1)}</button>`).join("")}
</div>
${this.threads.length === 0
? `<div class="empty">No threads</div>`
? `<div class="empty"><p>No threads${this.filter !== "all" ? ` with status "${this.filter}"` : ""}</p></div>`
: this.threads.map((t) => `
<div class="thread-row ${t.is_read ? "" : "unread"}" data-thread="${t.id}">
${t.is_read ? "" : `<span class="dot"></span>`}
<span class="thread-from">${t.from_name || t.from_address || "Unknown"}</span>
<span class="thread-subject">${t.subject} ${t.comment_count > 0 ? `<span style="color:#64748b;font-weight:400">(${t.comment_count})</span>` : ""}</span>
<span class="thread-indicators">
${t.is_starred ? `<span class="star">&#9733;</span>` : ""}
<span class="badge badge-${t.status}">${t.status}</span>
</span>
<span class="thread-time">${this.timeAgo(t.received_at)}</span>
<span class="thread-dot ${t.is_read ? "read" : ""}"></span>
<div class="thread-content">
<div class="thread-top">
<span class="thread-from">${t.from_name || t.from_address || "Unknown"}</span>
<span class="thread-time">${this.timeAgo(t.received_at)}</span>
</div>
<div class="thread-subject">${t.subject}</div>
<div class="thread-snippet">${this.snippet(t.body_text)}</div>
<div class="thread-tags">
${t.is_starred ? `<span class="star">&#9733;</span>` : ""}
<span class="badge badge-${t.status}">${t.status}</span>
${t.comment_count > 0 ? `<span class="badge badge-comments">&#128172; ${t.comment_count}</span>` : ""}
</div>
</div>
</div>
`).join("")}
</div>
@ -262,49 +420,193 @@ class FolkInboxClient extends HTMLElement {
<div class="detail-header">
<div class="detail-subject">${t.subject}</div>
<div class="detail-meta">
From: ${t.from_name || ""} &lt;${t.from_address || "unknown"}&gt;
&middot; ${this.timeAgo(t.received_at)}
&middot; <span class="badge badge-${t.status}">${t.status}</span>
<span>From: <strong style="color:#e2e8f0">${t.from_name || ""}</strong> &lt;${t.from_address || "unknown"}&gt;</span>
<span>&middot;</span>
<span>${this.timeAgo(t.received_at)}</span>
<span>&middot;</span>
<span class="badge badge-${t.status}">${t.status}</span>
${t.is_starred ? `<span class="star">&#9733;</span>` : ""}
</div>
</div>
<div class="detail-body">${t.body_text || t.body_html || "(no content)"}</div>
<div class="comments-section">
<div class="comments-title">Comments (${comments.length})</div>
<div class="comments-title">&#128172; Internal Comments (${comments.length})</div>
${comments.map((cm: any) => `
<div class="comment">
<span class="comment-author">${cm.username || "Anonymous"}</span>
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
<div class="comment-top">
<span class="comment-author">${cm.username || "Anonymous"}</span>
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
</div>
<div class="comment-body">${cm.body}</div>
</div>
`).join("")}
${comments.length === 0 ? `<div style="font-size:0.8rem;color:#475569">No comments yet</div>` : ""}
${comments.length === 0 ? `<div style="font-size:0.8rem;color:#475569;padding:0.75rem">No comments yet &mdash; discuss this thread with your team before replying.</div>` : ""}
</div>
</div>
`;
}
private renderApprovals(): string {
if (this.approvals.length === 0) {
return `<div class="empty"><p style="font-size:2rem;margin-bottom:1rem">&#9989;</p><p>No pending approvals</p></div>`;
const composeForm = this.composeOpen ? `
<div class="compose-panel">
<h3>&#9998; Draft for Multi-Sig Approval</h3>
<div class="compose-field">
<label>To</label>
<input id="compose-to" type="email" placeholder="recipient@example.com" />
</div>
<div class="compose-field">
<label>Subject</label>
<input id="compose-subject" type="text" placeholder="Email subject" />
</div>
<div class="compose-field">
<label>Body</label>
<textarea id="compose-body" placeholder="Write the email body. This will be reviewed and co-signed by your team before sending."></textarea>
</div>
<div class="compose-threshold">
&#128274; This email requires <strong style="color:#818cf8">2 of 3</strong> team member signatures before it will be sent.
</div>
<div class="compose-actions">
<button class="btn-submit" data-action="submit-compose">Submit for Approval</button>
<button class="btn-cancel" data-action="cancel-compose">Cancel</button>
</div>
</div>
` : "";
if (this.approvals.length === 0 && !this.composeOpen) {
return `
<button class="btn-compose" data-action="compose">&#9998; Compose for Approval</button>
<div class="empty">
<p style="font-size:2rem;margin-bottom:1rem">&#9989;</p>
<p style="font-size:1rem;color:#e2e8f0;font-weight:500;margin-bottom:0.5rem">No pending approvals</p>
<p style="font-size:0.8rem;color:#64748b;max-width:380px;margin:0 auto 1.5rem;line-height:1.5">
When a team member drafts an outgoing email, it appears here for multi-sig approval.
Collect the required signatures before it sends.
</p>
</div>`;
}
return this.approvals.map((a) => `
<div class="card approval-card">
<div class="card-header">
<div class="card-title">${a.subject}</div>
<span class="badge badge-${a.status.toLowerCase()}">${a.status}</span>
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 `
<div class="signer-row">
<div class="signer-avatar ${isSigned ? "signed" : "waiting"}">${initials}</div>
<span class="signer-name ${isSigned ? "signed" : "waiting"}">${s.name}</span>
<span class="signer-status ${isSigned ? "signed" : "waiting"}">
${isSigned ? "&#10003; Signed " + this.timeAgo(s.signed_at) : "Awaiting signature"}
</span>
</div>`;
}).join("");
return `
<div class="approval-card status-${statusClass}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.25rem">
<div style="font-weight:600;font-size:0.95rem;flex:1;margin-right:0.5rem">${a.subject}</div>
<span class="badge badge-${statusClass}">${a.status}</span>
</div>
<div class="card-meta">
${a.signature_count || 0} / ${a.required_signatures} signatures
&middot; ${this.timeAgo(a.created_at)}
<div class="approval-to">To: ${(a.to_addresses || []).map((e: string) => `<code>${e}</code>`).join(", ")}</div>
<div style="font-size:0.75rem;color:#475569;margin-bottom:0.35rem">${this.timeAgo(a.created_at)}</div>
${a.body_text ? `<div class="approval-body-preview">${this.snippet(a.body_text, 200)}</div>` : ""}
<div class="approval-progress">
<div class="progress-bar"><div class="progress-fill ${statusClass}" style="width:${pct}%"></div></div>
<span class="progress-text">${a.signature_count || 0} / ${a.required_signatures} signatures</span>
</div>
${signers ? `<div class="signer-list">${signers}</div>` : ""}
${a.status === "PENDING" ? `
<div class="approval-actions">
<button class="btn-approve" data-approve="${a.id}">Approve</button>
<button class="btn-reject" data-reject="${a.id}">Reject</button>
<button class="btn-approve" data-approve="${a.id}">&#10003; Approve &amp; Sign</button>
<button class="btn-reject" data-reject="${a.id}">&#10007; Reject</button>
</div>
` : ""}
</div>`;
}).join("");
return `
<button class="btn-compose" data-action="compose">&#9998; Compose for Approval</button>
${composeForm}
${cards}
`;
}
private renderHelp(): string {
return `
<div class="help-overlay" data-action="close-help-overlay">
<div class="help-panel" data-help-panel>
<button class="help-close" data-action="close-help">&times;</button>
<div class="help-title">&#128232; rInbox Guide</div>
<div class="help-subtitle">
Collaborative email with multi-sig approval workflows.
Shared mailboxes for your team where outgoing emails require collective sign-off.
</div>
<div class="help-grid">
<div class="help-card">
<div class="help-card-icon">&#128232;</div>
<h4>Shared Mailboxes</h4>
<p>Create team mailboxes that multiple members can read, triage, and respond to with role-based access.</p>
</div>
<div class="help-card">
<div class="help-card-icon">&#128172;</div>
<h4>Threaded Comments</h4>
<p>Discuss emails internally before replying. @mention teammates and coordinate responses.</p>
</div>
<div class="help-card">
<div class="help-card-icon">&#9989;</div>
<h4>Multi-Sig Approval</h4>
<p>Outgoing emails require M-of-N signatures. Board votes and treasury approvals built into email.</p>
</div>
<div class="help-card">
<div class="help-card-icon">&#128260;</div>
<h4>IMAP Sync</h4>
<p>Connect any IMAP mailbox. Server-side polling syncs emails automatically every 30 seconds.</p>
</div>
</div>
<div class="help-section">
<h3>How It Works</h3>
<div class="help-steps">
<div class="help-step">
<span class="help-step-num">1</span>
<span class="help-step-text"><strong>Create a mailbox</strong> &mdash; set up a shared inbox for your team with an email address and invite members.</span>
</div>
<div class="help-step">
<span class="help-step-num">2</span>
<span class="help-step-text"><strong>Triage &amp; discuss</strong> &mdash; incoming emails sync via IMAP. Team members triage threads, leave comments, and star important messages.</span>
</div>
<div class="help-step">
<span class="help-step-num">3</span>
<span class="help-step-text"><strong>Approve &amp; send</strong> &mdash; draft a reply, then collect the required signatures. Once the threshold is met, the email sends.</span>
</div>
</div>
</div>
<div class="help-section">
<h3>Use Cases</h3>
<div class="help-steps">
<div class="help-step">
<span class="help-step-num" style="background:#22d3ee">&#9878;</span>
<span class="help-step-text"><strong>Governance</strong> &mdash; board decisions require 3-of-5 signers before the email sends.</span>
</div>
<div class="help-step">
<span class="help-step-num" style="background:#22c55e">&#128176;</span>
<span class="help-step-text"><strong>Treasury</strong> &mdash; 2-of-3 finance team co-sign payment authorizations. Bridges email to on-chain wallets.</span>
</div>
<div class="help-step">
<span class="help-step-num" style="background:#fbbf24">&#128270;</span>
<span class="help-step-text"><strong>Audit Trails</strong> &mdash; every email is co-signed with cryptographic proof of who approved what.</span>
</div>
</div>
</div>
<div style="text-align:center;margin-top:1.5rem">
<a href="/${this.space}/rinbox/about" class="help-cta">View Full Feature Page &rarr;</a>
</div>
</div>
</div>
`).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 () => {

View File

@ -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 `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline">Part of the rSpace Ecosystem</span>
<h1 class="rl-heading">Encrypted<br>
<span style="background:linear-gradient(135deg,#3b82f6,#14b8a6);-webkit-background-clip:text;background-clip:text;color:transparent">Community Chat</span>
<span class="rl-tagline">rInbox</span>
<h1 class="rl-heading">Collaborative Email<br>
<span style="background:linear-gradient(135deg,#6366f1,#0891b2);-webkit-background-clip:text;background-clip:text;color:transparent">with Multi-Sig Approval</span>
</h1>
<p class="rl-subtext">
Secure messaging powered by <strong style="color:#e2e8f0">EncryptID passkeys</strong>.
No passwords, no seed phrases &mdash; just <strong style="color:#e2e8f0">hardware-backed encryption</strong> and
full data ownership for your community.
Shared mailboxes for your team with <strong style="color:#e2e8f0">threaded comments</strong>,
<strong style="color:#e2e8f0">M-of-N approval workflows</strong>, and
<strong style="color:#e2e8f0">IMAP sync</strong> &mdash; all powered by EncryptID passkeys.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary" id="ml-primary">
Try the Demo &rarr;
</a>
<a href="https://demo.rspace.online/rinbox" class="rl-cta-secondary">Get Started</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
<!-- How It Works -->
<section class="rl-section rl-section--alt">
<!-- What rInbox Does -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:1.5rem">
<span class="rl-badge">How It Works</span>
<h2 class="rl-heading" style="margin-top:0.75rem">Secure Chat in 30 Seconds</h2>
<p class="rl-subtext">
<strong style="color:#3b82f6">Create a passkey</strong> on your device,
<strong style="color:#14b8a6">join a community</strong>, and
<strong style="color:#e2e8f0">chat with end-to-end encryption</strong>.
</p>
</div>
<h2 class="rl-heading" style="text-align:center">What rInbox Does</h2>
<div class="rl-grid-3">
<div class="rl-card" style="border:2px solid rgba(59,130,246,0.4);background:linear-gradient(135deg,rgba(59,130,246,0.1),rgba(59,130,246,0.05))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="height:2rem;width:2rem;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10V4a2 2 0 00-4 0v6"/><path d="M18 11a6 6 0 00-12 0v3"/><rect x="3" y="14" width="18" height="8" rx="2"/><line x1="12" y1="18" x2="12" y2="18"/></svg>
</div>
<h3 style="font-weight:700;font-size:1.125rem;color:#e2e8f0">1. Create a Passkey</h3>
</div>
<p style="font-size:0.875rem;color:#94a3b8;line-height:1.6">
Register with EncryptID using your device's biometrics or security key.
No passwords to remember or leak.
<strong style="color:#e2e8f0;display:block;margin-top:0.5rem">Your keys never leave your device.</strong>
</p>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#6366f1,rgba(99,102,241,0.6))">&#128232;</div>
<h3>Shared Mailboxes</h3>
<p>Create team and project mailboxes that multiple members can read, triage, and respond to. Role-based access controls who sees what.</p>
</div>
<div class="rl-card" style="border:2px solid rgba(20,184,166,0.4);background:linear-gradient(135deg,rgba(20,184,166,0.1),rgba(20,184,166,0.05))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="height:2rem;width:2rem;border-radius:50%;background:#14b8a6;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
</div>
<h3 style="font-weight:700;font-size:1.125rem;color:#e2e8f0">2. Join a Community</h3>
</div>
<p style="font-size:0.875rem;color:#94a3b8;line-height:1.6">
Create or join a community chat space. Invite members with role-based
access: viewers, participants, moderators, and admins.
<strong style="color:#e2e8f0;display:block;margin-top:0.5rem">One identity across all r* apps.</strong>
</p>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#0891b2,rgba(8,145,178,0.6))">&#128172;</div>
<h3>Threaded Comments</h3>
<p>Discuss emails internally before replying. @mention teammates, leave notes on threads, and coordinate responses as a team.</p>
</div>
<div class="rl-card" style="border:2px solid rgba(100,116,139,0.4);background:linear-gradient(135deg,rgba(100,116,139,0.1),rgba(100,116,139,0.05))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="height:2rem;width:2rem;border-radius:50%;background:#64748b;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><rect x="9" y="9" width="6" height="1" rx="0.5"/></svg>
</div>
<h3 style="font-weight:700;font-size:1.125rem;color:#e2e8f0">3. Chat Securely</h3>
</div>
<p style="font-size:0.875rem;color:#94a3b8;line-height:1.6">
Messages are encrypted with keys derived from your passkey.
Only community members can read them.
<strong style="color:#e2e8f0;display:block;margin-top:0.5rem">True end-to-end encryption.</strong>
</p>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#22c55e,#059669)">&#9989;</div>
<h3>Multi-Sig Approval</h3>
<p>Outgoing emails require M-of-N signatures before sending. Board votes, treasury approvals, and governance decisions built into email.</p>
</div>
</div>
</div>
</section>
<!-- Features Row 1 -->
<section class="rl-section">
<!-- How It Works -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Self-Sovereign Identity</h2>
<p class="rl-subtext" style="text-align:center">Security without compromise, powered by EncryptID</p>
<div class="rl-grid-4">
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#3b82f6,rgba(59,130,246,0.6))">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<h3>End-to-End Encryption</h3>
<p>AES-256-GCM encryption with keys derived from your passkey. Messages are unreadable to the server.</p>
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
<div class="rl-grid-3">
<div class="rl-step">
<div class="rl-step__num">1</div>
<h3>Create a Mailbox</h3>
<p>Set up a shared mailbox (e.g. <code style="color:#818cf8">team@yourspace.rspace.online</code>) and invite your team members with the roles they need.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#14b8a6,rgba(20,184,166,0.6))">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10V4a2 2 0 00-4 0v6"/><path d="M18 11a6 6 0 00-12 0v3"/><rect x="3" y="14" width="18" height="8" rx="2"/><line x1="12" y1="18" x2="12" y2="18"/></svg>
</div>
<h3>Passkey Authentication</h3>
<p>WebAuthn passkeys backed by biometrics or hardware security keys. Phishing-resistant by design.</p>
<div class="rl-step">
<div class="rl-step__num">2</div>
<h3>Triage &amp; Discuss</h3>
<p>Incoming emails sync via IMAP. Team members triage threads, leave internal comments, star important messages, and assign owners.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#22c55e,#059669)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
</div>
<h3>Community Spaces</h3>
<p>Create isolated chat spaces with role-based access. Viewer, participant, moderator, and admin roles.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#f97316,#d97706)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
</div>
<h3>Cross-App SSO</h3>
<p>One identity across rSpace, rVote, rMaps, rWork, and the full r* ecosystem via EncryptID.</p>
<div class="rl-step">
<div class="rl-step__num">3</div>
<h3>Approve &amp; Send</h3>
<p>Draft a reply, then collect the required number of signatures. Once the threshold is met, the email sends automatically.</p>
</div>
</div>
</div>
</section>
<!-- Features Row 2 -->
<div class="rl-grid-4" style="margin-top:1rem">
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#a855f7,#7c3aed)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.42 4.58a5.4 5.4 0 00-7.65 0l-.77.78-.77-.78a5.4 5.4 0 00-7.65 7.65l.78.77L12 20.64l7.64-7.64.78-.77a5.4 5.4 0 000-7.65z"/><path d="M12 5.36V12"/><path d="M8.5 8.5L12 12l3.5-3.5"/></svg>
</div>
<h3>Social Recovery</h3>
<p>No seed phrases. Designate trusted guardians who can help you recover access to your account.</p>
<!-- Use Cases -->
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">What Can You Do With a Multi-Sig Inbox?</h2>
<p class="rl-subtext" style="text-align:center">When outbound email requires collective approval, new coordination patterns become possible.</p>
<div class="rl-grid-3">
<div class="rl-card">
<h3 style="color:#22d3ee">Governance &amp; Resolutions</h3>
<p>Board decisions require 3-of-5 signers before the email sends. The message is the vote &mdash; no separate tooling needed.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#3b82f6,#0891b2)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
</div>
<h3>No Passwords</h3>
<p>Passkeys replace passwords entirely. Nothing to leak, nothing to forget, nothing to phish.</p>
<div class="rl-card">
<h3 style="color:#a78bfa">Escrow &amp; Conditional Release</h3>
<p>Hold sensitive documents in an inbox that only unlocks when N parties agree. Mediation where neither side can act alone.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#f43f5e,#ec4899)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<h3>Self-Sovereign</h3>
<p>You own your identity and encryption keys. No platform lock-in, no central authority.</p>
<div class="rl-card">
<h3 style="color:#4ade80">Treasury &amp; Payments</h3>
<p>Invoice arrives, 2-of-3 finance team co-sign the reply authorizing payment. Bridges email to on-chain wallets via Gnosis Safe.</p>
</div>
<div class="rl-card">
<h3 style="color:#fbbf24">Tamper-Proof Audit Trails</h3>
<p>Every email read and sent is co-signed. Cryptographic proof of who approved what, when. Built for compliance.</p>
</div>
<div class="rl-card">
<h3 style="color:#f87171">Whistleblower Coordination</h3>
<p>Evidence requires M-of-N co-signers before release. Nobody goes first alone.</p>
</div>
<div class="rl-card">
<h3 style="color:#60a5fa">Social Key Recovery</h3>
<p>Lost access? 3-of-5 trusted contacts co-sign your restoration. No phone number, no backup email &mdash; a trust network.</p>
</div>
</div>
</div>
</section>
<!-- Technical Features -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Under the Hood</h2>
<div class="rl-grid-4">
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#14b8a6,#0891b2)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><rect x="9" y="9" width="6" height="1" rx="0.5"/></svg>
</div>
<h3>rSpace Ecosystem</h3>
<p>Chat integrates with rSpace canvas, rVote governance, rFunds treasury, and more.</p>
<div class="rl-icon-box" style="background:linear-gradient(135deg,#6366f1,rgba(99,102,241,0.6))">&#128274;</div>
<h3>EncryptID Auth</h3>
<p>Passkey-based authentication. No passwords, no seed phrases. Hardware-backed biometric login.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#0891b2,rgba(8,145,178,0.6))">&#128260;</div>
<h3>IMAP Sync</h3>
<p>Connect any IMAP mailbox. Server-side polling syncs emails every 30 seconds with UID tracking.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#22c55e,#059669)">&#128279;</div>
<h3>Gnosis Safe</h3>
<p>Approval workflows can tie to on-chain Gnosis Safe transactions for verifiable multi-sig.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box" style="background:linear-gradient(135deg,#f97316,#d97706)">&#127760;</div>
<h3>Local-First</h3>
<p>Automerge CRDT sync keeps mailbox state available offline. Changes merge automatically.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<div class="rl-card" style="border:2px solid rgba(59,130,246,0.3);background:linear-gradient(135deg,rgba(59,130,246,0.1),rgba(20,184,166,0.05),rgba(100,116,139,0.1));text-align:center;padding:3rem 1.5rem;position:relative;overflow:hidden">
<div style="position:absolute;top:0;right:0;width:16rem;height:16rem;background:rgba(59,130,246,0.1);border-radius:50%;filter:blur(48px)"></div>
<div style="position:absolute;bottom:0;left:0;width:16rem;height:16rem;background:rgba(20,184,166,0.1);border-radius:50%;filter:blur(48px)"></div>
<div style="position:relative">
<span class="rl-badge">Try EncryptID</span>
<h2 class="rl-heading" style="margin-top:1rem">See it in action</h2>
<p class="rl-subtext" style="max-width:36rem;margin:0.5rem auto 0">
Try the interactive EncryptID demo &mdash; register a passkey, derive encryption keys,
and test signing and encryption right in your browser. No account needed.
</p>
<div class="rl-cta-row" style="margin-top:1.5rem">
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary">
Launch Demo &rarr;
</a>
</div>
</div>
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Email, reimagined for teams.</h2>
<p class="rl-subtext">Try the demo or create a space to get started.</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary">Open Inbox</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
</section>

View File

@ -545,6 +545,7 @@ export const notesModule: RSpaceModule = {
},
],
seedTemplate: seedDemoIfEmpty,
async onInit({ syncServer }) {
_syncServer = syncServer;

View File

@ -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<TripDoc>(), '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;
},

View File

@ -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<ProposalDoc>(spaceConfigDocId('community'), 'seed space config', (d) => {
// Ensure space config exists
ensureSpaceConfigDoc(space);
_syncServer!.changeDoc<ProposalDoc>(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<ProposalDoc>(), '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();

View File

@ -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<BoardDoc>(), '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();

View File

@ -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<WSData>({
// 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<WSData>({
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);
}

View File

@ -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 */