diff --git a/modules/cal/components/cal.css b/modules/cal/components/cal.css new file mode 100644 index 0000000..20f0688 --- /dev/null +++ b/modules/cal/components/cal.css @@ -0,0 +1,6 @@ +/* Cal module — dark theme */ +folk-calendar-view { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/cal/components/folk-calendar-view.ts b/modules/cal/components/folk-calendar-view.ts new file mode 100644 index 0000000..133dd50 --- /dev/null +++ b/modules/cal/components/folk-calendar-view.ts @@ -0,0 +1,238 @@ +/** + * — temporal coordination calendar. + * + * Month grid view with event dots, lunar phase overlay, + * event creation, and source filtering. + */ + +class FolkCalendarView extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private currentDate = new Date(); + private events: any[] = []; + private sources: any[] = []; + private lunarData: Record = {}; + private showLunar = true; + private selectedDate = ""; + private selectedEvent: any = null; + private error = ""; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadMonth(); + this.render(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/cal/); + return match ? `/${match[1]}/cal` : ""; + } + + private async loadMonth() { + const year = this.currentDate.getFullYear(); + const month = this.currentDate.getMonth(); + const start = `${year}-${String(month + 1).padStart(2, "0")}-01`; + const lastDay = new Date(year, month + 1, 0).getDate(); + const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; + + const base = this.getApiBase(); + try { + const [eventsRes, sourcesRes, lunarRes] = await Promise.all([ + fetch(`${base}/api/events?start=${start}&end=${end}`), + fetch(`${base}/api/sources`), + fetch(`${base}/api/lunar?start=${start}&end=${end}`), + ]); + if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; } + if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; } + if (lunarRes.ok) { this.lunarData = await lunarRes.json(); } + } catch { /* offline fallback */ } + this.render(); + } + + private navigate(delta: number) { + this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1); + this.loadMonth(); + } + + private getMoonEmoji(phase: string): string { + const map: Record = { + new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}", + waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}", + last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}", + }; + return map[phase] || ""; + } + + private render() { + const year = this.currentDate.getFullYear(); + const month = this.currentDate.getMonth(); + const monthName = this.currentDate.toLocaleString("default", { month: "long" }); + + this.shadow.innerHTML = ` + + + ${this.error ? `
${this.esc(this.error)}
` : ""} + +
+ + ${monthName} ${year} + + +
+ + ${this.sources.length > 0 ? `
+ ${this.sources.map(s => `${this.esc(s.name)}`).join("")} +
` : ""} + +
+ ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `
${d}
`).join("")} +
+
+ ${this.renderDays(year, month)} +
+ + ${this.selectedEvent ? this.renderEventModal() : ""} + `; + this.attachListeners(); + } + + private renderDays(year: number, month: number): string { + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + + let html = ""; + // Previous month padding + const prevDays = new Date(year, month, 0).getDate(); + for (let i = firstDay - 1; i >= 0; i--) { + html += `
${prevDays - i}
`; + } + + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + const isToday = dateStr === todayStr; + const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)); + const lunar = this.lunarData[dateStr]; + + html += `
+
+ ${d} + ${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)}` : ""} +
+ ${dayEvents.length > 0 ? ` +
+ ${dayEvents.slice(0, 3).map(e => ``).join("")} + ${dayEvents.length > 3 ? `+${dayEvents.length - 3}` : ""} +
+ ${dayEvents.slice(0, 2).map(e => `
${this.esc(e.title)}
`).join("")} + ` : ""} +
`; + } + + // Next month padding + const totalCells = firstDay + daysInMonth; + const remaining = (7 - (totalCells % 7)) % 7; + for (let i = 1; i <= remaining; i++) { + html += `
${i}
`; + } + + return html; + } + + private renderEventModal(): string { + const e = this.selectedEvent; + return ` + + `; + } + + private attachListeners() { + this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1)); + this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1)); + this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => { + this.showLunar = !this.showLunar; + this.render(); + }); + + this.shadow.querySelectorAll("[data-event]").forEach(el => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const data = JSON.parse((el as HTMLElement).dataset.event!); + this.selectedEvent = this.events.find(ev => ev.id === data.id); + this.render(); + }); + }); + + this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => { + if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); } + }); + this.shadow.getElementById("modal-close")?.addEventListener("click", () => { + this.selectedEvent = null; this.render(); + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-calendar-view", FolkCalendarView); diff --git a/modules/cal/db/schema.sql b/modules/cal/db/schema.sql new file mode 100644 index 0000000..73c700d --- /dev/null +++ b/modules/cal/db/schema.sql @@ -0,0 +1,67 @@ +-- rCal module schema +CREATE SCHEMA IF NOT EXISTS rcal; + +CREATE TABLE IF NOT EXISTS rcal.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT UNIQUE NOT NULL, + username TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rcal.calendar_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + source_type TEXT NOT NULL CHECK (source_type IN ('MANUAL','ICS','CALDAV','GOOGLE','OUTLOOK','APPLE','OBSIDIAN')), + url TEXT, + color TEXT DEFAULT '#6366f1', + is_active BOOLEAN DEFAULT TRUE, + is_visible BOOLEAN DEFAULT TRUE, + sync_interval_minutes INT DEFAULT 60, + last_synced_at TIMESTAMPTZ, + owner_id UUID REFERENCES rcal.users(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rcal.locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + granularity INT NOT NULL DEFAULT 5, + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + parent_id UUID REFERENCES rcal.locations(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rcal.events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + all_day BOOLEAN DEFAULT FALSE, + timezone TEXT DEFAULT 'UTC', + rrule TEXT, + status TEXT DEFAULT 'CONFIRMED' CHECK (status IN ('TENTATIVE','CONFIRMED','CANCELLED')), + visibility TEXT DEFAULT 'DEFAULT' CHECK (visibility IN ('PUBLIC','PRIVATE','DEFAULT')), + source_id UUID REFERENCES rcal.calendar_sources(id) ON DELETE SET NULL, + location_id UUID REFERENCES rcal.locations(id) ON DELETE SET NULL, + location_name TEXT, + coordinates POINT, + location_granularity INT, + is_virtual BOOLEAN DEFAULT FALSE, + virtual_url TEXT, + virtual_platform TEXT, + r_tool_source TEXT, + r_tool_entity_id TEXT, + attendees TEXT[] DEFAULT '{}', + attendee_count INT DEFAULT 0, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_rcal_events_time ON rcal.events(start_time, end_time); +CREATE INDEX IF NOT EXISTS idx_rcal_events_source ON rcal.events(source_id); +CREATE INDEX IF NOT EXISTS idx_rcal_events_rtool ON rcal.events(r_tool_source, r_tool_entity_id); +CREATE INDEX IF NOT EXISTS idx_rcal_locations_parent ON rcal.locations(parent_id); +CREATE INDEX IF NOT EXISTS idx_rcal_sources_owner ON rcal.calendar_sources(owner_id); diff --git a/modules/cal/mod.ts b/modules/cal/mod.ts new file mode 100644 index 0000000..73652c1 --- /dev/null +++ b/modules/cal/mod.ts @@ -0,0 +1,281 @@ +/** + * Cal module — temporal coordination calendar. + * + * Group calendars with lunar/solar/seasonal time systems, + * location-aware events, and temporal-spatial zoom coupling. + */ + +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +// ── DB initialization ── +const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); + +async function initDB() { + try { + await sql.unsafe(SCHEMA_SQL); + console.log("[Cal] DB schema initialized"); + } catch (e) { + console.error("[Cal] DB init error:", e); + } +} + +initDB(); + +// ── API: Events ── + +// GET /api/events — query events with filters +routes.get("/api/events", async (c) => { + const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query(); + + let where = "WHERE 1=1"; + const params: unknown[] = []; + let idx = 1; + + if (start) { where += ` AND e.start_time >= $${idx}`; params.push(start); idx++; } + if (end) { where += ` AND e.start_time <= ($${idx}::date + interval '1 day')`; params.push(end); idx++; } + if (source) { where += ` AND e.source_id = $${idx}`; params.push(source); idx++; } + if (search) { where += ` AND (e.title ILIKE $${idx} OR e.description ILIKE $${idx})`; params.push(`%${search}%`); idx++; } + if (rTool) { where += ` AND e.r_tool_source = $${idx}`; params.push(rTool); idx++; } + if (rEntityId) { where += ` AND e.r_tool_entity_id = $${idx}`; params.push(rEntityId); idx++; } + if (upcoming) { + where += ` AND e.start_time >= NOW() AND e.start_time <= NOW() + ($${idx} || ' days')::interval`; + params.push(upcoming); + idx++; + } + + const rows = await sql.unsafe( + `SELECT e.*, cs.name as source_name, cs.color as source_color, l.name as location_label + FROM rcal.events e + LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id + LEFT JOIN rcal.locations l ON l.id = e.location_id + ${where} + ORDER BY e.start_time ASC LIMIT 500`, + params + ); + return c.json({ count: rows.length, results: rows }); +}); + +// POST /api/events — create event +routes.post("/api/events", async (c) => { + const body = await c.req.json(); + const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name, + is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body; + if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400); + + const rows = await sql.unsafe( + `INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id, + location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, + [title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC", + source_id || null, location_id || null, location_name || null, is_virtual || false, + virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null] + ); + return c.json(rows[0], 201); +}); + +// GET /api/events/:id +routes.get("/api/events/:id", async (c) => { + const rows = await sql.unsafe( + `SELECT e.*, cs.name as source_name, cs.color as source_color + FROM rcal.events e LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id + WHERE e.id = $1`, + [c.req.param("id")] + ); + if (rows.length === 0) return c.json({ error: "Event not found" }, 404); + return c.json(rows[0]); +}); + +// PATCH /api/events/:id +routes.patch("/api/events/:id", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + + const fields: string[] = []; + const params: unknown[] = []; + let idx = 1; + const allowed = ["title", "description", "start_time", "end_time", "all_day", "timezone", + "status", "visibility", "location_name", "is_virtual", "virtual_url"]; + + for (const key of allowed) { + if (body[key] !== undefined) { + fields.push(`${key} = $${idx}`); + params.push(body[key]); + idx++; + } + } + + if (fields.length === 0) return c.json({ error: "No fields" }, 400); + fields.push("updated_at = NOW()"); + params.push(id); + + const rows = await sql.unsafe( + `UPDATE rcal.events SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json(rows[0]); +}); + +// DELETE /api/events/:id +routes.delete("/api/events/:id", async (c) => { + const result = await sql.unsafe("DELETE FROM rcal.events WHERE id = $1 RETURNING id", [c.req.param("id")]); + if (result.length === 0) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); + +// ── API: Sources ── + +routes.get("/api/sources", async (c) => { + const { is_active, is_visible, source_type } = c.req.query(); + let where = "WHERE 1=1"; + const params: unknown[] = []; + let idx = 1; + + if (is_active !== undefined) { where += ` AND is_active = $${idx}`; params.push(is_active === "true"); idx++; } + if (is_visible !== undefined) { where += ` AND is_visible = $${idx}`; params.push(is_visible === "true"); idx++; } + if (source_type) { where += ` AND source_type = $${idx}`; params.push(source_type); idx++; } + + const rows = await sql.unsafe(`SELECT * FROM rcal.calendar_sources ${where} ORDER BY name`, params); + return c.json({ count: rows.length, results: rows }); +}); + +routes.post("/api/sources", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + `INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [body.name, body.source_type || "MANUAL", body.url || null, body.color || "#6366f1", + body.is_active ?? true, body.is_visible ?? true] + ); + return c.json(rows[0], 201); +}); + +// ── API: Locations ── + +routes.get("/api/locations", async (c) => { + const { granularity, parent, search, root } = c.req.query(); + let where = "WHERE 1=1"; + const params: unknown[] = []; + let idx = 1; + + if (root === "true") { where += " AND parent_id IS NULL"; } + if (granularity) { where += ` AND granularity = $${idx}`; params.push(parseInt(granularity)); idx++; } + if (parent) { where += ` AND parent_id = $${idx}`; params.push(parent); idx++; } + if (search) { where += ` AND name ILIKE $${idx}`; params.push(`%${search}%`); idx++; } + + const rows = await sql.unsafe(`SELECT * FROM rcal.locations ${where} ORDER BY name`, params); + return c.json(rows); +}); + +routes.get("/api/locations/tree", async (c) => { + const rows = await sql.unsafe( + `WITH RECURSIVE tree AS ( + SELECT id, name, granularity, parent_id, 0 as depth FROM rcal.locations WHERE parent_id IS NULL + UNION ALL + SELECT l.id, l.name, l.granularity, l.parent_id, t.depth + 1 + FROM rcal.locations l JOIN tree t ON l.parent_id = t.id + ) + SELECT * FROM tree ORDER BY depth, name` + ); + return c.json(rows); +}); + +// ── API: Lunar data (computed, not stored) ── + +routes.get("/api/lunar", async (c) => { + const { start, end } = c.req.query(); + if (!start || !end) return c.json({ error: "start and end required" }, 400); + + // Simple lunar phase approximation based on synodic month + const SYNODIC_MONTH = 29.53059; + const KNOWN_NEW_MOON = new Date("2024-01-11T11:57:00Z").getTime(); + const phases: Record = {}; + + const startDate = new Date(start); + const endDate = new Date(end); + const current = new Date(startDate); + + while (current <= endDate) { + const daysSinceNewMoon = (current.getTime() - KNOWN_NEW_MOON) / (1000 * 60 * 60 * 24); + const lunation = ((daysSinceNewMoon % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH; + const fraction = lunation / SYNODIC_MONTH; + const illumination = 0.5 * (1 - Math.cos(2 * Math.PI * fraction)); + + let phase = "waxing_crescent"; + if (fraction < 0.0625) phase = "new_moon"; + else if (fraction < 0.1875) phase = "waxing_crescent"; + else if (fraction < 0.3125) phase = "first_quarter"; + else if (fraction < 0.4375) phase = "waxing_gibbous"; + else if (fraction < 0.5625) phase = "full_moon"; + else if (fraction < 0.6875) phase = "waning_gibbous"; + else if (fraction < 0.8125) phase = "last_quarter"; + else if (fraction < 0.9375) phase = "waning_crescent"; + else phase = "new_moon"; + + phases[current.toISOString().split("T")[0]] = { phase, illumination: Math.round(illumination * 100) / 100 }; + current.setDate(current.getDate() + 1); + } + + return c.json(phases); +}); + +// ── API: Stats ── + +routes.get("/api/stats", async (c) => { + const [eventCount, sourceCount, locationCount] = await Promise.all([ + sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events"), + sql.unsafe("SELECT count(*)::int as cnt FROM rcal.calendar_sources WHERE is_active = true"), + sql.unsafe("SELECT count(*)::int as cnt FROM rcal.locations"), + ]); + return c.json({ + events: eventCount[0]?.cnt || 0, + sources: sourceCount[0]?.cnt || 0, + locations: locationCount[0]?.cnt || 0, + }); +}); + +// ── API: Context (r* tool bridge) ── + +routes.get("/api/context/:tool", async (c) => { + const tool = c.req.param("tool"); + const entityId = c.req.query("entityId"); + if (!entityId) return c.json({ error: "entityId required" }, 400); + + const rows = await sql.unsafe( + "SELECT * FROM rcal.events WHERE r_tool_source = $1 AND r_tool_entity_id = $2 ORDER BY start_time", + [tool, entityId] + ); + return c.json({ count: rows.length, results: rows }); +}); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Calendar | rSpace`, + moduleId: "cal", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const calModule: RSpaceModule = { + id: "cal", + name: "rCal", + icon: "\u{1F4C5}", + description: "Temporal coordination calendar with lunar, solar, and seasonal systems", + routes, + standaloneDomain: "rcal.online", +}; diff --git a/modules/cal/standalone.ts b/modules/cal/standalone.ts new file mode 100644 index 0000000..9f4346a --- /dev/null +++ b/modules/cal/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Cal module. + * Serves rcal.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { calModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/cal/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", calModule.routes); + +console.log(`[rCal Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/data/components/data.css b/modules/data/components/data.css new file mode 100644 index 0000000..2d1a91c --- /dev/null +++ b/modules/data/components/data.css @@ -0,0 +1,5 @@ +/* Data module — layout wrapper */ +folk-analytics-view { + display: block; + padding: 1.5rem; +} diff --git a/modules/data/components/folk-analytics-view.ts b/modules/data/components/folk-analytics-view.ts new file mode 100644 index 0000000..e723d43 --- /dev/null +++ b/modules/data/components/folk-analytics-view.ts @@ -0,0 +1,125 @@ +/** + * folk-analytics-view — Privacy-first analytics dashboard overview. + * + * Shows tracked apps, stats, and a link to the full Umami dashboard. + */ + +class FolkAnalyticsView extends HTMLElement { + private shadow: ShadowRoot; + private space = "demo"; + private stats: any = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadStats(); + } + + private async loadStats() { + try { + const base = window.location.pathname.replace(/\/$/, ""); + const resp = await fetch(`${base}/api/stats`); + if (resp.ok) { + this.stats = await resp.json(); + } + } catch { /* ignore */ } + this.render(); + } + + private render() { + const stats = this.stats || { trackedApps: 17, cookiesSet: 0, scriptSize: "~2KB", selfHosted: true, apps: [], dashboardUrl: "https://analytics.rspace.online" }; + + this.shadow.innerHTML = ` + +
+
+

Privacy-First Analytics

+

Zero-knowledge, cookieless, self-hosted analytics for the r* ecosystem. Know how your tools are used without compromising anyone's privacy.

+
+ +
+
+
${stats.trackedApps}
+
Apps Tracked
+
+
+
${stats.cookiesSet}
+
Cookies Set
+
+
+
${stats.scriptSize}
+
Script Size
+
+
+
100%
+
Self-Hosted
+
+
+ +
+
+
ZK
+

Zero-Knowledge Privacy

+

No cookies. No fingerprinting. No personal data. Each page view is anonymous. GDPR compliant by architecture.

+
+
+
LF
+

Local-First Data

+

Analytics data never leaves your infrastructure. No third-party servers, no cloud dependencies.

+
+
+
SH
+

Self-Hosted

+

Full control over data retention, access, and lifecycle. Powered by Umami.

+
+
+ +
+
Tracking the r* Ecosystem
+
+ ${(stats.apps || []).map((a: string) => `${a}`).join("")} +
+
+ + +
+ `; + } +} + +customElements.define("folk-analytics-view", FolkAnalyticsView); diff --git a/modules/data/mod.ts b/modules/data/mod.ts new file mode 100644 index 0000000..336041e --- /dev/null +++ b/modules/data/mod.ts @@ -0,0 +1,71 @@ +/** + * Data module — privacy-first analytics dashboard. + * + * Lightweight module that shows analytics stats from the + * self-hosted Umami instance. No database — proxies to Umami API. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online"; + +// ── API routes ── + +// GET /api/info — module info +routes.get("/api/info", (c) => { + return c.json({ + module: "data", + name: "rData", + umamiUrl: UMAMI_URL, + features: ["privacy-first", "cookieless", "self-hosted"], + trackedApps: 17, + }); +}); + +// GET /api/health +routes.get("/api/health", (c) => c.json({ ok: true })); + +// GET /api/stats — summary stats (placeholder until Umami API is wired) +routes.get("/api/stats", (c) => { + return c.json({ + trackedApps: 17, + cookiesSet: 0, + scriptSize: "~2KB", + selfHosted: true, + dashboardUrl: UMAMI_URL, + apps: [ + "rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet", + "rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles", + "rTrips", "rTube", "rWork", "rNetwork", "rData", + ], + }); +}); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Data | rSpace`, + moduleId: "data", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const dataModule: RSpaceModule = { + id: "data", + name: "rData", + icon: "\u{1F4CA}", + description: "Privacy-first analytics for the r* ecosystem", + routes, + standaloneDomain: "rdata.online", +}; diff --git a/modules/data/standalone.ts b/modules/data/standalone.ts new file mode 100644 index 0000000..4a9b82b --- /dev/null +++ b/modules/data/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Data module. + * Serves rdata.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { dataModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/data/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", dataModule.routes); + +console.log(`[rData Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/inbox/components/folk-inbox-client.ts b/modules/inbox/components/folk-inbox-client.ts new file mode 100644 index 0000000..d43ad5f --- /dev/null +++ b/modules/inbox/components/folk-inbox-client.ts @@ -0,0 +1,357 @@ +/** + * folk-inbox-client — Collaborative email client. + * + * Shows mailbox list, thread inbox, thread detail with comments, + * and approval workflow interface. + */ + +class FolkInboxClient extends HTMLElement { + private shadow: ShadowRoot; + private space = "demo"; + private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes"; + private mailboxes: any[] = []; + private threads: any[] = []; + private currentMailbox: any = null; + private currentThread: any = null; + private approvals: any[] = []; + private filter: "all" | "open" | "snoozed" | "closed" = "all"; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadMailboxes(); + } + + private async loadMailboxes() { + try { + const base = window.location.pathname.replace(/\/$/, ""); + const resp = await fetch(`${base}/api/mailboxes`); + if (resp.ok) { + const data = await resp.json(); + this.mailboxes = data.mailboxes || []; + } + } catch { /* ignore */ } + this.render(); + } + + private async loadThreads(slug: string) { + try { + const base = window.location.pathname.replace(/\/$/, ""); + const status = this.filter === "all" ? "" : `?status=${this.filter}`; + const resp = await fetch(`${base}/api/mailboxes/${slug}/threads${status}`); + if (resp.ok) { + const data = await resp.json(); + this.threads = data.threads || []; + } + } catch { /* ignore */ } + this.render(); + } + + private async loadThread(id: string) { + try { + const base = window.location.pathname.replace(/\/$/, ""); + const resp = await fetch(`${base}/api/threads/${id}`); + if (resp.ok) { + this.currentThread = await resp.json(); + } + } catch { /* ignore */ } + this.render(); + } + + private async loadApprovals() { + try { + const base = window.location.pathname.replace(/\/$/, ""); + const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : ""; + const resp = await fetch(`${base}/api/approvals${q}`); + if (resp.ok) { + const data = await resp.json(); + this.approvals = data.approvals || []; + } + } catch { /* ignore */ } + this.render(); + } + + private timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + } + + private render() { + this.shadow.innerHTML = ` + +
+ ${this.renderNav()} + ${this.renderView()} +
+ `; + this.bindEvents(); + } + + private renderNav(): string { + const items = [ + { id: "mailboxes", label: "Mailboxes" }, + { id: "approvals", label: "Approvals" }, + ]; + if (this.currentMailbox) { + items.unshift({ id: "threads", label: this.currentMailbox.name }); + } + return ` + + `; + } + + private renderView(): string { + switch (this.view) { + case "mailboxes": return this.renderMailboxes(); + case "threads": return this.renderThreads(); + case "thread": return this.renderThreadDetail(); + case "approvals": return this.renderApprovals(); + default: return ""; + } + } + + private renderMailboxes(): string { + if (this.mailboxes.length === 0) { + return `

📨

No mailboxes yet

Create a shared mailbox to get started

`; + } + return this.mailboxes.map((m) => ` +
+
+
${m.name}
+ ${m.email} +
+
${m.description || "Shared mailbox"}
+
+ `).join(""); + } + + private renderThreads(): string { + const filters = ["all", "open", "snoozed", "closed"]; + return ` +
+
+ ${filters.map((f) => ``).join("")} +
+ ${this.threads.length === 0 + ? `
No threads
` + : 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)} +
+ `).join("")} +
+ `; + } + + private renderThreadDetail(): string { + if (!this.currentThread) return `
Loading...
`; + const t = this.currentThread; + const comments = t.comments || []; + return ` +
+
+
${t.subject}
+
+ From: ${t.from_name || ""} <${t.from_address || "unknown"}> + · ${this.timeAgo(t.received_at)} + · ${t.status} +
+
+
${t.body_text || t.body_html || "(no content)"}
+
+
Comments (${comments.length})
+ ${comments.map((cm: any) => ` +
+ ${cm.username || "Anonymous"} + ${this.timeAgo(cm.created_at)} +
${cm.body}
+
+ `).join("")} + ${comments.length === 0 ? `
No comments yet
` : ""} +
+
+ `; + } + + private renderApprovals(): string { + if (this.approvals.length === 0) { + return `

No pending approvals

`; + } + return this.approvals.map((a) => ` +
+
+
${a.subject}
+ ${a.status} +
+
+ ${a.signature_count || 0} / ${a.required_signatures} signatures + · ${this.timeAgo(a.created_at)} +
+ ${a.status === "PENDING" ? ` +
+ + +
+ ` : ""} +
+ `).join(""); + } + + private bindEvents() { + // Navigation + this.shadow.querySelectorAll("[data-nav]").forEach((btn) => { + btn.addEventListener("click", () => { + const nav = (btn as HTMLElement).dataset.nav as any; + if (nav === "approvals") { + this.view = "approvals"; + this.loadApprovals(); + } else if (nav === "mailboxes") { + this.view = "mailboxes"; + this.currentMailbox = null; + this.render(); + } else if (nav === "threads") { + this.view = "threads"; + this.render(); + } + }); + }); + + // Back + const backBtn = this.shadow.querySelector("[data-action='back']"); + if (backBtn) { + backBtn.addEventListener("click", () => { + if (this.view === "thread") { + this.view = "threads"; + this.render(); + } else if (this.view === "threads" || this.view === "approvals") { + this.view = "mailboxes"; + this.currentMailbox = null; + this.render(); + } + }); + } + + // Mailbox click + this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => { + card.addEventListener("click", () => { + const slug = (card as HTMLElement).dataset.mailbox!; + this.currentMailbox = this.mailboxes.find((m) => m.slug === slug); + this.view = "threads"; + this.loadThreads(slug); + }); + }); + + // Thread click + this.shadow.querySelectorAll("[data-thread]").forEach((row) => { + row.addEventListener("click", () => { + const id = (row as HTMLElement).dataset.thread!; + this.view = "thread"; + this.loadThread(id); + }); + }); + + // Filter + this.shadow.querySelectorAll("[data-filter]").forEach((btn) => { + btn.addEventListener("click", () => { + this.filter = (btn as HTMLElement).dataset.filter as any; + if (this.currentMailbox) this.loadThreads(this.currentMailbox.slug); + }); + }); + + // Approval actions + this.shadow.querySelectorAll("[data-approve]").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = (btn as HTMLElement).dataset.approve!; + const base = window.location.pathname.replace(/\/$/, ""); + await fetch(`${base}/api/approvals/${id}/sign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vote: "APPROVE" }), + }); + this.loadApprovals(); + }); + }); + this.shadow.querySelectorAll("[data-reject]").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = (btn as HTMLElement).dataset.reject!; + const base = window.location.pathname.replace(/\/$/, ""); + await fetch(`${base}/api/approvals/${id}/sign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vote: "REJECT" }), + }); + this.loadApprovals(); + }); + }); + } +} + +customElements.define("folk-inbox-client", FolkInboxClient); diff --git a/modules/inbox/components/inbox.css b/modules/inbox/components/inbox.css new file mode 100644 index 0000000..75f9be0 --- /dev/null +++ b/modules/inbox/components/inbox.css @@ -0,0 +1,5 @@ +/* Inbox module — layout wrapper */ +folk-inbox-client { + display: block; + padding: 1.5rem; +} diff --git a/modules/inbox/db/schema.sql b/modules/inbox/db/schema.sql new file mode 100644 index 0000000..2ec4b89 --- /dev/null +++ b/modules/inbox/db/schema.sql @@ -0,0 +1,140 @@ +-- rInbox schema: collaborative email with multisig approval +CREATE SCHEMA IF NOT EXISTS rinbox; + +-- Users (synced from EncryptID) +CREATE TABLE IF NOT EXISTS rinbox.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT UNIQUE NOT NULL, + username TEXT, + email TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Workspaces (org/community container) +CREATE TABLE IF NOT EXISTS rinbox.workspaces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + owner_did TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Workspace members +CREATE TABLE IF NOT EXISTS rinbox.workspace_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES rinbox.workspaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES rinbox.users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('MEMBER', 'ADMIN', 'OWNER')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(workspace_id, user_id) +); + +-- Mailboxes (shared inboxes) +CREATE TABLE IF NOT EXISTS rinbox.mailboxes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID REFERENCES rinbox.workspaces(id) ON DELETE CASCADE, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + description TEXT, + visibility TEXT NOT NULL DEFAULT 'members_only' CHECK (visibility IN ('public', 'public_read', 'authenticated', 'members_only')), + owner_did TEXT NOT NULL, + -- IMAP config (encrypted in production) + imap_host TEXT, + imap_port INTEGER DEFAULT 993, + imap_user TEXT, + -- Safe multisig config + safe_address TEXT, + safe_chain_id INTEGER, + approval_threshold INTEGER DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Mailbox members with role hierarchy +CREATE TABLE IF NOT EXISTS rinbox.mailbox_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mailbox_id UUID NOT NULL REFERENCES rinbox.mailboxes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES rinbox.users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'VIEWER' CHECK (role IN ('VIEWER', 'COMMENTER', 'DRAFTER', 'SIGNER', 'ADMIN')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(mailbox_id, user_id) +); + +-- Email threads (synced from IMAP) +CREATE TABLE IF NOT EXISTS rinbox.threads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mailbox_id UUID NOT NULL REFERENCES rinbox.mailboxes(id) ON DELETE CASCADE, + message_id TEXT, + subject TEXT NOT NULL DEFAULT '(no subject)', + from_address TEXT, + from_name TEXT, + to_addresses JSONB DEFAULT '[]', + cc_addresses JSONB DEFAULT '[]', + body_text TEXT, + body_html TEXT, + tags TEXT[] DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'snoozed', 'closed')), + is_read BOOLEAN DEFAULT FALSE, + is_starred BOOLEAN DEFAULT FALSE, + assigned_to UUID REFERENCES rinbox.users(id), + has_attachments BOOLEAN DEFAULT FALSE, + received_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Thread comments (internal, not sent) +CREATE TABLE IF NOT EXISTS rinbox.comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES rinbox.threads(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES rinbox.users(id), + body TEXT NOT NULL, + mentions TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Outgoing email approvals (multisig workflow) +CREATE TABLE IF NOT EXISTS rinbox.approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mailbox_id UUID NOT NULL REFERENCES rinbox.mailboxes(id) ON DELETE CASCADE, + thread_id UUID REFERENCES rinbox.threads(id), + author_id UUID NOT NULL REFERENCES rinbox.users(id), + subject TEXT NOT NULL, + body_text TEXT, + body_html TEXT, + to_addresses JSONB DEFAULT '[]', + cc_addresses JSONB DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'SENT', 'REJECTED', 'EXPIRED')), + required_signatures INTEGER DEFAULT 1, + safe_tx_hash TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); + +-- Individual signatures on approvals +CREATE TABLE IF NOT EXISTS rinbox.approval_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_id UUID NOT NULL REFERENCES rinbox.approvals(id) ON DELETE CASCADE, + signer_id UUID NOT NULL REFERENCES rinbox.users(id), + vote TEXT NOT NULL CHECK (vote IN ('APPROVE', 'REJECT')), + signed_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(approval_id, signer_id) +); + +-- IMAP sync state tracking +CREATE TABLE IF NOT EXISTS rinbox.sync_state ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mailbox_id UUID UNIQUE NOT NULL REFERENCES rinbox.mailboxes(id) ON DELETE CASCADE, + last_uid INTEGER DEFAULT 0, + uid_validity INTEGER, + last_sync_at TIMESTAMPTZ, + error TEXT +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_threads_mailbox ON rinbox.threads(mailbox_id); +CREATE INDEX IF NOT EXISTS idx_threads_mailbox_status ON rinbox.threads(mailbox_id, status); +CREATE INDEX IF NOT EXISTS idx_threads_received ON rinbox.threads(mailbox_id, received_at DESC); +CREATE INDEX IF NOT EXISTS idx_comments_thread ON rinbox.comments(thread_id); +CREATE INDEX IF NOT EXISTS idx_approvals_mailbox ON rinbox.approvals(mailbox_id, status); +CREATE INDEX IF NOT EXISTS idx_approval_sigs ON rinbox.approval_signatures(approval_id); diff --git a/modules/inbox/mod.ts b/modules/inbox/mod.ts new file mode 100644 index 0000000..6e425fe --- /dev/null +++ b/modules/inbox/mod.ts @@ -0,0 +1,352 @@ +/** + * Inbox module — collaborative email with multisig approval. + * + * Shared mailboxes with role-based access, threaded comments, + * and Gnosis Safe multisig approval for outgoing emails. + */ + +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +// ── DB initialization ── +const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); + +async function initDB() { + try { + await sql.unsafe(SCHEMA_SQL); + console.log("[Inbox] DB schema initialized"); + } catch (e) { + console.error("[Inbox] DB init error:", e); + } +} + +initDB(); + +// ── Mailboxes API ── + +// GET /api/mailboxes — list mailboxes +routes.get("/api/mailboxes", async (c) => { + const { workspace } = c.req.query(); + let rows; + if (workspace) { + rows = await sql.unsafe( + `SELECT m.* FROM rinbox.mailboxes m + JOIN rinbox.workspaces w ON w.id = m.workspace_id + WHERE w.slug = $1 ORDER BY m.created_at DESC`, + [workspace] + ); + } else { + rows = await sql.unsafe( + "SELECT * FROM rinbox.mailboxes ORDER BY created_at DESC LIMIT 50" + ); + } + return c.json({ mailboxes: rows }); +}); + +// POST /api/mailboxes — create mailbox +routes.post("/api/mailboxes", async (c) => { + const body = await c.req.json(); + const { slug, name, email, description, visibility = "members_only" } = body; + if (!slug || !name || !email) return c.json({ error: "slug, name, email required" }, 400); + if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400); + + try { + const rows = await sql.unsafe( + `INSERT INTO rinbox.mailboxes (slug, name, email, description, visibility, owner_did) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [slug, name, email, description || null, visibility, "anonymous"] + ); + return c.json(rows[0], 201); + } catch (e: any) { + if (e.code === "23505") return c.json({ error: "Mailbox already exists" }, 409); + throw e; + } +}); + +// GET /api/mailboxes/:slug — mailbox detail +routes.get("/api/mailboxes/:slug", async (c) => { + const slug = c.req.param("slug"); + const rows = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [slug]); + if (rows.length === 0) return c.json({ error: "Mailbox not found" }, 404); + + // Get thread count + const counts = await sql.unsafe( + `SELECT status, count(*) as cnt FROM rinbox.threads WHERE mailbox_id = $1 GROUP BY status`, + [rows[0].id] + ); + const threadCounts: Record = {}; + for (const row of counts) threadCounts[row.status] = parseInt(row.cnt); + + return c.json({ ...rows[0], threadCounts }); +}); + +// ── Threads API ── + +// GET /api/mailboxes/:slug/threads — list threads +routes.get("/api/mailboxes/:slug/threads", async (c) => { + const slug = c.req.param("slug"); + const { status, search, limit = "50", offset = "0" } = c.req.query(); + + const mailbox = await sql.unsafe("SELECT id FROM rinbox.mailboxes WHERE slug = $1", [slug]); + if (mailbox.length === 0) return c.json({ error: "Mailbox not found" }, 404); + + const conditions = ["mailbox_id = $1"]; + const params: unknown[] = [mailbox[0].id]; + let idx = 2; + + if (status) { + conditions.push(`status = $${idx}`); + params.push(status); + idx++; + } + if (search) { + conditions.push(`(subject ILIKE $${idx} OR from_address ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const where = conditions.join(" AND "); + const rows = await sql.unsafe( + `SELECT t.*, (SELECT count(*) FROM rinbox.comments WHERE thread_id = t.id) as comment_count + FROM rinbox.threads t WHERE ${where} + ORDER BY t.received_at DESC + LIMIT ${Math.min(parseInt(limit), 100)} OFFSET ${parseInt(offset) || 0}`, + params + ); + + return c.json({ threads: rows }); +}); + +// GET /api/threads/:id — thread detail with comments +routes.get("/api/threads/:id", async (c) => { + const id = c.req.param("id"); + const rows = await sql.unsafe("SELECT * FROM rinbox.threads WHERE id = $1", [id]); + if (rows.length === 0) return c.json({ error: "Thread not found" }, 404); + + const comments = await sql.unsafe( + `SELECT c.*, u.username, u.did as author_did + FROM rinbox.comments c + LEFT JOIN rinbox.users u ON u.id = c.author_id + WHERE c.thread_id = $1 ORDER BY c.created_at ASC`, + [id] + ); + + return c.json({ ...rows[0], comments }); +}); + +// PATCH /api/threads/:id — update thread metadata +routes.patch("/api/threads/:id", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const allowed = ["status", "is_read", "is_starred", "tags", "assigned_to"]; + const updates: string[] = []; + const params: unknown[] = []; + let idx = 1; + + for (const key of allowed) { + if (key in body) { + const col = key === "tags" ? "tags" : key; + updates.push(`${col} = $${idx}`); + params.push(key === "tags" ? body[key] : body[key]); + idx++; + } + } + + if (updates.length === 0) return c.json({ error: "No valid fields" }, 400); + + params.push(id); + const rows = await sql.unsafe( + `UPDATE rinbox.threads SET ${updates.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (rows.length === 0) return c.json({ error: "Thread not found" }, 404); + return c.json(rows[0]); +}); + +// POST /api/threads/:id/comments — add comment +routes.post("/api/threads/:id/comments", async (c) => { + const threadId = c.req.param("id"); + const body = await c.req.json(); + const { text, mentions } = body; + if (!text) return c.json({ error: "text required" }, 400); + + // Ensure thread exists + const thread = await sql.unsafe("SELECT id FROM rinbox.threads WHERE id = $1", [threadId]); + if (thread.length === 0) return c.json({ error: "Thread not found" }, 404); + + // Get or create anonymous user + const user = await sql.unsafe( + `INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous') + ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id` + ); + + const rows = await sql.unsafe( + `INSERT INTO rinbox.comments (thread_id, author_id, body, mentions) + VALUES ($1, $2, $3, $4) RETURNING *`, + [threadId, user[0].id, text, mentions || []] + ); + return c.json(rows[0], 201); +}); + +// ── Approvals API ── + +// GET /api/approvals — list pending approvals +routes.get("/api/approvals", async (c) => { + const { mailbox, status = "PENDING" } = c.req.query(); + let rows; + if (mailbox) { + const mb = await sql.unsafe("SELECT id FROM rinbox.mailboxes WHERE slug = $1", [mailbox]); + if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404); + rows = await sql.unsafe( + `SELECT a.*, (SELECT count(*) FROM rinbox.approval_signatures WHERE approval_id = a.id) as signature_count + FROM rinbox.approvals a WHERE a.mailbox_id = $1 AND a.status = $2 + ORDER BY a.created_at DESC`, + [mb[0].id, status] + ); + } else { + rows = await sql.unsafe( + `SELECT a.*, (SELECT count(*) FROM rinbox.approval_signatures WHERE approval_id = a.id) as signature_count + FROM rinbox.approvals a WHERE a.status = $1 ORDER BY a.created_at DESC LIMIT 50`, + [status] + ); + } + return c.json({ approvals: rows }); +}); + +// POST /api/approvals — create approval draft +routes.post("/api/approvals", async (c) => { + const body = await c.req.json(); + const { mailbox_slug, thread_id, subject, body_text, to_addresses } = body; + if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400); + + const mb = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [mailbox_slug]); + if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404); + + const user = await sql.unsafe( + `INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous') + ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id` + ); + + const rows = await sql.unsafe( + `INSERT INTO rinbox.approvals (mailbox_id, thread_id, author_id, subject, body_text, to_addresses, required_signatures) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [mb[0].id, thread_id || null, user[0].id, subject, body_text || null, JSON.stringify(to_addresses || []), mb[0].approval_threshold || 1] + ); + return c.json(rows[0], 201); +}); + +// POST /api/approvals/:id/sign — sign an approval +routes.post("/api/approvals/:id/sign", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const { vote = "APPROVE" } = body; + if (!["APPROVE", "REJECT"].includes(vote)) return c.json({ error: "Invalid vote" }, 400); + + const approval = await sql.unsafe("SELECT * FROM rinbox.approvals WHERE id = $1", [id]); + if (approval.length === 0) return c.json({ error: "Approval not found" }, 404); + if (approval[0].status !== "PENDING") return c.json({ error: "Approval not pending" }, 400); + + const user = await sql.unsafe( + `INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous') + ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id` + ); + + await sql.unsafe( + `INSERT INTO rinbox.approval_signatures (approval_id, signer_id, vote) + VALUES ($1, $2, $3) + ON CONFLICT (approval_id, signer_id) DO UPDATE SET vote = $3, signed_at = NOW()`, + [id, user[0].id, vote] + ); + + // Check if threshold reached + const sigs = await sql.unsafe( + "SELECT count(*) as cnt FROM rinbox.approval_signatures WHERE approval_id = $1 AND vote = 'APPROVE'", + [id] + ); + const approveCount = parseInt(sigs[0].cnt); + + if (approveCount >= approval[0].required_signatures) { + await sql.unsafe( + "UPDATE rinbox.approvals SET status = 'APPROVED', resolved_at = NOW() WHERE id = $1", + [id] + ); + return c.json({ ok: true, status: "APPROVED", signatures: approveCount }); + } + + // Check for rejection (more rejects than possible remaining approvals) + const rejects = await sql.unsafe( + "SELECT count(*) as cnt FROM rinbox.approval_signatures WHERE approval_id = $1 AND vote = 'REJECT'", + [id] + ); + const rejectCount = parseInt(rejects[0].cnt); + if (rejectCount > 0) { + await sql.unsafe( + "UPDATE rinbox.approvals SET status = 'REJECTED', resolved_at = NOW() WHERE id = $1", + [id] + ); + return c.json({ ok: true, status: "REJECTED", signatures: approveCount }); + } + + return c.json({ ok: true, status: "PENDING", signatures: approveCount, required: approval[0].required_signatures }); +}); + +// ── Workspaces API ── + +// GET /api/workspaces +routes.get("/api/workspaces", async (c) => { + const rows = await sql.unsafe("SELECT * FROM rinbox.workspaces ORDER BY created_at DESC LIMIT 50"); + return c.json({ workspaces: rows }); +}); + +// POST /api/workspaces +routes.post("/api/workspaces", async (c) => { + const body = await c.req.json(); + const { slug, name, description } = body; + if (!slug || !name) return c.json({ error: "slug and name required" }, 400); + + try { + const rows = await sql.unsafe( + `INSERT INTO rinbox.workspaces (slug, name, description, owner_did) + VALUES ($1, $2, $3, $4) RETURNING *`, + [slug, name, description || null, "anonymous"] + ); + return c.json(rows[0], 201); + } catch (e: any) { + if (e.code === "23505") return c.json({ error: "Workspace already exists" }, 409); + throw e; + } +}); + +// GET /api/health +routes.get("/api/health", (c) => c.json({ ok: true })); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Inbox | rSpace`, + moduleId: "inbox", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const inboxModule: RSpaceModule = { + id: "inbox", + name: "rInbox", + icon: "\u{1F4E8}", + description: "Collaborative email with multisig approval", + routes, + standaloneDomain: "rinbox.online", +}; diff --git a/modules/inbox/standalone.ts b/modules/inbox/standalone.ts new file mode 100644 index 0000000..0ef63e3 --- /dev/null +++ b/modules/inbox/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Inbox module. + * Serves rinbox.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { inboxModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/inbox/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", inboxModule.routes); + +console.log(`[rInbox Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/maps/components/folk-map-viewer.ts b/modules/maps/components/folk-map-viewer.ts new file mode 100644 index 0000000..7178a25 --- /dev/null +++ b/modules/maps/components/folk-map-viewer.ts @@ -0,0 +1,268 @@ +/** + * — real-time location sharing map. + * + * Creates/joins map rooms, shows participant locations on a map, + * and provides location sharing controls. + */ + +class FolkMapViewer extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private room = ""; + private view: "lobby" | "map" = "lobby"; + private rooms: string[] = []; + private loading = false; + private error = ""; + private syncStatus: "disconnected" | "connected" = "disconnected"; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.room = this.getAttribute("room") || ""; + if (this.room) { + this.view = "map"; + } + this.checkSyncHealth(); + this.render(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/maps/); + return match ? `/${match[1]}/maps` : ""; + } + + private async checkSyncHealth() { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const data = await res.json(); + this.syncStatus = data.sync !== false ? "connected" : "disconnected"; + } + } catch { + this.syncStatus = "disconnected"; + } + this.render(); + } + + private async loadStats() { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/stats`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const data = await res.json(); + this.rooms = Object.keys(data.rooms || {}); + } + } catch { + this.rooms = []; + } + this.render(); + } + + private joinRoom(slug: string) { + this.room = slug; + this.view = "map"; + this.render(); + } + + private createRoom() { + const name = prompt("Room name (slug):"); + if (!name?.trim()) return; + const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-"); + this.joinRoom(slug); + } + + private render() { + this.shadow.innerHTML = ` + + + ${this.error ? `
${this.esc(this.error)}
` : ""} + ${this.view === "lobby" ? this.renderLobby() : this.renderMap()} + `; + + this.attachListeners(); + } + + private renderLobby(): string { + return ` +
+ Map Rooms + + ${this.syncStatus === "connected" ? "Sync online" : "Sync offline"} + +
+ + ${this.rooms.length > 0 ? this.rooms.map((r) => ` +
+ \u{1F5FA} + ${this.esc(r)} +
+ `).join("") : ""} + +
+

Create or join a map room to share locations

+

Share the room link with friends to see each other on the map in real-time

+
+ `; + } + + private renderMap(): string { + const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`; + return ` +
+ + \u{1F5FA} ${this.esc(this.room)} + +
+ +
+
+

\u{1F30D}

+

Map Room: ${this.esc(this.room)}

+

Connect the MapLibre GL library to display the interactive map.

+

WebSocket sync: ${this.syncStatus}

+
+
+ +
+ + +
+ + + `; + } + + private attachListeners() { + this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom()); + + this.shadow.querySelectorAll("[data-room]").forEach((el) => { + el.addEventListener("click", () => { + const room = (el as HTMLElement).dataset.room!; + this.joinRoom(room); + }); + }); + + this.shadow.querySelectorAll("[data-back]").forEach((el) => { + el.addEventListener("click", () => { + this.view = "lobby"; + this.loadStats(); + }); + }); + + this.shadow.getElementById("share-location")?.addEventListener("click", () => { + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const btn = this.shadow.getElementById("share-location"); + if (btn) { + btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`; + btn.classList.add("sharing"); + } + }, + () => { + this.error = "Location access denied"; + this.render(); + } + ); + } + }); + + const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link"); + copyUrl?.addEventListener("click", () => { + const url = `${window.location.origin}/${this.space}/maps/${this.room}`; + navigator.clipboard.writeText(url).then(() => { + if (copyUrl) copyUrl.textContent = "Copied!"; + setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000); + }); + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-map-viewer", FolkMapViewer); diff --git a/modules/maps/components/maps.css b/modules/maps/components/maps.css new file mode 100644 index 0000000..b0ef802 --- /dev/null +++ b/modules/maps/components/maps.css @@ -0,0 +1,6 @@ +/* Maps module — dark theme */ +folk-map-viewer { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/maps/mod.ts b/modules/maps/mod.ts new file mode 100644 index 0000000..c396faf --- /dev/null +++ b/modules/maps/mod.ts @@ -0,0 +1,165 @@ +/** + * Maps module — real-time collaborative location sharing. + * + * Port of rmaps-online. Rooms are ephemeral (no DB). Proxies to + * the sync server for WebSocket-based location sharing. Embeds + * MapLibre GL for outdoor maps and c3nav for indoor maps. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; + +// ── Proxy: sync server health ── +routes.get("/api/health", async (c) => { + try { + const res = await fetch(`${SYNC_SERVER}/health`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) return c.json(await res.json()); + return c.json({ status: "degraded", sync: false }); + } catch { + return c.json({ status: "degraded", sync: false }); + } +}); + +// ── Proxy: room stats ── +routes.get("/api/stats", async (c) => { + try { + const res = await fetch(`${SYNC_SERVER}/stats`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) return c.json(await res.json()); + } catch {} + return c.json({ rooms: {} }); +}); + +// ── Proxy: push notification VAPID key ── +routes.get("/api/push/vapid-public-key", async (c) => { + try { + const res = await fetch(`${SYNC_SERVER}/push/vapid-public-key`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) return c.json(await res.json()); + } catch {} + return c.json({ error: "Push not available" }, 503); +}); + +// ── Proxy: push subscribe/unsubscribe ── +routes.post("/api/push/subscribe", async (c) => { + const body = await c.req.json(); + const res = await fetch(`${SYNC_SERVER}/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/push/unsubscribe", async (c) => { + const body = await c.req.json(); + const res = await fetch(`${SYNC_SERVER}/push/unsubscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return c.json(await res.json(), res.status as any); +}); + +// ── Proxy: request location (ping) ── +routes.post("/api/push/request-location", async (c) => { + const body = await c.req.json(); + const res = await fetch(`${SYNC_SERVER}/push/request-location`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return c.json(await res.json(), res.status as any); +}); + +// ── Proxy: routing (OSRM + c3nav) ── +routes.post("/api/routing", async (c) => { + const body = await c.req.json(); + const { from, to, mode = "walking" } = body; + + if (!from?.lat || !from?.lng || !to?.lat || !to?.lng) { + return c.json({ error: "from and to with lat/lng required" }, 400); + } + + // Use OSRM for outdoor routing + const profile = mode === "driving" ? "car" : "foot"; + try { + const res = await fetch( + `https://router.project-osrm.org/route/v1/${profile}/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson&steps=true`, + { signal: AbortSignal.timeout(10000) } + ); + if (res.ok) { + const data = await res.json(); + return c.json(data); + } + } catch {} + return c.json({ error: "Routing failed" }, 502); +}); + +// ── Proxy: c3nav API ── +const VALID_C3NAV_EVENTS = ["39c3", "38c3", "37c3", "eh22", "eh2025", "camp2023"]; +const ALLOWED_C3NAV_ENDPOINTS = ["map/settings", "map/bounds", "map/locations", "map/locations/full", "map/projection"]; + +routes.get("/api/c3nav/:event", async (c) => { + const event = c.req.param("event"); + const endpoint = c.req.query("endpoint") || "map/bounds"; + + if (!VALID_C3NAV_EVENTS.includes(event)) return c.json({ error: "Invalid event" }, 400); + const isAllowed = ALLOWED_C3NAV_ENDPOINTS.some((a) => endpoint === a || endpoint.startsWith(a + "/")); + if (!isAllowed && !endpoint.startsWith("map/locations/")) return c.json({ error: "Endpoint not allowed" }, 403); + + try { + const res = await fetch(`https://${event}.c3nav.de/api/v2/${endpoint}/`, { + headers: { "X-API-Key": "anonymous", Accept: "application/json", "User-Agent": "rMaps/1.0" }, + signal: AbortSignal.timeout(5000), + }); + if (res.ok) return c.json(await res.json()); + return c.json({ error: "c3nav API error" }, res.status as any); + } catch { + return c.json({ error: "c3nav unreachable" }, 502); + } +}); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Maps | rSpace`, + moduleId: "maps", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +// Room-specific page +routes.get("/:room", (c) => { + const space = c.req.param("space") || "demo"; + const room = c.req.param("room"); + return c.html(renderShell({ + title: `${room} — Maps | rSpace`, + moduleId: "maps", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const mapsModule: RSpaceModule = { + id: "maps", + name: "rMaps", + icon: "\u{1F5FA}", + description: "Real-time collaborative location sharing and indoor/outdoor maps", + routes, + standaloneDomain: "rmaps.online", +}; diff --git a/modules/maps/standalone.ts b/modules/maps/standalone.ts new file mode 100644 index 0000000..928f286 --- /dev/null +++ b/modules/maps/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Maps module. + * Serves rmaps.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { mapsModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/maps/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", mapsModule.routes); + +console.log(`[rMaps Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/network/components/folk-graph-viewer.ts b/modules/network/components/folk-graph-viewer.ts new file mode 100644 index 0000000..3369940 --- /dev/null +++ b/modules/network/components/folk-graph-viewer.ts @@ -0,0 +1,162 @@ +/** + * — community relationship graph. + * + * Displays network nodes (people, companies, opportunities) + * and edges in a force-directed layout with search and filtering. + */ + +class FolkGraphViewer extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private workspaces: any[] = []; + private info: any = null; + private filter: "all" | "person" | "company" | "opportunity" = "all"; + private searchQuery = ""; + private error = ""; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadData(); + this.render(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/network/); + return match ? `/${match[1]}/network` : ""; + } + + private async loadData() { + const base = this.getApiBase(); + try { + const [wsRes, infoRes] = await Promise.all([ + fetch(`${base}/api/workspaces`), + fetch(`${base}/api/info`), + ]); + if (wsRes.ok) this.workspaces = await wsRes.json(); + if (infoRes.ok) this.info = await infoRes.json(); + } catch { /* offline */ } + this.render(); + } + + private render() { + this.shadow.innerHTML = ` + + + ${this.error ? `
${this.esc(this.error)}
` : ""} + +
+ \u{1F310} Network Graph +
+ +
+ + ${(["all", "person", "company", "opportunity"] as const).map(f => + `` + ).join("")} +
+ +
+
+

\u{1F578}\u{FE0F}

+

Community Relationship Graph

+

Connect the force-directed layout engine to visualize your network.

+

Automerge CRDT sync + d3-force layout

+
+
+ +
+
People
+
Companies
+
Opportunities
+
+ + ${this.workspaces.length > 0 ? ` +
Workspaces
+
+ ${this.workspaces.map(ws => ` +
+
${this.esc(ws.name || ws.slug)}
+
${ws.nodeCount || 0} nodes \u00B7 ${ws.edgeCount || 0} edges
+
+ `).join("")} +
+ ` : ""} + `; + this.attachListeners(); + } + + private attachListeners() { + this.shadow.querySelectorAll("[data-filter]").forEach(el => { + el.addEventListener("click", () => { + this.filter = (el as HTMLElement).dataset.filter as any; + this.render(); + }); + }); + this.shadow.getElementById("search-input")?.addEventListener("input", (e) => { + this.searchQuery = (e.target as HTMLInputElement).value; + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-graph-viewer", FolkGraphViewer); diff --git a/modules/network/components/network.css b/modules/network/components/network.css new file mode 100644 index 0000000..e0cef2d --- /dev/null +++ b/modules/network/components/network.css @@ -0,0 +1,6 @@ +/* Network module — dark theme */ +folk-graph-viewer { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/network/mod.ts b/modules/network/mod.ts new file mode 100644 index 0000000..a12be09 --- /dev/null +++ b/modules/network/mod.ts @@ -0,0 +1,64 @@ +/** + * Network module — community relationship graph viewer. + * + * Visualizes CRM data as interactive force-directed graphs. + * Nodes: people, companies, opportunities. Edges: relationships. + * Syncs from Twenty CRM via API proxy. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +// No database — network data lives in Automerge CRDT docs or is proxied from Twenty CRM. +// The existing rNetwork-online at /opt/apps/rNetwork-online/ already uses Bun+Hono+Automerge. + +// ── API: Health ── +routes.get("/api/health", (c) => { + return c.json({ ok: true, module: "network", sync: true }); +}); + +// ── API: Graph info (placeholder — real data from Automerge) ── +routes.get("/api/info", (c) => { + return c.json({ + module: "network", + description: "Community relationship graph visualization", + entityTypes: ["person", "company", "opportunity"], + features: ["force-directed layout", "CRM sync", "real-time collaboration"], + }); +}); + +// ── API: Workspaces ── +routes.get("/api/workspaces", (c) => { + // In production, this would scan Automerge graph docs + return c.json([ + { slug: "demo", name: "Demo Network", nodeCount: 0, edgeCount: 0 }, + ]); +}); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Network | rSpace`, + moduleId: "network", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const networkModule: RSpaceModule = { + id: "network", + name: "rNetwork", + icon: "\u{1F310}", + description: "Community relationship graph visualization with CRM sync", + routes, + standaloneDomain: "rnetwork.online", +}; diff --git a/modules/network/standalone.ts b/modules/network/standalone.ts new file mode 100644 index 0000000..1262620 --- /dev/null +++ b/modules/network/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Network module. + * Serves rnetwork.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { networkModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/network/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", networkModule.routes); + +console.log(`[rNetwork Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/notes/components/folk-notes-app.ts b/modules/notes/components/folk-notes-app.ts new file mode 100644 index 0000000..22bd3fc --- /dev/null +++ b/modules/notes/components/folk-notes-app.ts @@ -0,0 +1,360 @@ +/** + * — notebook and note management. + * + * Browse notebooks, create/edit notes with rich text, + * search, tag management. + */ + +interface Notebook { + id: string; + title: string; + description: string; + cover_color: string; + note_count: string; + updated_at: string; +} + +interface Note { + id: string; + title: string; + content: string; + content_plain: string; + type: string; + tags: string[] | null; + is_pinned: boolean; + created_at: string; + updated_at: string; +} + +class FolkNotesApp extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private view: "notebooks" | "notebook" | "note" = "notebooks"; + private notebooks: Notebook[] = []; + private selectedNotebook: (Notebook & { notes: Note[] }) | null = null; + private selectedNote: Note | null = null; + private searchQuery = ""; + private searchResults: Note[] = []; + private loading = false; + private error = ""; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadNotebooks(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/notes/); + return match ? `/${match[1]}/notes` : ""; + } + + private async loadNotebooks() { + this.loading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/notebooks`); + const data = await res.json(); + this.notebooks = data.notebooks || []; + } catch { + this.error = "Failed to load notebooks"; + } + this.loading = false; + this.render(); + } + + private async loadNotebook(id: string) { + this.loading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/notebooks/${id}`); + this.selectedNotebook = await res.json(); + } catch { + this.error = "Failed to load notebook"; + } + this.loading = false; + this.render(); + } + + private async loadNote(id: string) { + this.loading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/notes/${id}`); + this.selectedNote = await res.json(); + } catch { + this.error = "Failed to load note"; + } + this.loading = false; + this.render(); + } + + private async searchNotes(query: string) { + if (!query.trim()) { + this.searchResults = []; + this.render(); + return; + } + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`); + const data = await res.json(); + this.searchResults = data.notes || []; + } catch { + this.searchResults = []; + } + this.render(); + } + + private async createNotebook() { + const title = prompt("Notebook name:"); + if (!title?.trim()) return; + try { + const base = this.getApiBase(); + await fetch(`${base}/api/notebooks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + await this.loadNotebooks(); + } catch { + this.error = "Failed to create notebook"; + this.render(); + } + } + + private getNoteIcon(type: string): string { + switch (type) { + case "NOTE": return "\u{1F4DD}"; + case "CODE": return "\u{1F4BB}"; + case "BOOKMARK": return "\u{1F517}"; + case "IMAGE": return "\u{1F5BC}"; + case "AUDIO": return "\u{1F3A4}"; + case "FILE": return "\u{1F4CE}"; + case "CLIP": return "\u2702\uFE0F"; + default: return "\u{1F4C4}"; + } + } + + private formatDate(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString(); + } + + private render() { + this.shadow.innerHTML = ` + + + ${this.error ? `
${this.esc(this.error)}
` : ""} + ${this.loading ? '
Loading...
' : ""} + ${!this.loading ? this.renderView() : ""} + `; + + this.attachListeners(); + } + + private renderView(): string { + if (this.view === "note" && this.selectedNote) return this.renderNote(); + if (this.view === "notebook" && this.selectedNotebook) return this.renderNotebook(); + return this.renderNotebooks(); + } + + private renderNotebooks(): string { + return ` +
+ Notebooks + +
+ + + ${this.searchQuery && this.searchResults.length > 0 ? ` +
${this.searchResults.length} results for "${this.esc(this.searchQuery)}"
+ ${this.searchResults.map((n) => this.renderNoteItem(n)).join("")} + ` : ""} + + ${!this.searchQuery ? ` +
+ ${this.notebooks.map((nb) => ` +
+
+
${this.esc(nb.title)}
+
${this.esc(nb.description || "")}
+
+
${nb.note_count} notes · ${this.formatDate(nb.updated_at)}
+
+ `).join("")} +
+ ${this.notebooks.length === 0 ? '
No notebooks yet. Create one to get started.
' : ""} + ` : ""} + `; + } + + private renderNotebook(): string { + const nb = this.selectedNotebook!; + return ` +
+ + ${this.esc(nb.title)} +
+ ${nb.notes && nb.notes.length > 0 + ? nb.notes.map((n) => this.renderNoteItem(n)).join("") + : '
No notes in this notebook.
' + } + `; + } + + private renderNoteItem(n: Note): string { + return ` +
+ ${this.getNoteIcon(n.type)} +
+
${n.is_pinned ? '📌 ' : ""}${this.esc(n.title)}
+
${this.esc(n.content_plain || "")}
+
+ ${this.formatDate(n.updated_at)} + ${n.type} + ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} +
+
+
+ `; + } + + private renderNote(): string { + const n = this.selectedNote!; + return ` +
+ + ${this.getNoteIcon(n.type)} ${this.esc(n.title)} +
+
${n.content || 'Empty note'}
+
+ Type: ${n.type} + Created: ${this.formatDate(n.created_at)} + Updated: ${this.formatDate(n.updated_at)} + ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} +
+ `; + } + + private attachListeners() { + // Create notebook + this.shadow.getElementById("create-notebook")?.addEventListener("click", () => this.createNotebook()); + + // Search + const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; + let searchTimeout: any; + searchInput?.addEventListener("input", () => { + clearTimeout(searchTimeout); + this.searchQuery = searchInput.value; + searchTimeout = setTimeout(() => this.searchNotes(this.searchQuery), 300); + }); + + // Notebook cards + this.shadow.querySelectorAll("[data-notebook]").forEach((el) => { + el.addEventListener("click", () => { + const id = (el as HTMLElement).dataset.notebook!; + this.view = "notebook"; + this.loadNotebook(id); + }); + }); + + // Note items + this.shadow.querySelectorAll("[data-note]").forEach((el) => { + el.addEventListener("click", () => { + const id = (el as HTMLElement).dataset.note!; + this.view = "note"; + this.loadNote(id); + }); + }); + + // Back buttons + this.shadow.querySelectorAll("[data-back]").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const target = (el as HTMLElement).dataset.back; + if (target === "notebooks") { this.view = "notebooks"; this.render(); } + else if (target === "notebook") { this.view = "notebook"; this.render(); } + }); + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-notes-app", FolkNotesApp); diff --git a/modules/notes/components/notes.css b/modules/notes/components/notes.css new file mode 100644 index 0000000..9bd2574 --- /dev/null +++ b/modules/notes/components/notes.css @@ -0,0 +1,6 @@ +/* Notes module — dark theme */ +folk-notes-app { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/notes/db/schema.sql b/modules/notes/db/schema.sql new file mode 100644 index 0000000..54ffb00 --- /dev/null +++ b/modules/notes/db/schema.sql @@ -0,0 +1,72 @@ +-- rNotes module schema +CREATE SCHEMA IF NOT EXISTS rnotes; + +-- Users +CREATE TABLE IF NOT EXISTS rnotes.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT UNIQUE NOT NULL, + username TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Notebooks +CREATE TABLE IF NOT EXISTS rnotes.notebooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL DEFAULT 'Untitled Notebook', + slug TEXT, + description TEXT, + cover_color TEXT DEFAULT '#3b82f6', + is_public BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Notebook collaborators +CREATE TABLE IF NOT EXISTS rnotes.notebook_collaborators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES rnotes.users(id) ON DELETE CASCADE, + notebook_id UUID NOT NULL REFERENCES rnotes.notebooks(id) ON DELETE CASCADE, + role TEXT DEFAULT 'EDITOR' CHECK (role IN ('OWNER','EDITOR','VIEWER')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, notebook_id) +); + +-- Tags +CREATE TABLE IF NOT EXISTS rnotes.tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT UNIQUE NOT NULL +); + +-- Notes +CREATE TABLE IF NOT EXISTS rnotes.notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + notebook_id UUID REFERENCES rnotes.notebooks(id) ON DELETE CASCADE, + author_id UUID REFERENCES rnotes.users(id), + title TEXT NOT NULL DEFAULT 'Untitled', + content TEXT DEFAULT '', + content_plain TEXT, + type TEXT DEFAULT 'NOTE' CHECK (type IN ('NOTE','CLIP','BOOKMARK','CODE','IMAGE','FILE','AUDIO')), + url TEXT, + language TEXT, + file_url TEXT, + mime_type TEXT, + file_size INTEGER, + duration INTEGER, + is_pinned BOOLEAN DEFAULT FALSE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Note tags (many-to-many) +CREATE TABLE IF NOT EXISTS rnotes.note_tags ( + note_id UUID NOT NULL REFERENCES rnotes.notes(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES rnotes.tags(id) ON DELETE CASCADE, + PRIMARY KEY (note_id, tag_id) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_notes_notebook ON rnotes.notes(notebook_id); +CREATE INDEX IF NOT EXISTS idx_notes_author ON rnotes.notes(author_id); +CREATE INDEX IF NOT EXISTS idx_notes_type ON rnotes.notes(type); +CREATE INDEX IF NOT EXISTS idx_collaborators_notebook ON rnotes.notebook_collaborators(notebook_id); diff --git a/modules/notes/mod.ts b/modules/notes/mod.ts new file mode 100644 index 0000000..1d5f80f --- /dev/null +++ b/modules/notes/mod.ts @@ -0,0 +1,274 @@ +/** + * Notes module — notebooks, rich-text notes, voice transcription. + * + * Port of rnotes-online (Next.js + Prisma → Hono + postgres.js). + * Supports multiple note types: text, code, bookmark, audio, image, file. + */ + +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +// ── DB initialization ── +const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); + +async function initDB() { + try { + await sql.unsafe(SCHEMA_SQL); + console.log("[Notes] DB schema initialized"); + } catch (e) { + console.error("[Notes] DB init error:", e); + } +} + +initDB(); + +// ── Helper: get or create user ── +async function getOrCreateUser(did: string, username?: string) { + const rows = await sql.unsafe( + `INSERT INTO rnotes.users (did, username) VALUES ($1, $2) + ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rnotes.users.username) + RETURNING *`, + [did, username || null] + ); + return rows[0]; +} + +// ── Notebooks API ── + +// GET /api/notebooks — list notebooks +routes.get("/api/notebooks", async (c) => { + const rows = await sql.unsafe( + `SELECT n.*, count(note.id) as note_count + FROM rnotes.notebooks n + LEFT JOIN rnotes.notes note ON note.notebook_id = n.id + GROUP BY n.id + ORDER BY n.updated_at DESC LIMIT 50` + ); + return c.json({ notebooks: rows }); +}); + +// POST /api/notebooks — create notebook +routes.post("/api/notebooks", async (c) => { + const body = await c.req.json(); + const { title, description, cover_color } = body; + + const rows = await sql.unsafe( + `INSERT INTO rnotes.notebooks (title, description, cover_color) + VALUES ($1, $2, $3) RETURNING *`, + [title || "Untitled Notebook", description || null, cover_color || "#3b82f6"] + ); + return c.json(rows[0], 201); +}); + +// GET /api/notebooks/:id — notebook detail with notes +routes.get("/api/notebooks/:id", async (c) => { + const id = c.req.param("id"); + const nb = await sql.unsafe("SELECT * FROM rnotes.notebooks WHERE id = $1", [id]); + if (nb.length === 0) return c.json({ error: "Notebook not found" }, 404); + + const notes = await sql.unsafe( + `SELECT n.*, array_agg(t.name) FILTER (WHERE t.name IS NOT NULL) as tags + FROM rnotes.notes n + LEFT JOIN rnotes.note_tags nt ON nt.note_id = n.id + LEFT JOIN rnotes.tags t ON t.id = nt.tag_id + WHERE n.notebook_id = $1 + GROUP BY n.id + ORDER BY n.is_pinned DESC, n.sort_order ASC, n.updated_at DESC`, + [id] + ); + return c.json({ ...nb[0], notes }); +}); + +// PUT /api/notebooks/:id — update notebook +routes.put("/api/notebooks/:id", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const { title, description, cover_color, is_public } = body; + + const fields: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; } + if (description !== undefined) { fields.push(`description = $${idx}`); params.push(description); idx++; } + if (cover_color !== undefined) { fields.push(`cover_color = $${idx}`); params.push(cover_color); idx++; } + if (is_public !== undefined) { fields.push(`is_public = $${idx}`); params.push(is_public); idx++; } + + if (fields.length === 0) return c.json({ error: "No fields to update" }, 400); + fields.push("updated_at = NOW()"); + params.push(id); + + const rows = await sql.unsafe( + `UPDATE rnotes.notebooks SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (rows.length === 0) return c.json({ error: "Notebook not found" }, 404); + return c.json(rows[0]); +}); + +// DELETE /api/notebooks/:id +routes.delete("/api/notebooks/:id", async (c) => { + const result = await sql.unsafe( + "DELETE FROM rnotes.notebooks WHERE id = $1 RETURNING id", + [c.req.param("id")] + ); + if (result.length === 0) return c.json({ error: "Notebook not found" }, 404); + return c.json({ ok: true }); +}); + +// ── Notes API ── + +// GET /api/notes — list all notes (query: notebook_id, type, q) +routes.get("/api/notes", async (c) => { + const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query(); + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (notebook_id) { conditions.push(`n.notebook_id = $${idx}`); params.push(notebook_id); idx++; } + if (type) { conditions.push(`n.type = $${idx}`); params.push(type); idx++; } + if (q) { + conditions.push(`(n.title ILIKE $${idx} OR n.content_plain ILIKE $${idx})`); + params.push(`%${q}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const rows = await sql.unsafe( + `SELECT n.*, array_agg(t.name) FILTER (WHERE t.name IS NOT NULL) as tags + FROM rnotes.notes n + LEFT JOIN rnotes.note_tags nt ON nt.note_id = n.id + LEFT JOIN rnotes.tags t ON t.id = nt.tag_id + ${where} + GROUP BY n.id + ORDER BY n.is_pinned DESC, n.updated_at DESC + LIMIT ${Math.min(parseInt(limit), 100)} OFFSET ${parseInt(offset) || 0}`, + params + ); + return c.json({ notes: rows }); +}); + +// POST /api/notes — create note +routes.post("/api/notes", async (c) => { + const body = await c.req.json(); + const { notebook_id, title, content, type, url, language, file_url, mime_type, file_size, duration, tags } = body; + + if (!title?.trim()) return c.json({ error: "Title is required" }, 400); + + // Strip HTML for plain text search + const contentPlain = content ? content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() : null; + + const rows = await sql.unsafe( + `INSERT INTO rnotes.notes (notebook_id, title, content, content_plain, type, url, language, file_url, mime_type, file_size, duration) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, + [notebook_id || null, title.trim(), content || "", contentPlain, type || "NOTE", + url || null, language || null, file_url || null, mime_type || null, file_size || null, duration || null] + ); + + // Handle tags + if (tags && Array.isArray(tags)) { + for (const tagName of tags) { + const name = tagName.trim().toLowerCase(); + if (!name) continue; + const tag = await sql.unsafe( + "INSERT INTO rnotes.tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = $1 RETURNING id", + [name] + ); + await sql.unsafe( + "INSERT INTO rnotes.note_tags (note_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + [rows[0].id, tag[0].id] + ); + } + } + + return c.json(rows[0], 201); +}); + +// GET /api/notes/:id — note detail +routes.get("/api/notes/:id", async (c) => { + const id = c.req.param("id"); + const rows = await sql.unsafe( + `SELECT n.*, array_agg(t.name) FILTER (WHERE t.name IS NOT NULL) as tags + FROM rnotes.notes n + LEFT JOIN rnotes.note_tags nt ON nt.note_id = n.id + LEFT JOIN rnotes.tags t ON t.id = nt.tag_id + WHERE n.id = $1 + GROUP BY n.id`, + [id] + ); + if (rows.length === 0) return c.json({ error: "Note not found" }, 404); + return c.json(rows[0]); +}); + +// PUT /api/notes/:id — update note +routes.put("/api/notes/:id", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const { title, content, type, url, language, is_pinned, sort_order } = body; + + const fields: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; } + if (content !== undefined) { + fields.push(`content = $${idx}`); params.push(content); idx++; + const plain = content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + fields.push(`content_plain = $${idx}`); params.push(plain); idx++; + } + if (type !== undefined) { fields.push(`type = $${idx}`); params.push(type); idx++; } + if (url !== undefined) { fields.push(`url = $${idx}`); params.push(url); idx++; } + if (language !== undefined) { fields.push(`language = $${idx}`); params.push(language); idx++; } + if (is_pinned !== undefined) { fields.push(`is_pinned = $${idx}`); params.push(is_pinned); idx++; } + if (sort_order !== undefined) { fields.push(`sort_order = $${idx}`); params.push(sort_order); idx++; } + + if (fields.length === 0) return c.json({ error: "No fields to update" }, 400); + fields.push("updated_at = NOW()"); + params.push(id); + + const rows = await sql.unsafe( + `UPDATE rnotes.notes SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (rows.length === 0) return c.json({ error: "Note not found" }, 404); + return c.json(rows[0]); +}); + +// DELETE /api/notes/:id +routes.delete("/api/notes/:id", async (c) => { + const result = await sql.unsafe("DELETE FROM rnotes.notes WHERE id = $1 RETURNING id", [c.req.param("id")]); + if (result.length === 0) return c.json({ error: "Note not found" }, 404); + return c.json({ ok: true }); +}); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Notes | rSpace`, + moduleId: "notes", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const notesModule: RSpaceModule = { + id: "notes", + name: "rNotes", + icon: "\u{1F4DD}", + description: "Notebooks with rich-text notes, voice transcription, and collaboration", + routes, + standaloneDomain: "rnotes.online", +}; diff --git a/modules/notes/standalone.ts b/modules/notes/standalone.ts new file mode 100644 index 0000000..3541db8 --- /dev/null +++ b/modules/notes/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Notes module. + * Serves rnotes.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { notesModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/notes/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", notesModule.routes); + +console.log(`[rNotes Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/providers/mod.ts b/modules/providers/mod.ts index e2b86c9..526dddb 100644 --- a/modules/providers/mod.ts +++ b/modules/providers/mod.ts @@ -353,7 +353,7 @@ routes.get("/", (c) => { export const providersModule: RSpaceModule = { id: "providers", - name: "Providers", + name: "rProviders", icon: "\u{1F3ED}", description: "Local provider directory for cosmolocal production", routes, diff --git a/modules/swag/mod.ts b/modules/swag/mod.ts index 1aa7ed6..751db60 100644 --- a/modules/swag/mod.ts +++ b/modules/swag/mod.ts @@ -242,7 +242,7 @@ routes.get("/", (c) => { export const swagModule: RSpaceModule = { id: "swag", - name: "Swag", + name: "rSwag", icon: "\u{1F3A8}", description: "Design print-ready swag: stickers, posters, tees", routes, diff --git a/modules/trips/components/folk-trips-planner.ts b/modules/trips/components/folk-trips-planner.ts new file mode 100644 index 0000000..0a28dfb --- /dev/null +++ b/modules/trips/components/folk-trips-planner.ts @@ -0,0 +1,287 @@ +/** + * — collaborative trip planning dashboard. + * + * Views: trip list → trip detail (tabs: overview, destinations, + * itinerary, bookings, expenses, packing). + */ + +class FolkTripsPlanner extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private view: "list" | "detail" = "list"; + private trips: any[] = []; + private trip: any = null; + private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview"; + private error = ""; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadTrips(); + this.render(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/trips/); + return match ? `/${match[1]}/trips` : ""; + } + + private async loadTrips() { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/trips`); + if (res.ok) this.trips = await res.json(); + } catch { this.trips = []; } + this.render(); + } + + private async loadTrip(id: string) { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/trips/${id}`); + if (res.ok) this.trip = await res.json(); + } catch { this.error = "Failed to load trip"; } + this.render(); + } + + private async createTrip() { + const title = prompt("Trip name:"); + if (!title?.trim()) return; + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/trips`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: title.trim() }), + }); + if (res.ok) this.loadTrips(); + } catch { this.error = "Failed to create trip"; this.render(); } + } + + private render() { + this.shadow.innerHTML = ` + + + ${this.error ? `
${this.esc(this.error)}
` : ""} + ${this.view === "list" ? this.renderList() : this.renderDetail()} + `; + this.attachListeners(); + } + + private renderList(): string { + return ` +
+ \u2708\uFE0F My Trips + +
+ ${this.trips.length > 0 ? `
+ ${this.trips.map(t => ` +
+
${this.esc(t.title)}
+
+ ${t.destination_count || 0} destinations \u00B7 + ${t.start_date ? new Date(t.start_date).toLocaleDateString() : "No dates"} +
+ ${t.budget_total ? `
Budget: $${parseFloat(t.budget_total).toFixed(0)} \u00B7 Spent: $${parseFloat(t.total_spent || 0).toFixed(0)}
` : ""} + ${t.status || "PLANNING"} +
+ `).join("")} +
` : `
+

No trips yet

+

Start planning your next adventure

+
`} + `; + } + + private renderDetail(): string { + if (!this.trip) return '
Loading...
'; + const t = this.trip; + return ` +
+ + \u2708\uFE0F ${this.esc(t.title)} + ${t.status || "PLANNING"} +
+
+ ${(["overview", "destinations", "itinerary", "bookings", "expenses", "packing"] as const).map(tab => + `` + ).join("")} +
+ ${this.renderTab()} + `; + } + + private renderTab(): string { + const t = this.trip; + switch (this.tab) { + case "overview": { + const spent = (t.expenses || []).reduce((s: number, e: any) => s + parseFloat(e.amount || 0), 0); + const pct = t.budget_total ? Math.min(100, (spent / parseFloat(t.budget_total)) * 100) : 0; + return ` +
Trip Details
+
${t.description || "No description"}
+ ${t.start_date ? `
Dates: ${new Date(t.start_date).toLocaleDateString()} \u2014 ${t.end_date ? new Date(t.end_date).toLocaleDateString() : "open"}
` : ""} + ${t.budget_total ? ` +
Budget
+
+
+ $${spent.toFixed(0)} spent + $${parseFloat(t.budget_total).toFixed(0)} budget +
+
+
+ ` : ""} +
Summary
+
${(t.destinations || []).length} destinations \u00B7 ${(t.itinerary || []).length} activities \u00B7 ${(t.bookings || []).length} bookings \u00B7 ${(t.packing || []).length} packing items
+ `; + } + case "destinations": + return (t.destinations || []).length > 0 + ? (t.destinations || []).map((d: any) => ` +
+ \u{1F4CD} +
+
${this.esc(d.name)}
+
${d.country || ""} ${d.arrival_date ? `\u00B7 ${new Date(d.arrival_date).toLocaleDateString()}` : ""}
+
+
+ `).join("") + : '
No destinations added yet
'; + case "itinerary": + return (t.itinerary || []).length > 0 + ? (t.itinerary || []).map((i: any) => ` +
+ ${i.category || "ACTIVITY"} +
+
${this.esc(i.title)}
+
${i.date ? new Date(i.date).toLocaleDateString() : ""} ${i.start_time || ""}
+
+
+ `).join("") + : '
No itinerary items yet
'; + case "bookings": + return (t.bookings || []).length > 0 + ? (t.bookings || []).map((b: any) => ` +
+ ${b.type || "OTHER"} +
+
${this.esc(b.provider || "Booking")}
+
${b.confirmation_number ? `#${b.confirmation_number}` : ""} ${b.cost ? `\u00B7 $${parseFloat(b.cost).toFixed(0)}` : ""}
+
+
+ `).join("") + : '
No bookings yet
'; + case "expenses": + return (t.expenses || []).length > 0 + ? (t.expenses || []).map((e: any) => ` +
+ ${e.category || "OTHER"} +
+
${this.esc(e.description)}
+
${e.date ? new Date(e.date).toLocaleDateString() : ""}
+
+ $${parseFloat(e.amount).toFixed(2)} +
+ `).join("") + : '
No expenses recorded yet
'; + case "packing": + return (t.packing || []).length > 0 + ? `
${(t.packing || []).map((p: any) => ` +
+ + ${this.esc(p.name)} + ${p.category} + ${p.quantity > 1 ? `x${p.quantity}` : ""} +
+ `).join("")}
` + : '
No packing items yet
'; + default: return ""; + } + } + + private attachListeners() { + this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip()); + + this.shadow.querySelectorAll("[data-trip]").forEach(el => { + el.addEventListener("click", () => { + this.view = "detail"; + this.tab = "overview"; + this.loadTrip((el as HTMLElement).dataset.trip!); + }); + }); + this.shadow.querySelectorAll("[data-back]").forEach(el => { + el.addEventListener("click", () => { this.view = "list"; this.loadTrips(); }); + }); + this.shadow.querySelectorAll("[data-tab]").forEach(el => { + el.addEventListener("click", () => { + this.tab = (el as HTMLElement).dataset.tab as any; + this.render(); + }); + }); + this.shadow.querySelectorAll("[data-pack]").forEach(el => { + el.addEventListener("change", async () => { + const checkbox = el as HTMLInputElement; + try { + const base = this.getApiBase(); + await fetch(`${base}/api/packing/${checkbox.dataset.pack}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ packed: checkbox.checked }), + }); + } catch {} + }); + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-trips-planner", FolkTripsPlanner); diff --git a/modules/trips/components/trips.css b/modules/trips/components/trips.css new file mode 100644 index 0000000..9c2ef99 --- /dev/null +++ b/modules/trips/components/trips.css @@ -0,0 +1,6 @@ +/* Trips module — dark theme */ +folk-trips-planner { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/trips/db/schema.sql b/modules/trips/db/schema.sql new file mode 100644 index 0000000..ce9f872 --- /dev/null +++ b/modules/trips/db/schema.sql @@ -0,0 +1,108 @@ +-- rTrips module schema +CREATE SCHEMA IF NOT EXISTS rtrips; + +CREATE TABLE IF NOT EXISTS rtrips.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT UNIQUE NOT NULL, + username TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rtrips.trips ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT, + start_date DATE, + end_date DATE, + budget_total NUMERIC(12,2), + budget_currency TEXT DEFAULT 'USD', + status TEXT DEFAULT 'PLANNING' CHECK (status IN ('PLANNING','BOOKED','IN_PROGRESS','COMPLETED','CANCELLED')), + created_by UUID REFERENCES rtrips.users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rtrips.trip_collaborators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trip_id UUID NOT NULL REFERENCES rtrips.trips(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES rtrips.users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('OWNER','EDITOR','VIEWER','MEMBER')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(trip_id, user_id) +); + +CREATE TABLE IF NOT EXISTS rtrips.destinations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trip_id UUID NOT NULL REFERENCES rtrips.trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + country TEXT, + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + arrival_date DATE, + departure_date DATE, + notes TEXT, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rtrips.itinerary_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trip_id UUID NOT NULL REFERENCES rtrips.trips(id) ON DELETE CASCADE, + destination_id UUID REFERENCES rtrips.destinations(id) ON DELETE SET NULL, + title TEXT NOT NULL, + category TEXT DEFAULT 'ACTIVITY' CHECK (category IN ('FLIGHT','TRANSPORT','ACCOMMODATION','ACTIVITY','MEAL','FREE_TIME','OTHER')), + date DATE, + start_time TIME, + end_time TIME, + notes TEXT, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rtrips.bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trip_id UUID NOT NULL REFERENCES rtrips.trips(id) ON DELETE CASCADE, + type TEXT DEFAULT 'OTHER' CHECK (type IN ('FLIGHT','HOTEL','CAR_RENTAL','TRAIN','BUS','FERRY','ACTIVITY','RESTAURANT','OTHER')), + provider TEXT, + confirmation_number TEXT, + cost NUMERIC(12,2), + currency TEXT DEFAULT 'USD', + start_date DATE, + end_date DATE, + status TEXT DEFAULT 'PENDING' CHECK (status IN ('PENDING','CONFIRMED','CANCELLED')), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rtrips.expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trip_id UUID NOT NULL REFERENCES rtrips.trips(id) ON DELETE CASCADE, + paid_by UUID REFERENCES rtrips.users(id), + description TEXT NOT NULL, + amount NUMERIC(12,2) NOT NULL, + currency TEXT DEFAULT 'USD', + category TEXT DEFAULT 'OTHER' CHECK (category IN ('FOOD','TRANSPORT','ACCOMMODATION','ACTIVITIES','SHOPPING','OTHER')), + date DATE, + split_type TEXT DEFAULT 'EQUAL' CHECK (split_type IN ('EQUAL','CUSTOM','INDIVIDUAL')), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rtrips.packing_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trip_id UUID NOT NULL REFERENCES rtrips.trips(id) ON DELETE CASCADE, + added_by UUID REFERENCES rtrips.users(id), + name TEXT NOT NULL, + category TEXT DEFAULT 'GENERAL', + packed BOOLEAN DEFAULT FALSE, + quantity INT DEFAULT 1, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_rtrips_destinations ON rtrips.destinations(trip_id, sort_order); +CREATE INDEX IF NOT EXISTS idx_rtrips_itinerary ON rtrips.itinerary_items(trip_id, date, sort_order); +CREATE INDEX IF NOT EXISTS idx_rtrips_bookings ON rtrips.bookings(trip_id, start_date); +CREATE INDEX IF NOT EXISTS idx_rtrips_expenses ON rtrips.expenses(trip_id, date DESC); +CREATE INDEX IF NOT EXISTS idx_rtrips_packing ON rtrips.packing_items(trip_id, category, sort_order); +CREATE INDEX IF NOT EXISTS idx_rtrips_collaborators ON rtrips.trip_collaborators(user_id); diff --git a/modules/trips/mod.ts b/modules/trips/mod.ts new file mode 100644 index 0000000..345459c --- /dev/null +++ b/modules/trips/mod.ts @@ -0,0 +1,216 @@ +/** + * Trips module — collaborative trip planner. + * + * Plan trips with destinations, itinerary, bookings, expenses, + * and packing lists. Collaborative with role-based access. + */ + +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +// ── DB initialization ── +const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); + +async function initDB() { + try { + await sql.unsafe(SCHEMA_SQL); + console.log("[Trips] DB schema initialized"); + } catch (e) { + console.error("[Trips] DB init error:", e); + } +} + +initDB(); + +// ── API: Trips ── + +// GET /api/trips — list trips +routes.get("/api/trips", async (c) => { + const rows = await sql.unsafe( + `SELECT t.*, + count(DISTINCT d.id)::int as destination_count, + count(DISTINCT e.id)::int as expense_count, + coalesce(sum(e.amount), 0)::numeric as total_spent + FROM rtrips.trips t + LEFT JOIN rtrips.destinations d ON d.trip_id = t.id + LEFT JOIN rtrips.expenses e ON e.trip_id = t.id + GROUP BY t.id ORDER BY t.created_at DESC` + ); + return c.json(rows); +}); + +// POST /api/trips — create trip +routes.post("/api/trips", async (c) => { + const body = await c.req.json(); + const { title, description, start_date, end_date, budget_total, budget_currency } = body; + if (!title?.trim()) return c.json({ error: "Title required" }, 400); + + const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + const rows = await sql.unsafe( + `INSERT INTO rtrips.trips (title, slug, description, start_date, end_date, budget_total, budget_currency) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [title.trim(), slug, description || null, start_date || null, end_date || null, + budget_total || null, budget_currency || "USD"] + ); + return c.json(rows[0], 201); +}); + +// GET /api/trips/:id — trip detail with all sub-resources +routes.get("/api/trips/:id", async (c) => { + const id = c.req.param("id"); + const trip = await sql.unsafe("SELECT * FROM rtrips.trips WHERE id = $1", [id]); + if (trip.length === 0) return c.json({ error: "Trip not found" }, 404); + + const [destinations, itinerary, bookings, expenses, packing] = await Promise.all([ + sql.unsafe("SELECT * FROM rtrips.destinations WHERE trip_id = $1 ORDER BY sort_order", [id]), + sql.unsafe("SELECT * FROM rtrips.itinerary_items WHERE trip_id = $1 ORDER BY date, sort_order", [id]), + sql.unsafe("SELECT * FROM rtrips.bookings WHERE trip_id = $1 ORDER BY start_date", [id]), + sql.unsafe("SELECT * FROM rtrips.expenses WHERE trip_id = $1 ORDER BY date DESC", [id]), + sql.unsafe("SELECT * FROM rtrips.packing_items WHERE trip_id = $1 ORDER BY category, sort_order", [id]), + ]); + + return c.json({ ...trip[0], destinations, itinerary, bookings, expenses, packing }); +}); + +// PUT /api/trips/:id — update trip +routes.put("/api/trips/:id", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const { title, description, start_date, end_date, budget_total, budget_currency, status } = body; + + const fields: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; } + if (description !== undefined) { fields.push(`description = $${idx}`); params.push(description); idx++; } + if (start_date !== undefined) { fields.push(`start_date = $${idx}`); params.push(start_date); idx++; } + if (end_date !== undefined) { fields.push(`end_date = $${idx}`); params.push(end_date); idx++; } + if (budget_total !== undefined) { fields.push(`budget_total = $${idx}`); params.push(budget_total); idx++; } + if (budget_currency !== undefined) { fields.push(`budget_currency = $${idx}`); params.push(budget_currency); idx++; } + if (status !== undefined) { fields.push(`status = $${idx}`); params.push(status); idx++; } + + if (fields.length === 0) return c.json({ error: "No fields" }, 400); + fields.push("updated_at = NOW()"); + params.push(id); + + const rows = await sql.unsafe( + `UPDATE rtrips.trips SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json(rows[0]); +}); + +// ── API: Destinations ── + +routes.post("/api/trips/:id/destinations", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + `INSERT INTO rtrips.destinations (trip_id, name, country, lat, lng, arrival_date, departure_date, notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, + [c.req.param("id"), body.name, body.country || null, body.lat || null, body.lng || null, + body.arrival_date || null, body.departure_date || null, body.notes || null, body.sort_order ?? 0] + ); + return c.json(rows[0], 201); +}); + +// ── API: Itinerary ── + +routes.post("/api/trips/:id/itinerary", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + `INSERT INTO rtrips.itinerary_items (trip_id, destination_id, title, category, date, start_time, end_time, notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, + [c.req.param("id"), body.destination_id || null, body.title, body.category || "ACTIVITY", + body.date || null, body.start_time || null, body.end_time || null, body.notes || null, body.sort_order ?? 0] + ); + return c.json(rows[0], 201); +}); + +// ── API: Bookings ── + +routes.post("/api/trips/:id/bookings", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + `INSERT INTO rtrips.bookings (trip_id, type, provider, confirmation_number, cost, currency, start_date, end_date, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, + [c.req.param("id"), body.type || "OTHER", body.provider || null, body.confirmation_number || null, + body.cost || null, body.currency || "USD", body.start_date || null, body.end_date || null, body.notes || null] + ); + return c.json(rows[0], 201); +}); + +// ── API: Expenses ── + +routes.post("/api/trips/:id/expenses", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + `INSERT INTO rtrips.expenses (trip_id, description, amount, currency, category, date, split_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [c.req.param("id"), body.description, body.amount, body.currency || "USD", + body.category || "OTHER", body.date || null, body.split_type || "EQUAL"] + ); + return c.json(rows[0], 201); +}); + +// ── API: Packing ── + +routes.get("/api/trips/:id/packing", async (c) => { + const rows = await sql.unsafe( + "SELECT * FROM rtrips.packing_items WHERE trip_id = $1 ORDER BY category, sort_order", + [c.req.param("id")] + ); + return c.json(rows); +}); + +routes.post("/api/trips/:id/packing", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + `INSERT INTO rtrips.packing_items (trip_id, name, category, quantity, sort_order) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [c.req.param("id"), body.name, body.category || "GENERAL", body.quantity || 1, body.sort_order ?? 0] + ); + return c.json(rows[0], 201); +}); + +routes.patch("/api/packing/:id", async (c) => { + const body = await c.req.json(); + const rows = await sql.unsafe( + "UPDATE rtrips.packing_items SET packed = $1 WHERE id = $2 RETURNING *", + [body.packed ?? false, c.req.param("id")] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json(rows[0]); +}); + +// ── Page route ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Trips | rSpace`, + moduleId: "trips", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const tripsModule: RSpaceModule = { + id: "trips", + name: "rTrips", + icon: "\u{2708}\u{FE0F}", + description: "Collaborative trip planner with itinerary, bookings, and expense splitting", + routes, + standaloneDomain: "rtrips.online", +}; diff --git a/modules/trips/standalone.ts b/modules/trips/standalone.ts new file mode 100644 index 0000000..3df0737 --- /dev/null +++ b/modules/trips/standalone.ts @@ -0,0 +1,17 @@ +/** + * Standalone server for the Trips module. + * Serves rtrips.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { tripsModule } from "./mod"; + +const app = new Hono(); + +app.use("/modules/trips/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); +app.route("/", tripsModule.routes); + +console.log(`[rTrips Standalone] Listening on :3000`); +export default { port: 3000, fetch: app.fetch }; diff --git a/modules/tube/components/folk-video-player.ts b/modules/tube/components/folk-video-player.ts new file mode 100644 index 0000000..ab4c404 --- /dev/null +++ b/modules/tube/components/folk-video-player.ts @@ -0,0 +1,194 @@ +/** + * folk-video-player — Video library browser + player. + * + * Lists videos from the API, plays them with native