diff --git a/modules/rbnb/mod.ts b/modules/rbnb/mod.ts index f59720a..acc91a8 100644 --- a/modules/rbnb/mod.ts +++ b/modules/rbnb/mod.ts @@ -1254,4 +1254,7 @@ export const bnbModule: RSpaceModule = { { path: "stays", name: "Stays", icon: "\u{1F6CC}", description: "Stay requests and history" }, { path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from stays" }, ], + onboardingActions: [ + { label: "Create a Listing", icon: "๐Ÿ ", description: "List a space for community stays", type: 'create', href: '/{space}/rbnb' }, + ], }; diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index a5b64c4..cbdd7b9 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -475,6 +475,10 @@ export const booksModule: RSpaceModule = { ], }, ], + onboardingActions: [ + { label: "Upload a PDF", icon: "๐Ÿ“•", description: "Add a book or document to the library", type: 'upload' as const, upload: { accept: '.pdf', endpoint: '/{space}/rbooks/api/books' } }, + { label: "Browse the Library", icon: "๐Ÿ“š", description: "Explore the shared book collection", type: 'create' as const, href: '/{space}/rbooks' }, + ], async onSpaceCreate(ctx: SpaceLifecycleContext) { // Books are global, not space-scoped (for now). No-op. }, diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 96c127e..c67b482 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -395,6 +395,126 @@ routes.post("/api/events", async (c) => { return c.json(eventToRow(updated.events[eventId], updated.sources), 201); }); +// POST /api/import-ics โ€” import events from an ICS file upload +routes.post("/api/import-ics", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + + const formData = await c.req.formData(); + const file = formData.get("file"); + if (!file || !(file instanceof File)) return c.json({ error: "No .ics file provided" }, 400); + + const text = await file.text(); + + // Simple VEVENT parser โ€” extract events from ICS text + const vevents: Array<{ title: string; description: string; start: number; end: number; allDay: boolean; location: string }> = []; + const eventBlocks = text.split("BEGIN:VEVENT"); + for (let i = 1; i < eventBlocks.length; i++) { + const block = eventBlocks[i].split("END:VEVENT")[0]; + const getField = (name: string): string => { + const match = block.match(new RegExp(`^${name}[;:](.*)$`, 'm')); + return match ? match[1].replace(/\\n/g, '\n').replace(/\\,/g, ',').trim() : ''; + }; + + const summary = getField("SUMMARY"); + if (!summary) continue; + + const dtstart = getField("DTSTART"); + const dtend = getField("DTEND"); + const description = getField("DESCRIPTION"); + const location = getField("LOCATION"); + + // Parse ICS date: YYYYMMDD or YYYYMMDDTHHmmssZ + const parseIcsDate = (s: string): number => { + if (!s) return 0; + // Strip VALUE=DATE: prefix etc + const clean = s.replace(/^[^:]*:/, '').trim(); + if (clean.length === 8) { + // All-day: YYYYMMDD + return new Date(`${clean.slice(0,4)}-${clean.slice(4,6)}-${clean.slice(6,8)}`).getTime(); + } + // Full datetime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ + const m = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/); + if (m) return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`).getTime(); + return new Date(clean).getTime() || 0; + }; + + const startMs = parseIcsDate(dtstart); + if (!startMs) continue; + const endMs = parseIcsDate(dtend) || startMs + 3600000; + const allDay = dtstart.includes("VALUE=DATE") || (dtstart.replace(/^[^:]*:/, '').trim().length === 8); + + vevents.push({ title: summary, description, start: startMs, end: endMs, allDay, location }); + } + + if (!vevents.length) return c.json({ error: "No events found in ICS file" }, 400); + + const docId = calendarDocId(dataSpace); + ensureDoc(dataSpace); + const now = Date.now(); + + // Create an "Imported" source for these events + const sourceId = crypto.randomUUID(); + + _syncServer!.changeDoc(docId, `import ${vevents.length} ICS events`, (d) => { + d.sources[sourceId] = { + id: sourceId, + name: file.name.replace(/\.ics$/i, '') || 'Imported Calendar', + sourceType: 'ICS_IMPORT', + url: null, + color: '#8b5cf6', + isActive: true, + isVisible: true, + syncIntervalMinutes: null, + lastSyncedAt: now, + ownerId: null, + createdAt: now, + }; + for (const ev of vevents) { + const id = crypto.randomUUID(); + d.events[id] = { + id, + title: ev.title, + description: ev.description, + startTime: ev.start, + endTime: ev.end, + allDay: ev.allDay, + timezone: 'UTC', + rrule: null, + status: null, + visibility: null, + sourceId, + sourceName: null, + sourceType: null, + sourceColor: null, + locationId: null, + locationName: ev.location || null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: 'ICS_IMPORT', + rToolEntityId: null, + attendees: [], + attendeeCount: 0, + metadata: null, + likelihood: null, + createdAt: now, + updatedAt: now, + }; + } + }); + + return c.json({ imported: vevents.length, sourceId }); +}); + // GET /api/events/scheduled โ€” query only scheduled knowledge items routes.get("/api/events/scheduled", async (c) => { const space = c.req.param("space") || "demo"; @@ -771,4 +891,9 @@ export const calModule: RSpaceModule = { { path: "saved-views", name: "Saved Views", icon: "๐Ÿ‘๏ธ", description: "Custom calendar views and filters" }, { path: "events", name: "Events", icon: "๐Ÿ“…", description: "Calendar events across all systems" }, ], + onboardingActions: [ + { label: "Import Calendar (.ics)", icon: "๐Ÿ“…", description: "Upload an ICS file from Google Calendar, Outlook, or Apple", type: 'upload', upload: { accept: '.ics,.ical', endpoint: '/{space}/rcal/api/import-ics' } }, + { label: "Subscribe to iCal URL", icon: "๐Ÿ”—", description: "Add a read-only calendar feed", type: 'link', href: '/{space}/rcal?action=add-source' }, + { label: "Create an Event", icon: "โœ๏ธ", description: "Add your first calendar event", type: 'create', href: '/{space}/rcal' }, + ], }; diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 07a6e79..0e056fb 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -2584,4 +2584,7 @@ export const cartModule: RSpaceModule = { { path: "group-buys", name: "Group Buys", icon: "๐Ÿ‘ฅ", description: "Volume discount group purchasing campaigns" }, { path: "subscriptions", name: "Subscriptions", icon: "๐Ÿ”„", description: "Recurring subscription orders" }, ], + onboardingActions: [ + { label: "Create a Store", icon: "๐Ÿช", description: "Set up a community storefront", type: 'create', href: '/{space}/rcart' }, + ], }; diff --git a/modules/rfiles/mod.ts b/modules/rfiles/mod.ts index 92c2d17..105a381 100644 --- a/modules/rfiles/mod.ts +++ b/modules/rfiles/mod.ts @@ -703,4 +703,7 @@ export const filesModule: RSpaceModule = { { path: "files", name: "Files", icon: "๐Ÿ“", description: "Uploaded files and documents" }, { path: "shares", name: "Shares", icon: "๐Ÿ”—", description: "Public share links" }, ], + onboardingActions: [ + { label: "Upload Files", icon: "โฌ†๏ธ", description: "Add files to your space", type: 'create', href: '/{space}/rfiles' }, + ], }; diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index fc68e5b..9c83b09 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -1086,4 +1086,7 @@ export const flowsModule: RSpaceModule = { ], }, ], + onboardingActions: [ + { label: "Create a Budget", icon: "๐ŸŒŠ", description: "Set up a community funding flow", type: 'create', href: '/{space}/rflows' }, + ], }; diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index 5e0a9a9..cf84658 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -1750,4 +1750,8 @@ export const inboxModule: RSpaceModule = { ], }, ], + onboardingActions: [ + { label: "Connect your Email", icon: "๐Ÿ“ง", description: "Link an email account to receive messages", type: 'link', href: '/{space}/rinbox?action=connect-email' }, + { label: "Create a Mailbox", icon: "๐Ÿ“ฌ", description: "Set up a shared team mailbox", type: 'create', href: '/{space}/rinbox' }, + ], }; diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 0b9869d..c0d6ee9 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1421,4 +1421,9 @@ export const notesModule: RSpaceModule = { { path: "transcripts", name: "Transcripts", icon: "๐ŸŽ™๏ธ", description: "Voice transcription records" }, { path: "articles", name: "Articles", icon: "๐Ÿ“ฐ", description: "Published articles and posts" }, ], + onboardingActions: [ + { label: "Import from Obsidian or Logseq", icon: "๐Ÿ“‚", description: "Import markdown files from your vault", type: 'link', href: '/{space}/rnotes?action=import' }, + { label: "Import from Notion", icon: "๐Ÿ“„", description: "Bring in pages from a Notion export", type: 'link', href: '/{space}/rnotes?action=import&source=notion' }, + { label: "Create a Notebook", icon: "โœ๏ธ", description: "Start writing from scratch", type: 'create', href: '/{space}/rnotes' }, + ], }; diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index cc8ea37..8e3156f 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -2091,4 +2091,7 @@ export const scheduleModule: RSpaceModule = { { path: "workflows", name: "Automations", icon: "๐Ÿ”€", description: "Visual automation workflows with triggers, conditions, and actions" }, { path: "log", name: "Execution Log", icon: "๐Ÿ“‹", description: "History of job executions" }, ], + onboardingActions: [ + { label: "Create a Schedule", icon: "โฑ", description: "Set up a recurring job or reminder", type: 'create', href: '/{space}/rschedule' }, + ], }; diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index bab6ad3..268686e 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -1215,4 +1215,7 @@ export const socialsModule: RSpaceModule = { ], }, ], + onboardingActions: [ + { label: "Create a Thread", icon: "๐Ÿงต", description: "Start a discussion thread", type: 'create', href: '/{space}/rsocials' }, + ], }; diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index bafb5f9..226456b 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -899,6 +899,9 @@ export const splatModule: RSpaceModule = { _syncServer = ctx.syncServer; console.log("[Splat] Automerge document store ready"); }, + onboardingActions: [ + { label: "3D Splatting", icon: "๐ŸŒ€", description: "Upload a PLY or Splat file", type: 'upload' as const, upload: { accept: '.ply,.splat', endpoint: '/{space}/rsplat/api/scenes' } }, + ], async onSpaceCreate(ctx: SpaceLifecycleContext) { // Eagerly create the Automerge doc for new spaces ensureDoc(ctx.spaceSlug); diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index b4abcf1..c8002e5 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -520,4 +520,7 @@ export const tasksModule: RSpaceModule = { { path: "projects", name: "Projects", icon: "๐Ÿ“‹", description: "Kanban project boards" }, { path: "tasks", name: "Tasks", icon: "โœ…", description: "Task cards across all boards" }, ], + onboardingActions: [ + { label: "Create a Taskboard", icon: "๐Ÿ“‹", description: "Start a new kanban project board", type: 'create', href: '/{space}/rtasks' }, + ], }; diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index a14e207..6173365 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -717,4 +717,7 @@ export const tripsModule: RSpaceModule = { ], }, ], + onboardingActions: [ + { label: "Plan a Trip", icon: "โœˆ๏ธ", description: "Create your first trip itinerary", type: 'create', href: '/{space}/rtrips' }, + ], }; diff --git a/modules/rvnb/mod.ts b/modules/rvnb/mod.ts index 386da59..31ffa12 100644 --- a/modules/rvnb/mod.ts +++ b/modules/rvnb/mod.ts @@ -1335,4 +1335,7 @@ export const vnbModule: RSpaceModule = { { path: "rentals", name: "Rentals", icon: "\u{1F697}", description: "Rental requests and history" }, { path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from trips" }, ], + onboardingActions: [ + { label: "Add a Vehicle", icon: "๐Ÿš—", description: "Share a vehicle with your community", type: 'create', href: '/{space}/rvnb' }, + ], }; diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index a31dad8..f497287 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -600,4 +600,7 @@ export const voteModule: RSpaceModule = { { path: "proposals", name: "Proposals", icon: "๐Ÿ“œ", description: "Governance proposals for conviction voting" }, { path: "ballots", name: "Ballots", icon: "๐Ÿ—ณ๏ธ", description: "Voting ballots and results" }, ], + onboardingActions: [ + { label: "Create a Proposal", icon: "๐Ÿ—ณ๏ธ", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' }, + ], }; diff --git a/server/index.ts b/server/index.ts index 2060485..7756f79 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2026,6 +2026,7 @@ for (const mod of getAllModules()) { spaceSlug: space, modules: getModuleInfoList(), landingHTML: mod.landingPage?.(), + onboardingActions: mod.onboardingActions, })); } return next(); diff --git a/server/shell.ts b/server/shell.ts index 5bee351..02da16f 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -6,7 +6,7 @@ */ import { resolve } from "node:path"; -import type { ModuleInfo, SubPageInfo } from "../shared/module"; +import type { ModuleInfo, SubPageInfo, OnboardingAction } from "../shared/module"; import { getDocumentData } from "./community-store"; // โ”€โ”€ Browser compatibility polyfills (inline, runs before ES modules) โ”€โ”€ @@ -2165,10 +2165,12 @@ export interface OnboardingOptions { modules: ModuleInfo[]; /** Pre-rendered landing page HTML from the module (feature cards, etc.) */ landingHTML?: string; + /** Module-specific onboarding CTAs (import, connect, create) */ + onboardingActions?: OnboardingAction[]; } export function renderOnboarding(opts: OnboardingOptions): string { - const { moduleId, moduleName, moduleIcon, moduleDescription, spaceSlug, modules, landingHTML } = opts; + const { moduleId, moduleName, moduleIcon, moduleDescription, spaceSlug, modules, landingHTML, onboardingActions } = opts; const demoUrl = `/${moduleId}/demo`; const templateUrl = `/${moduleId}/template`; @@ -2176,6 +2178,61 @@ export function renderOnboarding(opts: OnboardingOptions): string { ? `
${landingHTML}
` : ''; + // Render action cards if module declares them + let actionsBlock = ''; + if (onboardingActions?.length) { + const cards = onboardingActions.map((a) => { + const resolvedHref = a.href?.replace(/\{space\}/g, spaceSlug) ?? '#'; + const resolvedEndpoint = a.upload?.endpoint?.replace(/\{space\}/g, spaceSlug) ?? ''; + if (a.type === 'upload') { + const inputId = `upload-${Math.random().toString(36).slice(2, 8)}`; + return ``; + } + return ` + ${a.icon} + ${escapeHtml(a.label)} + ${escapeHtml(a.description)} + `; + }).join('\n'); + + actionsBlock = ` +
or connect your data
+
${cards}
`; + } + + const uploadScript = onboardingActions?.some(a => a.type === 'upload') ? ` + ` : ''; + const body = `
@@ -2189,11 +2246,13 @@ export function renderOnboarding(opts: OnboardingOptions): string { Load Sample Data - Try Demo + View with Demo Data
+ ${actionsBlock}
${featuresBlock} - `; + + ${uploadScript}`; return renderShell({ title: `${moduleName} โ€” ${spaceSlug} | rSpace`, @@ -2268,10 +2327,38 @@ const ONBOARDING_CSS = ` border-top: 1px solid var(--rs-border-subtle); padding-top: 2rem; margin-top: 1rem; } +.onboarding__divider { + position: relative; display: flex; align-items: center; gap: 1rem; + margin: 2rem 0 1.25rem; color: var(--rs-text-muted); font-size: 0.8125rem; +} +.onboarding__divider::before, .onboarding__divider::after { + content: ''; flex: 1; height: 1px; background: var(--rs-border); +} +.onboarding__actions { + position: relative; + display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; width: 100%; +} +.onboarding__action { + display: flex; flex-direction: column; align-items: center; gap: 0.25rem; + padding: 1.25rem 1rem; border-radius: 12px; + border: 1px solid var(--rs-border); background: var(--rs-bg-surface); + text-decoration: none; color: var(--rs-text-primary); cursor: pointer; + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; + font-family: inherit; font-size: inherit; text-align: center; +} +.onboarding__action:hover { + transform: translateY(-2px); border-color: var(--rs-border-strong); + box-shadow: 0 4px 12px rgba(0,0,0,0.08); +} +.onboarding__action-icon { font-size: 1.75rem; margin-bottom: 0.25rem; } +.onboarding__action-label { font-size: 0.875rem; font-weight: 600; } +.onboarding__action-desc { font-size: 0.75rem; color: var(--rs-text-muted); line-height: 1.4; } @media (max-width: 600px) { .onboarding { padding: 2rem 1rem 1.5rem; } .onboarding__title { font-size: 1.6rem; } .onboarding__icon { font-size: 2.5rem; } + .onboarding__actions { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } } `; diff --git a/shared/module.ts b/shared/module.ts index 9110502..63e7e46 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -71,6 +71,22 @@ export interface ModuleSettingField { options?: Array<{ value: string; label: string }>; } +/** An onboarding call-to-action shown when a module has no data. */ +export interface OnboardingAction { + /** Button label, e.g. "Import Calendar (.ics)" */ + label: string; + /** Emoji icon */ + icon: string; + /** Short description, e.g. "Upload an ICS file from Google Calendar, Outlook, or Apple" */ + description: string; + /** Action type: link navigates, upload opens file picker, create navigates to module */ + type: 'link' | 'upload' | 'create'; + /** For 'link'/'create': URL with {space} placeholder */ + href?: string; + /** For 'upload': file accept filter + POST endpoint */ + upload?: { accept: string; endpoint: string }; +} + /** A browsable content type that a module produces. */ export interface OutputPath { /** URL segment: "notebooks" */ @@ -148,6 +164,9 @@ export interface RSpaceModule { /** Seed template/demo data for a space. Called by /template route. */ seedTemplate?: (space: string) => void; + /** Onboarding CTAs shown when the module has no data in a space */ + onboardingActions?: OnboardingAction[]; + /** If true, write operations (POST/PUT/PATCH/DELETE) skip the space role check. * Use for modules whose API endpoints are publicly accessible (e.g. thread builder). */ publicWrite?: boolean; @@ -190,6 +209,7 @@ export interface ModuleInfo { outputPaths?: OutputPath[]; subPageInfos?: Array<{ path: string; title: string }>; settingsSchema?: ModuleSettingField[]; + onboardingActions?: OnboardingAction[]; } export function getModuleInfoList(): ModuleInfo[] { @@ -209,5 +229,6 @@ export function getModuleInfoList(): ModuleInfo[] { ...(m.outputPaths ? { outputPaths: m.outputPaths } : {}), ...(m.subPageInfos ? { subPageInfos: m.subPageInfos.map(s => ({ path: s.path, title: s.title })) } : {}), ...(m.settingsSchema ? { settingsSchema: m.settingsSchema } : {}), + ...(m.onboardingActions ? { onboardingActions: m.onboardingActions } : {}), })); }