From f9a36b9d3e410178c75900cea92ce8a1610c6272 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 05:48:35 +0000 Subject: [PATCH] feat: add interactive demo modes to all 20 rApps + mobile responsive CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each folk-* web component detects space === "demo" and renders a self-contained interactive demo with hardcoded data โ€” no API calls or WebSocket connections. Accessible at demo.rspace.online/{rApp}. Includes: spider charts, drag-to-rank, live voting sim, kanban board, video library, calendar with lunar phases, forum provisioner, email client, conviction voting, trip planner, book shelf, pub editor, wallet viewer, world map, analytics dashboard, cart shop, file browser, swag designer, photo gallery, graph viewer, social feed, and funds river. Also adds @media responsive CSS for mobile rendering across all modules. Co-Authored-By: Claude Opus 4.6 --- modules/books/components/folk-book-shelf.ts | 12 + modules/cal/components/folk-calendar-view.ts | 84 ++++ modules/cart/components/folk-cart-shop.ts | 145 +++++++ .../components/folk-choices-dashboard.ts | 361 ++++++++++++++++++ .../data/components/folk-analytics-view.ts | 19 + modules/files/components/folk-file-browser.ts | 132 +++++++ .../forum/components/folk-forum-dashboard.ts | 49 +++ modules/funds/components/folk-funds-app.ts | 2 +- modules/funds/components/funds.css | 9 + modules/inbox/components/folk-inbox-client.ts | 44 +++ modules/maps/components/folk-map-viewer.ts | 123 ++++++ .../network/components/folk-graph-viewer.ts | 125 +++++- modules/notes/components/folk-notes-app.ts | 278 +++++++++++++- .../photos/components/folk-photo-gallery.ts | 110 +++++- modules/pubs/components/folk-pubs-editor.ts | 16 + modules/rsocials/mod.ts | 147 ++++++- modules/swag/components/folk-swag-designer.ts | 67 +++- .../trips/components/folk-trips-planner.ts | 96 +++++ modules/tube/components/folk-video-player.ts | 49 ++- .../vote/components/folk-vote-dashboard.ts | 36 ++ .../wallet/components/folk-wallet-viewer.ts | 38 ++ modules/work/components/folk-work-board.ts | 86 ++++- 22 files changed, 1979 insertions(+), 49 deletions(-) diff --git a/modules/books/components/folk-book-shelf.ts b/modules/books/components/folk-book-shelf.ts index 7a654dd..5769fb2 100644 --- a/modules/books/components/folk-book-shelf.ts +++ b/modules/books/components/folk-book-shelf.ts @@ -49,6 +49,18 @@ export class FolkBookShelf extends HTMLElement { connectedCallback() { this.attachShadow({ mode: "open" }); this.render(); + if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { this.loadDemoBooks(); } + } + + private loadDemoBooks() { + this.books = [ + { id: "b1", slug: "governing-the-commons", title: "Governing the Commons", author: "Elinor Ostrom", description: "Analysis of collective action and the governance of common-pool resources", pdf_size_bytes: 2457600, page_count: 280, tags: ["economics", "governance"], cover_color: "#2563eb", contributor_name: "Community Library", featured: true, view_count: 342, created_at: "2026-01-15" }, + { id: "b2", slug: "the-mushroom-at-the-end-of-the-world", title: "The Mushroom at the End of the World", author: "Anna Lowenhaupt Tsing", description: "On the possibility of life in capitalist ruins", pdf_size_bytes: 3145728, page_count: 352, tags: ["ecology", "anthropology"], cover_color: "#059669", contributor_name: null, featured: false, view_count: 187, created_at: "2026-01-20" }, + { id: "b3", slug: "doughnut-economics", title: "Doughnut Economics", author: "Kate Raworth", description: "Seven ways to think like a 21st-century economist", pdf_size_bytes: 1887436, page_count: 320, tags: ["economics"], cover_color: "#d97706", contributor_name: "Reading Circle", featured: true, view_count: 256, created_at: "2026-02-01" }, + { id: "b4", slug: "patterns-of-commoning", title: "Patterns of Commoning", author: "David Bollier & Silke Helfrich", description: "A collection of essays on commons-based peer production", pdf_size_bytes: 4194304, page_count: 416, tags: ["commons", "governance"], cover_color: "#7c3aed", contributor_name: null, featured: false, view_count: 98, created_at: "2026-02-05" }, + { id: "b5", slug: "entangled-life", title: "Entangled Life", author: "Merlin Sheldrake", description: "How fungi make our worlds, change our minds, and shape our futures", pdf_size_bytes: 2621440, page_count: 368, tags: ["ecology", "science"], cover_color: "#0891b2", contributor_name: "Mycofi Lab", featured: false, view_count: 431, created_at: "2026-02-10" }, + { id: "b6", slug: "free-fair-and-alive", title: "Free, Fair, and Alive", author: "David Bollier & Silke Helfrich", description: "The insurgent power of the commons", pdf_size_bytes: 3670016, page_count: 340, tags: ["commons", "politics"], cover_color: "#e11d48", contributor_name: null, featured: true, view_count: 175, created_at: "2026-02-12" } + ]; } attributeChangedCallback(name: string, _old: string, val: string) { diff --git a/modules/cal/components/folk-calendar-view.ts b/modules/cal/components/folk-calendar-view.ts index 0d5e74d..e6bce27 100644 --- a/modules/cal/components/folk-calendar-view.ts +++ b/modules/cal/components/folk-calendar-view.ts @@ -24,10 +24,94 @@ class FolkCalendarView extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + if (this.space === "demo") { this.loadDemoData(); return; } this.loadMonth(); this.render(); } + private loadDemoData() { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + + const sources = [ + { name: "rSpace Team", color: "#6366f1" }, + { name: "Community", color: "#22c55e" }, + { name: "Personal", color: "#f59e0b" }, + ]; + + const demoEvents = [ + { day: 3, title: "Sprint Planning", source: 0, desc: "Plan the next two-week sprint", location: "Room A", virtual: false }, + { day: 5, title: "Community Call", source: 1, desc: "Open community discussion", location: null, virtual: true }, + { day: 8, title: "Design Review", source: 0, desc: "Review new component designs", location: "Design Lab", virtual: false }, + { day: 10, title: "Standup", source: 0, desc: "Daily sync โ€” async-first format", location: null, virtual: true }, + { day: 14, title: "Hackathon Day 1", source: 1, desc: "Build something local-first in 48h", location: "Hackerspace", virtual: false }, + { day: 15, title: "Hackathon Day 2", source: 1, desc: "Demos and judging", location: "Hackerspace", virtual: false }, + { day: 18, title: "Release Planning", source: 0, desc: "Scope the next release milestone", location: null, virtual: true }, + { day: 20, title: "Town Hall", source: 1, desc: "Monthly all-hands update", location: null, virtual: true }, + { day: 22, title: "Retrospective", source: 0, desc: "Reflect on what went well and what to improve", location: "Room B", virtual: false }, + { day: 25, title: "Workshop: Local-First", source: 1, desc: "Hands-on local-first architecture workshop", location: "Community Center", virtual: false }, + { day: 27, title: "Demo Day", source: 0, desc: "Show off what shipped this month", location: null, virtual: true }, + { day: 28, title: "Social Meetup", source: 2, desc: "Casual evening hangout", location: "Cafe Decentralized", virtual: false }, + ]; + + this.events = demoEvents.map((e, i) => { + const startDate = new Date(year, month, e.day, 10 + (i % 4), 0); + const endDate = new Date(startDate.getTime() + 3600000); + const src = sources[e.source]; + return { + id: `demo-${i + 1}`, + title: e.title, + start_time: startDate.toISOString(), + end_time: endDate.toISOString(), + source_color: src.color, + source_name: src.name, + description: e.desc, + location_name: e.location || undefined, + is_virtual: e.virtual, + virtual_platform: e.virtual ? "Jitsi" : undefined, + virtual_url: e.virtual ? "#" : undefined, + }; + }); + + this.sources = sources; + + // Compute lunar phases for each day of the month + const knownNewMoon = new Date(2026, 0, 29).getTime(); // Jan 29, 2026 + const cycle = 29.53; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const phaseNames: [string, number][] = [ + ["new_moon", 1.85], + ["waxing_crescent", 7.38], + ["first_quarter", 11.07], + ["waxing_gibbous", 14.76], + ["full_moon", 16.62], + ["waning_gibbous", 22.15], + ["last_quarter", 25.84], + ["waning_crescent", 29.53], + ]; + + const lunar: Record = {}; + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + const dayTime = new Date(year, month, d).getTime(); + const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle; + const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew; + + let phaseName = "new_moon"; + for (const [name, threshold] of phaseNames) { + if (normalizedDays < threshold) { phaseName = name; break; } + } + + // Rough illumination: 0 at new, 1 at full + const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100; + lunar[dateStr] = { phase: phaseName, illumination }; + } + this.lunarData = lunar; + + this.render(); + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/cal/); diff --git a/modules/cart/components/folk-cart-shop.ts b/modules/cart/components/folk-cart-shop.ts index 5aa3bf4..ae6bfdc 100644 --- a/modules/cart/components/folk-cart-shop.ts +++ b/modules/cart/components/folk-cart-shop.ts @@ -5,6 +5,7 @@ class FolkCartShop extends HTMLElement { private shadow: ShadowRoot; + private space = "default"; private catalog: any[] = []; private orders: any[] = []; private view: "catalog" | "orders" = "catalog"; @@ -16,10 +17,147 @@ class FolkCartShop extends HTMLElement { } connectedCallback() { + // Resolve space from attribute or URL path + const attr = this.getAttribute("space"); + if (attr) { + this.space = attr; + } else { + const parts = window.location.pathname.split("/").filter(Boolean); + this.space = parts.length >= 1 ? parts[0] : "default"; + } + + if (this.space === "demo") { + this.loadDemoData(); + return; + } + this.render(); this.loadData(); } + private loadDemoData() { + const now = Date.now(); + this.catalog = [ + { + id: "demo-cat-1", + title: "The Commons", + description: "A pocket book exploring shared resources and collective stewardship.", + price: 12, + currency: "USD", + tags: ["books"], + product_type: "pocket book", + status: "active", + created_at: new Date(now - 30 * 86400000).toISOString(), + }, + { + id: "demo-cat-2", + title: "Mycelium Networks", + description: "Illustrated poster mapping underground fungal communication pathways.", + price: 18, + currency: "USD", + tags: ["prints"], + product_type: "poster", + status: "active", + created_at: new Date(now - 25 * 86400000).toISOString(), + }, + { + id: "demo-cat-3", + title: "#DefectFi", + description: "Organic cotton tee shirt with the #DefectFi campaign logo.", + price: 25, + currency: "USD", + tags: ["apparel"], + product_type: "tee shirt", + status: "active", + created_at: new Date(now - 20 * 86400000).toISOString(), + }, + { + id: "demo-cat-4", + title: "Cosmolocal Sticker Sheet", + description: "Die-cut sticker sheet with cosmolocal design motifs.", + price: 5, + currency: "USD", + tags: ["stickers"], + product_type: "sticker sheet", + status: "active", + created_at: new Date(now - 15 * 86400000).toISOString(), + }, + { + id: "demo-cat-5", + title: "Doughnut Economics", + description: "A zine breaking down Kate Raworth's doughnut economics framework.", + price: 8, + currency: "USD", + tags: ["books"], + product_type: "zine", + status: "active", + created_at: new Date(now - 10 * 86400000).toISOString(), + }, + { + id: "demo-cat-6", + title: "rSpace Logo", + description: "Embroidered patch featuring the rSpace logo on twill backing.", + price: 6, + currency: "USD", + tags: ["accessories"], + product_type: "embroidered patch", + status: "active", + created_at: new Date(now - 5 * 86400000).toISOString(), + }, + ]; + + this.orders = [ + { + id: "demo-ord-1001", + items: [ + { title: "The Commons", qty: 1, price: 12 }, + { title: "Mycelium Networks", qty: 1, price: 18 }, + ], + total: 30, + total_price: "30.00", + currency: "USD", + status: "paid", + created_at: new Date(now - 2 * 86400000).toISOString(), + customer_email: "reader@example.com", + artifact_title: "Order #1001", + quantity: 2, + }, + { + id: "demo-ord-1002", + items: [ + { title: "#DefectFi", qty: 1, price: 25 }, + ], + total: 25, + total_price: "25.00", + currency: "USD", + status: "pending", + created_at: new Date(now - 1 * 86400000).toISOString(), + customer_email: "activist@example.com", + artifact_title: "Order #1002", + quantity: 1, + }, + { + id: "demo-ord-1003", + items: [ + { title: "Cosmolocal Sticker Sheet", qty: 1, price: 5 }, + { title: "Doughnut Economics", qty: 1, price: 8 }, + { title: "rSpace Logo", qty: 1, price: 6 }, + ], + total: 23, + total_price: "23.00", + currency: "USD", + status: "shipped", + created_at: new Date(now - 5 * 86400000).toISOString(), + customer_email: "maker@example.com", + artifact_title: "Order #1003", + quantity: 3, + }, + ]; + + this.loading = false; + this.render(); + } + private getApiBase(): string { const path = window.location.pathname; const parts = path.split("/").filter(Boolean); @@ -70,11 +208,16 @@ class FolkCartShop extends HTMLElement { .status-active { background: rgba(34,197,94,0.15); color: #4ade80; } .status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; } .status-cancelled { background: rgba(239,68,68,0.15); color: #f87171; } + .status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; } + .price { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin-top: 0.5rem; } .order-card { display: flex; justify-content: space-between; align-items: center; } .order-info { flex: 1; } .order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; } .empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; } .loading { text-align: center; padding: 3rem; color: #94a3b8; } + @media (max-width: 480px) { + .grid { grid-template-columns: 1fr; } + }
@@ -109,9 +252,11 @@ class FolkCartShop extends HTMLElement {
${entry.product_type ? `${this.esc(entry.product_type)}` : ""} ${(entry.required_capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} + ${(entry.tags || []).map((t: string) => `${this.esc(t)}`).join("")}
${entry.description ? `
${this.esc(entry.description)}
` : ""} ${entry.dimensions ? `
${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm
` : ""} + ${entry.price != null ? `
$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}
` : ""}
${entry.status}
`).join("")} diff --git a/modules/choices/components/folk-choices-dashboard.ts b/modules/choices/components/folk-choices-dashboard.ts index 33544f3..95f86e4 100644 --- a/modules/choices/components/folk-choices-dashboard.ts +++ b/modules/choices/components/folk-choices-dashboard.ts @@ -9,6 +9,15 @@ class FolkChoicesDashboard extends HTMLElement { private loading = true; private space: string; + /* Demo state */ + private demoTab: "spider" | "ranking" | "voting" = "spider"; + private hoveredPerson: string | null = null; + private rankItems: { id: number; name: string; emoji: string }[] = []; + private rankDragging: number | null = null; + private voteOptions: { id: number; name: string; emoji: string; votes: number }[] = []; + private voted = false; + private simTimer: number | null = null; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -16,10 +25,21 @@ class FolkChoicesDashboard extends HTMLElement { } connectedCallback() { + if (this.space === "demo") { + this.loadDemoData(); + return; + } this.render(); this.loadChoices(); } + disconnectedCallback() { + if (this.simTimer !== null) { + clearInterval(this.simTimer); + this.simTimer = null; + } + } + private getApiBase(): string { const path = window.location.pathname; const parts = path.split("/").filter(Boolean); @@ -116,6 +136,347 @@ class FolkChoicesDashboard extends HTMLElement { `; } + /* ===== Demo mode ===== */ + + private loadDemoData() { + this.rankItems = [ + { id: 1, name: "Thai", emoji: "๐Ÿœ" }, + { id: 2, name: "Pizza", emoji: "๐Ÿ•" }, + { id: 3, name: "Sushi", emoji: "๐Ÿฃ" }, + { id: 4, name: "Tacos", emoji: "๐ŸŒฎ" }, + { id: 5, name: "Burgers", emoji: "๐Ÿ”" }, + ]; + this.voteOptions = [ + { id: 1, name: "Inception", emoji: "๐ŸŒ€", votes: 4 }, + { id: 2, name: "Spirited Away", emoji: "๐Ÿ‰", votes: 3 }, + { id: 3, name: "The Matrix", emoji: "๐Ÿ’Š", votes: 5 }, + { id: 4, name: "Parasite", emoji: "๐Ÿชจ", votes: 2 }, + ]; + this.voted = false; + this.startVoteSim(); + this.renderDemo(); + } + + private startVoteSim() { + if (this.simTimer !== null) clearInterval(this.simTimer); + const tick = () => { + if (this.voted) return; + const idx = Math.floor(Math.random() * this.voteOptions.length); + this.voteOptions[idx].votes += 1 + Math.floor(Math.random() * 3); + if (this.demoTab === "voting") this.renderDemo(); + }; + const scheduleNext = () => { + const delay = 1200 + Math.random() * 2000; + this.simTimer = window.setTimeout(() => { + tick(); + scheduleNext(); + }, delay) as unknown as number; + }; + scheduleNext(); + } + + private renderDemo() { + const tabs: { key: "spider" | "ranking" | "voting"; label: string; icon: string }[] = [ + { key: "spider", label: "Spider Chart", icon: "๐Ÿ•ธ" }, + { key: "ranking", label: "Ranking", icon: "๐Ÿ“Š" }, + { key: "voting", label: "Live Voting", icon: "โ˜‘" }, + ]; + + let content = ""; + if (this.demoTab === "spider") content = this.renderSpider(); + else if (this.demoTab === "ranking") content = this.renderRanking(); + else content = this.renderVoting(); + + this.shadow.innerHTML = ` + + +
+ Choices + DEMO +
+ +
+ ${tabs.map((t) => ``).join("")} +
+ +
+ ${content} +
+ `; + + this.bindDemoEvents(); + } + + /* -- Spider Chart -- */ + + private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } { + const rad = ((angleDeg - 90) * Math.PI) / 180; + return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) }; + } + + private renderSpider(): string { + const cx = 200, cy = 200, maxR = 150; + const axes = ["Taste", "Price", "Speed", "Healthy", "Distance"]; + const people: { name: string; color: string; values: number[] }[] = [ + { name: "Alice", color: "#f472b6", values: [0.9, 0.5, 0.7, 0.8, 0.4] }, + { name: "Bob", color: "#38bdf8", values: [0.6, 0.8, 0.5, 0.4, 0.9] }, + { name: "Carol", color: "#a3e635", values: [0.7, 0.6, 0.9, 0.7, 0.6] }, + ]; + const angleStep = 360 / axes.length; + + // Grid rings + let gridLines = ""; + for (let ring = 1; ring <= 5; ring++) { + const r = (ring / 5) * maxR; + const pts = axes.map((_, i) => { + const p = this.polarToXY(cx, cy, r, i * angleStep); + return `${p.x},${p.y}`; + }).join(" "); + gridLines += ``; + } + + // Axis lines + labels + let axisLines = ""; + const labelOffset = 18; + axes.forEach((label, i) => { + const angle = i * angleStep; + const tip = this.polarToXY(cx, cy, maxR, angle); + axisLines += ``; + const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle); + axisLines += `${this.esc(label)}`; + }); + + // Data polygons + let polygons = ""; + people.forEach((person) => { + const dimmed = this.hoveredPerson !== null && this.hoveredPerson !== person.name; + const opacity = dimmed ? 0.12 : 0.25; + const strokeOpacity = dimmed ? 0.2 : 1; + const strokeWidth = dimmed ? 1 : 2; + const pts = person.values.map((v, i) => { + const p = this.polarToXY(cx, cy, v * maxR, i * angleStep); + return `${p.x},${p.y}`; + }).join(" "); + polygons += ``; + + // Dots at each vertex + person.values.forEach((v, i) => { + const p = this.polarToXY(cx, cy, v * maxR, i * angleStep); + const dotOpacity = dimmed ? 0.2 : 1; + polygons += ``; + }); + }); + + const legend = people.map((p) => + `
${this.esc(p.name)}
` + ).join(""); + + return `
+ + ${gridLines} + ${axisLines} + ${polygons} + +
${legend}
+
`; + } + + /* -- Ranking -- */ + + private renderRanking(): string { + const medalClass = (i: number) => i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : "plain"; + const items = this.rankItems.map((item, i) => + `
  • + ${i + 1} + ${item.emoji} + ${this.esc(item.name)} + โ ฟ +
  • ` + ).join(""); + return `
      ${items}
    `; + } + + /* -- Live Voting -- */ + + private renderVoting(): string { + const sorted = [...this.voteOptions].sort((a, b) => b.votes - a.votes); + const maxVotes = Math.max(...sorted.map((o) => o.votes), 1); + const leaderId = sorted[0]?.id; + + const items = sorted.map((opt) => { + const pct = (opt.votes / maxVotes) * 100; + const isLeader = opt.id === leaderId; + return `
    +
    + ${opt.emoji} + ${this.esc(opt.name)}${isLeader ? `Leading` : ""} + ${opt.votes} +
    `; + }).join(""); + + const status = this.voted + ? "You voted! Simulation paused." + : "Votes are rolling in live..."; + + return `
    +
    ${status}
    + ${items} +
    + +
    +
    `; + } + + /* -- Demo event binding -- */ + + private bindDemoEvents() { + // Tab switching + this.shadow.querySelectorAll(".demo-tab").forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.dataset.tab as "spider" | "ranking" | "voting"; + if (tab && tab !== this.demoTab) { + this.demoTab = tab; + this.renderDemo(); + } + }); + }); + + // Spider legend hover + this.shadow.querySelectorAll(".spider-legend-item").forEach((el) => { + el.addEventListener("mouseenter", () => { + this.hoveredPerson = el.dataset.person || null; + this.renderDemo(); + }); + el.addEventListener("mouseleave", () => { + this.hoveredPerson = null; + this.renderDemo(); + }); + }); + + // Ranking drag-and-drop + const rankList = this.shadow.querySelector(".rank-list"); + if (rankList) { + const items = rankList.querySelectorAll(".rank-item"); + items.forEach((li) => { + li.addEventListener("dragstart", (e: DragEvent) => { + const id = parseInt(li.dataset.rankId || "0", 10); + this.rankDragging = id; + li.classList.add("dragging"); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(id)); + } + }); + + li.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + li.classList.add("drag-over"); + }); + + li.addEventListener("dragleave", () => { + li.classList.remove("drag-over"); + }); + + li.addEventListener("drop", (e: DragEvent) => { + e.preventDefault(); + li.classList.remove("drag-over"); + const targetId = parseInt(li.dataset.rankId || "0", 10); + if (this.rankDragging !== null && this.rankDragging !== targetId) { + const fromIdx = this.rankItems.findIndex((r) => r.id === this.rankDragging); + const toIdx = this.rankItems.findIndex((r) => r.id === targetId); + if (fromIdx !== -1 && toIdx !== -1) { + const [moved] = this.rankItems.splice(fromIdx, 1); + this.rankItems.splice(toIdx, 0, moved); + this.renderDemo(); + } + } + }); + + li.addEventListener("dragend", () => { + this.rankDragging = null; + li.classList.remove("dragging"); + }); + }); + } + + // Voting click + this.shadow.querySelectorAll(".vote-option").forEach((el) => { + el.addEventListener("click", () => { + if (this.voted) return; + const id = parseInt(el.dataset.voteId || "0", 10); + const opt = this.voteOptions.find((o) => o.id === id); + if (opt) { + opt.votes += 1; + this.voted = true; + this.renderDemo(); + } + }); + }); + + // Vote reset + const resetBtn = this.shadow.querySelector(".vote-reset"); + if (resetBtn) { + resetBtn.addEventListener("click", () => { + this.voteOptions.forEach((o) => (o.votes = Math.floor(Math.random() * 5) + 1)); + this.voted = false; + this.startVoteSim(); + this.renderDemo(); + }); + } + } + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s; diff --git a/modules/data/components/folk-analytics-view.ts b/modules/data/components/folk-analytics-view.ts index 799ec2a..c4afc76 100644 --- a/modules/data/components/folk-analytics-view.ts +++ b/modules/data/components/folk-analytics-view.ts @@ -16,9 +16,25 @@ class FolkAnalyticsView extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + if (this.space === "demo") { + this.loadDemoData(); + return; + } this.loadStats(); } + private loadDemoData() { + this.stats = { + trackedApps: 17, + cookiesSet: 0, + scriptSize: "~2KB", + selfHosted: true, + apps: ["rSpace", "rBooks", "rCart", "rFunds", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"], + dashboardUrl: "https://analytics.rspace.online", + }; + this.render(); + } + private async loadStats() { try { const base = window.location.pathname.replace(/\/$/, ""); @@ -61,6 +77,9 @@ class FolkAnalyticsView extends HTMLElement { .stats-grid { grid-template-columns: repeat(2, 1fr); } .pillars { grid-template-columns: 1fr; } } + @media (max-width: 480px) { + .stats-grid { grid-template-columns: 1fr; } + }

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

    diff --git a/modules/files/components/folk-file-browser.ts b/modules/files/components/folk-file-browser.ts index 6426633..7fe4235 100644 --- a/modules/files/components/folk-file-browser.ts +++ b/modules/files/components/folk-file-browser.ts @@ -20,11 +20,119 @@ class FolkFileBrowser extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "default"; + + if (this.space === "demo") { + this.loadDemoData(); + return; + } + this.render(); this.loadFiles(); this.loadCards(); } + private loadDemoData() { + const now = Date.now(); + this.files = [ + { + id: "demo-file-1", + name: "meeting-notes-feb2026.md", + original_filename: "meeting-notes-feb2026.md", + title: "meeting-notes-feb2026.md", + size: 12288, + file_size: 12288, + mime_type: "text/markdown", + created_at: new Date(now - 3 * 86400000).toISOString(), + updated_at: new Date(now - 1 * 86400000).toISOString(), + space: "demo", + }, + { + id: "demo-file-2", + name: "budget-proposal.pdf", + original_filename: "budget-proposal.pdf", + title: "budget-proposal.pdf", + size: 2202009, + file_size: 2202009, + mime_type: "application/pdf", + created_at: new Date(now - 7 * 86400000).toISOString(), + updated_at: new Date(now - 5 * 86400000).toISOString(), + space: "demo", + }, + { + id: "demo-file-3", + name: "community-logo.svg", + original_filename: "community-logo.svg", + title: "community-logo.svg", + size: 46080, + file_size: 46080, + mime_type: "image/svg+xml", + created_at: new Date(now - 14 * 86400000).toISOString(), + updated_at: new Date(now - 14 * 86400000).toISOString(), + space: "demo", + }, + { + id: "demo-file-4", + name: "workshop-recording.mp4", + original_filename: "workshop-recording.mp4", + title: "workshop-recording.mp4", + size: 157286400, + file_size: 157286400, + mime_type: "video/mp4", + created_at: new Date(now - 2 * 86400000).toISOString(), + updated_at: new Date(now - 2 * 86400000).toISOString(), + space: "demo", + }, + { + id: "demo-file-5", + name: "member-directory.csv", + original_filename: "member-directory.csv", + title: "member-directory.csv", + size: 8192, + file_size: 8192, + mime_type: "text/csv", + created_at: new Date(now - 10 * 86400000).toISOString(), + updated_at: new Date(now - 4 * 86400000).toISOString(), + space: "demo", + }, + ]; + + this.cards = [ + { + id: "demo-card-1", + title: "Design Sprint Outcomes", + card_type: "summary", + type: "summary", + item_count: 3, + body: "Key outcomes from the 5-day design sprint covering user flows, wireframes, and prototypes.", + created_at: new Date(now - 5 * 86400000).toISOString(), + space: "demo", + }, + { + id: "demo-card-2", + title: "Q1 Budget Allocation", + card_type: "data", + type: "data", + item_count: 5, + body: "Budget breakdown across infrastructure, development, community, marketing, and reserves.", + created_at: new Date(now - 12 * 86400000).toISOString(), + space: "demo", + }, + { + id: "demo-card-3", + title: "Community Principles", + card_type: "reference", + type: "reference", + item_count: 7, + body: "Seven guiding principles adopted by the community for governance and collaboration.", + created_at: new Date(now - 20 * 86400000).toISOString(), + space: "demo", + }, + ]; + + this.loading = false; + this.render(); + } + private async loadFiles() { this.loading = true; this.render(); @@ -91,6 +199,10 @@ class FolkFileBrowser extends HTMLElement { private async handleUpload(e: Event) { e.preventDefault(); + if (this.space === "demo") { + alert("Upload is disabled in demo mode."); + return; + } const form = this.shadow.querySelector("#upload-form") as HTMLFormElement; if (!form) return; @@ -120,6 +232,10 @@ class FolkFileBrowser extends HTMLElement { } private async handleDelete(fileId: string) { + if (this.space === "demo") { + alert("Delete is disabled in demo mode."); + return; + } if (!confirm("Delete this file?")) return; try { const base = this.getApiBase(); @@ -129,6 +245,10 @@ class FolkFileBrowser extends HTMLElement { } private async handleShare(fileId: string) { + if (this.space === "demo") { + alert("Sharing is disabled in demo mode."); + return; + } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/files/${fileId}/share`, { @@ -149,6 +269,10 @@ class FolkFileBrowser extends HTMLElement { private async handleCreateCard(e: Event) { e.preventDefault(); + if (this.space === "demo") { + alert("Creating cards is disabled in demo mode."); + return; + } const form = this.shadow.querySelector("#card-form") as HTMLFormElement; if (!form) return; @@ -173,6 +297,10 @@ class FolkFileBrowser extends HTMLElement { } private async handleDeleteCard(cardId: string) { + if (this.space === "demo") { + alert("Deleting cards is disabled in demo mode."); + return; + } if (!confirm("Delete this card?")) return; try { const base = this.getApiBase(); @@ -286,6 +414,10 @@ class FolkFileBrowser extends HTMLElement { else if (action === "share") this.handleShare(id); else if (action === "delete-card") this.handleDeleteCard(id); else if (action === "download") { + if (this.space === "demo") { + alert("Download is disabled in demo mode."); + return; + } const base = this.getApiBase(); window.open(`${base}/api/files/${id}/download`, "_blank"); } diff --git a/modules/forum/components/folk-forum-dashboard.ts b/modules/forum/components/folk-forum-dashboard.ts index edc8616..b9d0da3 100644 --- a/modules/forum/components/folk-forum-dashboard.ts +++ b/modules/forum/components/folk-forum-dashboard.ts @@ -13,6 +13,7 @@ class FolkForumDashboard extends HTMLElement { private view: "list" | "detail" | "create" = "list"; private loading = false; private pollTimer: number | null = null; + private space = ""; constructor() { super(); @@ -20,10 +21,22 @@ class FolkForumDashboard extends HTMLElement { } connectedCallback() { + this.space = this.getAttribute("space") || ""; + if (this.space === "demo") { this.loadDemoData(); return; } this.render(); this.loadInstances(); } + private loadDemoData() { + this.instances = [ + { id: "1", name: "Commons Hub", domain: "commons.rforum.online", status: "active", region: "nbg1", size: "cx22", admin_email: "admin@commons.example", vps_ip: "116.203.x.x", ssl_provisioned: true }, + { id: "2", name: "Design Guild", domain: "design.rforum.online", status: "provisioning", region: "fsn1", size: "cx22", admin_email: "admin@design.example", vps_ip: "168.119.x.x", ssl_provisioned: false }, + { id: "3", name: "Archive Project", domain: "archive.rforum.online", status: "destroyed", region: "hel1", size: "cx22", admin_email: "admin@archive.example", vps_ip: null, ssl_provisioned: false }, + ]; + this.loading = false; + this.render(); + } + disconnectedCallback() { if (this.pollTimer) clearInterval(this.pollTimer); } @@ -63,6 +76,36 @@ class FolkForumDashboard extends HTMLElement { } private async loadInstanceDetail(id: string) { + if (this.space === "demo") { + this.selectedInstance = this.instances.find(i => i.id === id); + const demoLogs: Record = { + "1": [ + { step: "create_vps", status: "success", message: "Server created in nbg1" }, + { step: "wait_ready", status: "success", message: "Server booted and SSH ready" }, + { step: "configure_dns", status: "success", message: "DNS record set for commons.rforum.online" }, + { step: "install_discourse", status: "success", message: "Discourse installed and configured" }, + { step: "verify_live", status: "success", message: "Forum responding at https://commons.rforum.online" }, + ], + "2": [ + { step: "create_vps", status: "success", message: "Server created in fsn1" }, + { step: "wait_ready", status: "success", message: "Server booted and SSH ready" }, + { step: "configure_dns", status: "running", message: "Configuring DNS for design.rforum.online..." }, + { step: "install_discourse", status: "pending", message: "" }, + { step: "verify_live", status: "pending", message: "" }, + ], + "3": [ + { step: "create_vps", status: "success", message: "Server created in hel1" }, + { step: "wait_ready", status: "success", message: "Server booted and SSH ready" }, + { step: "configure_dns", status: "success", message: "DNS record set for archive.rforum.online" }, + { step: "install_discourse", status: "success", message: "Discourse installed and configured" }, + { step: "verify_live", status: "success", message: "Forum verified live before destruction" }, + ], + }; + this.selectedLogs = demoLogs[id] || []; + this.view = "detail"; + this.render(); + return; + } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() }); @@ -87,6 +130,7 @@ class FolkForumDashboard extends HTMLElement { private async handleCreate(e: Event) { e.preventDefault(); + if (this.space === "demo") { alert("Create is disabled in demo mode."); return; } const form = this.shadow.querySelector("#create-form") as HTMLFormElement; if (!form) return; @@ -119,6 +163,7 @@ class FolkForumDashboard extends HTMLElement { } private async handleDestroy(id: string) { + if (this.space === "demo") { alert("Destroy is disabled in demo mode."); return; } if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return; try { const base = this.getApiBase(); @@ -221,6 +266,10 @@ class FolkForumDashboard extends HTMLElement { .price-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; } .price-cost { font-size: 18px; color: #64b5f6; font-weight: 700; } .price-specs { font-size: 11px; color: #888; margin-top: 4px; } + @media (max-width: 768px) { + .pricing { grid-template-columns: 1fr; } + .form-row { grid-template-columns: 1fr; } + } ${this.view === "list" ? this.renderList() : ""} diff --git a/modules/funds/components/folk-funds-app.ts b/modules/funds/components/folk-funds-app.ts index a581669..4e3cef6 100644 --- a/modules/funds/components/folk-funds-app.ts +++ b/modules/funds/components/folk-funds-app.ts @@ -80,7 +80,7 @@ class FolkFundsApp extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.flowId = this.getAttribute("flow-id") || ""; - this.isDemo = this.getAttribute("mode") === "demo"; + this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo"; if (this.isDemo) { this.view = "detail"; diff --git a/modules/funds/components/funds.css b/modules/funds/components/funds.css index b099487..9a3b55a 100644 --- a/modules/funds/components/funds.css +++ b/modules/funds/components/funds.css @@ -169,3 +169,12 @@ .funds-tx__amount--positive { color: #10b981; } .funds-tx__amount--negative { color: #ef4444; } .funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; } + +/* โ”€โ”€ Mobile responsive โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +@media (max-width: 768px) { + .funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; } + .funds-flows__grid { grid-template-columns: 1fr; } + .funds-features__grid { grid-template-columns: 1fr; } + .funds-cards { grid-template-columns: 1fr; } + .funds-tabs { flex-wrap: wrap; } +} diff --git a/modules/inbox/components/folk-inbox-client.ts b/modules/inbox/components/folk-inbox-client.ts index 1df423f..1710a0d 100644 --- a/modules/inbox/components/folk-inbox-client.ts +++ b/modules/inbox/components/folk-inbox-client.ts @@ -15,6 +15,19 @@ class FolkInboxClient extends HTMLElement { private currentThread: any = null; private approvals: any[] = []; private filter: "all" | "open" | "snoozed" | "closed" = "all"; + private demoThreads: Record = { + team: [ + { id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] }, + { id: "t2", from_name: "Bob Martinez", from_address: "bob@example.com", subject: "Deploy checklist for v2.1", status: "open", is_read: false, is_starred: false, comment_count: 1, received_at: new Date(Date.now() - 5 * 3600000).toISOString(), body_text: "Here is the deploy checklist for v2.1. Please review before we cut the release.\n\n- [ ] Run full test suite\n- [ ] Update changelog\n- [ ] Tag release in Gitea\n- [ ] Deploy to staging\n- [ ] Smoke test all modules", comments: [{ username: "Alice Chen", body: "I can handle the changelog update.", created_at: new Date(Date.now() - 4 * 3600000).toISOString() }] }, + { id: "t3", from_name: "Carol Wu", from_address: "carol@example.com", subject: "Design system color tokens", status: "snoozed", is_read: true, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 24 * 3600000).toISOString(), body_text: "I've been working on standardizing our color tokens across all modules. The current approach of inline hex values is getting unwieldy. Proposal attached.", comments: [] }, + { id: "t4", from_name: "Dave Park", from_address: "dave@example.com", subject: "Q1 Retrospective summary", status: "closed", is_read: true, is_starred: false, comment_count: 5, received_at: new Date(Date.now() - 72 * 3600000).toISOString(), body_text: "Summary of our Q1 retrospective:\n\nWhat went well: Local-first architecture, community engagement, rapid prototyping.\nWhat to improve: Documentation, test coverage, onboarding flow.", comments: [{ username: "Alice Chen", body: "Great summary, Dave.", created_at: new Date(Date.now() - 70 * 3600000).toISOString() }, { username: "Bob Martinez", body: "+1 on improving docs.", created_at: new Date(Date.now() - 69 * 3600000).toISOString() }, { username: "Carol Wu", body: "I can lead the onboarding redesign.", created_at: new Date(Date.now() - 68 * 3600000).toISOString() }, { username: "Dave Park", body: "Sounds good, let's schedule a kickoff.", created_at: new Date(Date.now() - 67 * 3600000).toISOString() }, { username: "Alice Chen", body: "Added to next sprint.", created_at: new Date(Date.now() - 66 * 3600000).toISOString() }] }, + ], + support: [ + { id: "t5", from_name: "New User", from_address: "newuser@example.com", subject: "How do I create a space?", status: "open", is_read: false, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 1 * 3600000).toISOString(), body_text: "Hi, I just signed up and I'm not sure how to create my own space. The docs mention a space switcher but I can't find it. Could you point me in the right direction?", comments: [] }, + { id: "t6", from_name: "Partner Org", from_address: "partner@example.org", subject: "Integration API access request", status: "open", is_read: true, is_starred: true, comment_count: 2, received_at: new Date(Date.now() - 8 * 3600000).toISOString(), body_text: "We'd like to integrate our platform with rSpace modules via the API. Could you provide API documentation and access credentials for our staging environment?", comments: [{ username: "Team Bot", body: "Request logged. Assigning to API team.", created_at: new Date(Date.now() - 7 * 3600000).toISOString() }, { username: "Bob Martinez", body: "I'll send over the API docs today.", created_at: new Date(Date.now() - 6 * 3600000).toISOString() }] }, + { id: "t7", from_name: "Community Member", from_address: "member@example.com", subject: "Feature request: dark mode", status: "closed", is_read: true, is_starred: false, comment_count: 4, received_at: new Date(Date.now() - 96 * 3600000).toISOString(), body_text: "Would love to see a proper dark mode toggle. The current theme is close but some panels still have bright backgrounds.", comments: [{ username: "Carol Wu", body: "This is on our roadmap! Targeting next release.", created_at: new Date(Date.now() - 90 * 3600000).toISOString() }, { username: "Community Member", body: "Awesome, looking forward to it.", created_at: new Date(Date.now() - 88 * 3600000).toISOString() }, { username: "Carol Wu", body: "Dark mode shipped in v2.0!", created_at: new Date(Date.now() - 48 * 3600000).toISOString() }, { username: "Community Member", body: "Looks great, thanks!", created_at: new Date(Date.now() - 46 * 3600000).toISOString() }] }, + ], + }; constructor() { super(); @@ -23,9 +36,18 @@ class FolkInboxClient extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + if (this.space === "demo") { this.loadDemoData(); return; } this.loadMailboxes(); } + private loadDemoData() { + this.mailboxes = [ + { slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Internal team communications" }, + { slug: "support", name: "Support", email: "support@rspace.online", description: "User support requests" }, + ]; + this.render(); + } + private async loadMailboxes() { try { const base = window.location.pathname.replace(/\/$/, ""); @@ -39,6 +61,12 @@ class FolkInboxClient extends HTMLElement { } private async loadThreads(slug: string) { + if (this.space === "demo") { + this.threads = this.demoThreads[slug] || []; + if (this.filter !== "all") this.threads = this.threads.filter(t => t.status === this.filter); + this.render(); + return; + } try { const base = window.location.pathname.replace(/\/$/, ""); const status = this.filter === "all" ? "" : `?status=${this.filter}`; @@ -52,6 +80,15 @@ class FolkInboxClient extends HTMLElement { } private async loadThread(id: string) { + if (this.space === "demo") { + const all = [...(this.demoThreads.team || []), ...(this.demoThreads.support || [])]; + this.currentThread = all.find(t => t.id === id) || null; + if (this.currentThread) { + this.currentThread.comments = this.currentThread.comments || [{ username: "Team Bot", body: "Thread noted.", created_at: new Date().toISOString() }]; + } + this.render(); + return; + } try { const base = window.location.pathname.replace(/\/$/, ""); const resp = await fetch(`${base}/api/threads/${id}`); @@ -63,6 +100,7 @@ class FolkInboxClient extends HTMLElement { } private async loadApprovals() { + if (this.space === "demo") { this.approvals = []; this.render(); return; } try { const base = window.location.pathname.replace(/\/$/, ""); const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : ""; @@ -136,6 +174,10 @@ class FolkInboxClient extends HTMLElement { .approval-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; } .btn-approve { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #22c55e; color: white; cursor: pointer; font-size: 0.8rem; } .btn-reject { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #ef4444; color: white; cursor: pointer; font-size: 0.8rem; } + @media (max-width: 600px) { + .thread-from { width: auto; max-width: 100px; } + .thread-row { flex-wrap: wrap; gap: 4px; } + }
    ${this.renderNav()} @@ -329,6 +371,7 @@ class FolkInboxClient extends HTMLElement { // Approval actions this.shadow.querySelectorAll("[data-approve]").forEach((btn) => { btn.addEventListener("click", async () => { + if (this.space === "demo") { alert("Approvals are disabled in demo mode."); return; } const id = (btn as HTMLElement).dataset.approve!; const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { @@ -341,6 +384,7 @@ class FolkInboxClient extends HTMLElement { }); this.shadow.querySelectorAll("[data-reject]").forEach((btn) => { btn.addEventListener("click", async () => { + if (this.space === "demo") { alert("Approvals are disabled in demo mode."); return; } const id = (btn as HTMLElement).dataset.reject!; const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { diff --git a/modules/maps/components/folk-map-viewer.ts b/modules/maps/components/folk-map-viewer.ts index 0bedb44..ab80c3d 100644 --- a/modules/maps/components/folk-map-viewer.ts +++ b/modules/maps/components/folk-map-viewer.ts @@ -14,6 +14,7 @@ class FolkMapViewer extends HTMLElement { private loading = false; private error = ""; private syncStatus: "disconnected" | "connected" = "disconnected"; + private providers: { name: string; city: string; lat: number; lng: number; color: string }[] = []; constructor() { super(); @@ -23,6 +24,10 @@ class FolkMapViewer extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.room = this.getAttribute("room") || ""; + if (this.space === "demo") { + this.loadDemoData(); + return; + } if (this.room) { this.view = "map"; } @@ -30,6 +35,120 @@ class FolkMapViewer extends HTMLElement { this.render(); } + private loadDemoData() { + this.view = "map"; + this.room = "cosmolocal-providers"; + this.syncStatus = "connected"; + this.providers = [ + { name: "Radiant Hall Press", city: "Pittsburgh, PA", lat: 40.44, lng: -79.99, color: "#ef4444" }, + { name: "Tiny Splendor", city: "Los Angeles, CA", lat: 34.05, lng: -118.24, color: "#f59e0b" }, + { name: "People's Print Shop", city: "Toronto, ON", lat: 43.65, lng: -79.38, color: "#22c55e" }, + { name: "Colour Code Press", city: "London, UK", lat: 51.51, lng: -0.13, color: "#3b82f6" }, + { name: "Druckwerkstatt Berlin", city: "Berlin, DE", lat: 52.52, lng: 13.40, color: "#8b5cf6" }, + { name: "Kink\u014D Printing Collective", city: "Tokyo, JP", lat: 35.68, lng: 139.69, color: "#ec4899" }, + ]; + this.renderDemo(); + } + + private renderDemo() { + const mapWidth = 800; + const mapHeight = 400; + + const projectX = (lng: number) => ((lng + 180) * (mapWidth / 360)); + const projectY = (lat: number) => ((90 - lat) * (mapHeight / 180)); + + const providerDots = this.providers.map((p) => { + const x = projectX(p.lng); + const y = projectY(p.lat); + const labelX = x + 10; + const labelY = y + 4; + return ` + + + + ${this.esc(p.name)} + `; + }).join(""); + + const legendItems = this.providers.map((p) => ` +
    +
    +
    + ${this.esc(p.name)} + ${this.esc(p.city)} +
    +
    + `).join(""); + + this.shadow.innerHTML = ` + + +
    + Cosmolocal Print Network + + 6 providers +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + ${providerDots} + +
    + +
    +
    Print Providers
    + ${legendItems} +
    + `; + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/maps/); @@ -145,6 +264,10 @@ class FolkMapViewer extends HTMLElement { } .empty { text-align: center; color: #666; padding: 40px; } + + @media (max-width: 768px) { + .map-container { height: 300px; } + } ${this.error ? `
    ${this.esc(this.error)}
    ` : ""} diff --git a/modules/network/components/folk-graph-viewer.ts b/modules/network/components/folk-graph-viewer.ts index d6a9612..125a8c1 100644 --- a/modules/network/components/folk-graph-viewer.ts +++ b/modules/network/components/folk-graph-viewer.ts @@ -5,11 +5,19 @@ * and edges in a force-directed layout with search and filtering. */ +interface GraphNode { + id: string; + name: string; + type: "person" | "company" | "opportunity"; + workspace: string; +} + class FolkGraphViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; private workspaces: any[] = []; private info: any = null; + private nodes: GraphNode[] = []; private filter: "all" | "person" | "company" | "opportunity" = "all"; private searchQuery = ""; private error = ""; @@ -21,10 +29,32 @@ class FolkGraphViewer extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + if (this.space === "demo") { this.loadDemoData(); return; } this.loadData(); this.render(); } + private loadDemoData() { + this.info = { name: "rSpace Community", member_count: 42, company_count: 8, opportunity_count: 5 }; + + this.workspaces = [ + { name: "Core Contributors", slug: "core-contributors", nodeCount: 12, edgeCount: 3 }, + { name: "Extended Network", slug: "extended-network", nodeCount: 30, edgeCount: 5 }, + ]; + + this.nodes = [ + { id: "demo-p1", name: "Alice Chen", type: "person", workspace: "Core Contributors" }, + { id: "demo-p2", name: "Bob Marley", type: "person", workspace: "Core Contributors" }, + { id: "demo-p3", name: "Carol Danvers", type: "person", workspace: "Extended Network" }, + { id: "demo-p4", name: "Diana Prince", type: "person", workspace: "Extended Network" }, + { id: "demo-c1", name: "Radiant Hall Press", type: "company", workspace: "Core Contributors" }, + { id: "demo-c2", name: "Tiny Splendor", type: "company", workspace: "Extended Network" }, + { id: "demo-c3", name: "Commons Hub", type: "company", workspace: "Core Contributors" }, + ]; + + this.render(); + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/network/); @@ -44,6 +74,72 @@ class FolkGraphViewer extends HTMLElement { this.render(); } + private getFilteredNodes(): GraphNode[] { + let filtered = this.nodes; + if (this.filter !== "all") { + filtered = filtered.filter(n => n.type === this.filter); + } + if (this.searchQuery.trim()) { + const q = this.searchQuery.toLowerCase(); + filtered = filtered.filter(n => + n.name.toLowerCase().includes(q) || + n.workspace.toLowerCase().includes(q) + ); + } + return filtered; + } + + private renderGraphNodes(): string { + const filtered = this.getFilteredNodes(); + if (filtered.length === 0 && this.nodes.length > 0) { + return `

    No nodes match current filter.

    `; + } + if (filtered.length === 0) { + return ` +
    +

    🕸️

    +

    Community Relationship Graph

    +

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

    +

    Automerge CRDT sync + d3-force layout

    +
    + `; + } + + // Render demo nodes as positioned circles inside the graph canvas + const cx = 250; + const cy = 250; + const r = 180; + const nodesSvg = filtered.map((node, i) => { + const angle = (2 * Math.PI * i) / filtered.length - Math.PI / 2; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + const color = node.type === "person" ? "#3b82f6" : node.type === "company" ? "#22c55e" : "#f59e0b"; + const radius = node.type === "company" ? 18 : 14; + return ` + + ${this.esc(node.name)} + `; + }).join(""); + + // Draw edges between nodes in the same workspace + const edgesSvg: string[] = []; + for (let i = 0; i < filtered.length; i++) { + for (let j = i + 1; j < filtered.length; j++) { + if (filtered[i].workspace === filtered[j].workspace) { + const a1 = (2 * Math.PI * i) / filtered.length - Math.PI / 2; + const a2 = (2 * Math.PI * j) / filtered.length - Math.PI / 2; + const x1 = cx + r * Math.cos(a1); + const y1 = cy + r * Math.sin(a1); + const x2 = cx + r * Math.cos(a2); + const y2 = cy + r * Math.sin(a2); + edgesSvg.push(``); + } + } + } + + return `${edgesSvg.join("")}${nodesSvg}`; + } + private render() { this.shadow.innerHTML = ` ${this.error ? `
    ${this.esc(this.error)}
    ` : ""}
    - Network Graph + Network Graph${this.space === "demo" ? 'Demo' : ""}
    + ${this.info ? ` +
    +
    ${this.info.member_count || 0}
    Members
    +
    ${this.info.company_count || 0}
    Companies
    +
    ${this.info.opportunity_count || 0}
    Opportunities
    +
    + ` : ""} +
    ${(["all", "person", "company", "opportunity"] as const).map(f => @@ -111,12 +225,14 @@ class FolkGraphViewer extends HTMLElement {
    + ${this.nodes.length > 0 ? this.renderGraphNodes() : `
    -

    ๐Ÿ•ธ๏ธ

    +

    🕸️

    Community Relationship Graph

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

    Automerge CRDT sync + d3-force layout

    + `}
    @@ -131,7 +247,7 @@ class FolkGraphViewer extends HTMLElement { ${this.workspaces.map(ws => `
    ${this.esc(ws.name || ws.slug)}
    -
    ${ws.nodeCount || 0} nodes ยท ${ws.edgeCount || 0} edges
    +
    ${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges
    `).join("")}
    @@ -147,8 +263,11 @@ class FolkGraphViewer extends HTMLElement { this.render(); }); }); + let searchTimeout: any; this.shadow.getElementById("search-input")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => this.render(), 200); }); } diff --git a/modules/notes/components/folk-notes-app.ts b/modules/notes/components/folk-notes-app.ts index 8db94cb..87ad78d 100644 --- a/modules/notes/components/folk-notes-app.ts +++ b/modules/notes/components/folk-notes-app.ts @@ -71,12 +71,214 @@ class FolkNotesApp extends HTMLElement { this.shadow = this.attachShadow({ mode: "open" }); } + // โ”€โ”€ Demo data โ”€โ”€ + private demoNotebooks: (Notebook & { notes: Note[] })[] = []; + connectedCallback() { this.space = this.getAttribute("space") || "demo"; + if (this.space === "demo") { this.loadDemoData(); return; } this.connectSync(); this.loadNotebooks(); } + private loadDemoData() { + const now = Date.now(); + const hour = 3600000; + const day = 86400000; + + const projectNotes: Note[] = [ + { + id: "demo-note-1", title: "Cosmolocal Marketplace", + content: "Build a decentralized marketplace connecting local makers with global designers. Use rCart for orders, rFunds for revenue splits.", + content_plain: "Build a decentralized marketplace connecting local makers with global designers. Use rCart for orders, rFunds for revenue splits.", + type: "NOTE", tags: ["cosmolocal", "marketplace"], is_pinned: true, + created_at: new Date(now - 2 * day).toISOString(), updated_at: new Date(now - hour).toISOString(), + }, + { + id: "demo-note-2", title: "Community Garden App", + content: "Track plots, plantings, and harvests. Share surplus through a local exchange network.", + content_plain: "Track plots, plantings, and harvests. Share surplus through a local exchange network.", + type: "NOTE", tags: ["community", "local"], is_pinned: false, + created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), + }, + { + id: "demo-note-3", title: "Mesh Network Map", + content: "Visualize community mesh networks using rMaps. Show signal strength, coverage areas.", + content_plain: "Visualize community mesh networks using rMaps. Show signal strength, coverage areas.", + type: "NOTE", tags: ["mesh", "infrastructure"], is_pinned: false, + created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), + }, + { + id: "demo-note-4", title: "Open Hardware Library", + content: "Catalog open-source hardware designs. Link to local fabrication providers.", + content_plain: "Catalog open-source hardware designs. Link to local fabrication providers.", + type: "NOTE", tags: ["hardware", "open-source"], is_pinned: false, + created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(), + }, + ]; + + const meetingNotes: Note[] = [ + { + id: "demo-note-5", title: "Sprint Planning \u2014 Feb 24", + content: "Discussed module porting progress. Canvas and books done. Next: work, cal, vote...", + content_plain: "Discussed module porting progress. Canvas and books done. Next: work, cal, vote...", + type: "NOTE", tags: ["sprint", "planning"], is_pinned: false, + created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), + }, + { + id: "demo-note-6", title: "Design Review \u2014 Feb 22", + content: "Reviewed new shell header. Consensus on simplified nav. Action items: finalize color palette.", + content_plain: "Reviewed new shell header. Consensus on simplified nav. Action items: finalize color palette.", + type: "NOTE", tags: ["design", "review"], is_pinned: false, + created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(), + }, + { + id: "demo-note-7", title: "Community Call \u2014 Feb 20", + content: "30 participants. Demoed rFunds river view. Positive feedback on enoughness score.", + content_plain: "30 participants. Demoed rFunds river view. Positive feedback on enoughness score.", + type: "NOTE", tags: ["community", "call"], is_pinned: false, + created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(), + }, + { + id: "demo-note-8", title: "Infrastructure Sync \u2014 Feb 18", + content: "Mailcow migration complete. All 20 domains verified. DKIM keys rotated.", + content_plain: "Mailcow migration complete. All 20 domains verified. DKIM keys rotated.", + type: "NOTE", tags: ["infra", "mail"], is_pinned: false, + created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 10 * hour).toISOString(), + }, + { + id: "demo-note-9", title: "Retrospective \u2014 Feb 15", + content: "What went well: EncryptID launch. What to improve: documentation coverage.", + content_plain: "What went well: EncryptID launch. What to improve: documentation coverage.", + type: "NOTE", tags: ["retro"], is_pinned: false, + created_at: new Date(now - 13 * day).toISOString(), updated_at: new Date(now - 13 * hour).toISOString(), + }, + { + id: "demo-note-10", title: "Onboarding Session \u2014 Feb 12", + content: "Walked 3 new contributors through rSpace setup. Created video guide.", + content_plain: "Walked 3 new contributors through rSpace setup. Created video guide.", + type: "NOTE", tags: ["onboarding"], is_pinned: false, + created_at: new Date(now - 16 * day).toISOString(), updated_at: new Date(now - 16 * hour).toISOString(), + }, + ]; + + const readingNotes: Note[] = [ + { + id: "demo-note-11", title: "Governing the Commons", + content: "Ostrom's 8 principles for managing shared resources. Especially relevant to our governance module.", + content_plain: "Ostrom's 8 principles for managing shared resources. Especially relevant to our governance module.", + type: "NOTE", tags: ["book", "governance"], is_pinned: false, + created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - day).toISOString(), + }, + { + id: "demo-note-12", title: "Entangled Life", + content: "Sheldrake's exploration of fungal networks. The wood wide web metaphor maps perfectly to mesh networks.", + content_plain: "Sheldrake's exploration of fungal networks. The wood wide web metaphor maps perfectly to mesh networks.", + type: "NOTE", tags: ["book", "mycelium"], is_pinned: false, + created_at: new Date(now - 20 * day).toISOString(), updated_at: new Date(now - 2 * day).toISOString(), + }, + { + id: "demo-note-13", title: "Doughnut Economics", + content: "Raworth's framework for staying within planetary boundaries while meeting human needs.", + content_plain: "Raworth's framework for staying within planetary boundaries while meeting human needs.", + type: "NOTE", tags: ["book", "economics"], is_pinned: false, + created_at: new Date(now - 25 * day).toISOString(), updated_at: new Date(now - 3 * day).toISOString(), + }, + ]; + + this.demoNotebooks = [ + { + id: "demo-nb-1", title: "Project Ideas", description: "Ideas for new projects and features", + cover_color: "#6366f1", note_count: "4", updated_at: new Date(now - hour).toISOString(), + notes: projectNotes, space: "demo", + } as any, + { + id: "demo-nb-2", title: "Meeting Notes", description: "Team meetings and sync calls", + cover_color: "#22c55e", note_count: "6", updated_at: new Date(now - 3 * hour).toISOString(), + notes: meetingNotes, space: "demo", + } as any, + { + id: "demo-nb-3", title: "Reading Journal", description: "Books, articles, and reflections", + cover_color: "#f59e0b", note_count: "3", updated_at: new Date(now - day).toISOString(), + notes: readingNotes, space: "demo", + } as any, + ]; + + this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook); + this.loading = false; + this.render(); + } + + private demoSearchNotes(query: string) { + if (!query.trim()) { + this.searchResults = []; + this.render(); + return; + } + const q = query.toLowerCase(); + const all = this.demoNotebooks.flatMap(nb => nb.notes); + this.searchResults = all.filter(n => + n.title.toLowerCase().includes(q) || + n.content_plain.toLowerCase().includes(q) || + (n.tags && n.tags.some(t => t.toLowerCase().includes(q))) + ); + this.render(); + } + + private demoLoadNotebook(id: string) { + const nb = this.demoNotebooks.find(n => n.id === id); + if (nb) { + this.selectedNotebook = { ...nb }; + } else { + this.error = "Notebook not found"; + } + this.loading = false; + this.render(); + } + + private demoLoadNote(id: string) { + const allNotes = this.demoNotebooks.flatMap(nb => nb.notes); + this.selectedNote = allNotes.find(n => n.id === id) || null; + this.render(); + } + + private demoCreateNotebook() { + const title = prompt("Notebook name:"); + if (!title?.trim()) return; + const now = Date.now(); + const nb = { + id: `demo-nb-${now}`, title, description: "", + cover_color: "#8b5cf6", note_count: "0", + updated_at: new Date(now).toISOString(), notes: [] as Note[], + space: "demo", + } as any; + this.demoNotebooks.push(nb); + this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook); + this.render(); + } + + private demoCreateNote() { + if (!this.selectedNotebook) return; + const now = Date.now(); + const noteId = `demo-note-${now}`; + const newNote: Note = { + id: noteId, title: "Untitled Note", content: "", content_plain: "", + type: "NOTE", tags: null, is_pinned: false, + created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), + }; + // Add to the matching demoNotebook + const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id); + if (demoNb) { + demoNb.notes.push(newNote); + demoNb.note_count = String(demoNb.notes.length); + } + this.selectedNotebook.notes.push(newNote); + this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length); + this.selectedNote = newNote; + this.view = "note"; + this.render(); + } + disconnectedCallback() { this.disconnectSync(); } @@ -603,32 +805,41 @@ class FolkNotesApp extends HTMLElement { private renderNote(): string { const n = this.selectedNote!; + const isDemo = this.space === "demo"; const isAutomerge = !!(this.doc?.items?.[n.id]); + const isEditable = isAutomerge || isDemo; return `
    - ${isAutomerge + ${isEditable ? `` : `${this.getNoteIcon(n.type)} ${this.esc(n.title)}` }
    -
    ${n.content || 'Empty note'}
    +
    ${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("") : ""} ${isAutomerge ? 'Live' : ""} + ${isDemo ? 'Demo' : ""}
    `; } private attachListeners() { - // Create notebook - this.shadow.getElementById("create-notebook")?.addEventListener("click", () => this.createNotebook()); + const isDemo = this.space === "demo"; - // Create note (Automerge) - this.shadow.getElementById("create-note")?.addEventListener("click", () => this.createNoteViaSync()); + // Create notebook + this.shadow.getElementById("create-notebook")?.addEventListener("click", () => { + isDemo ? this.demoCreateNotebook() : this.createNotebook(); + }); + + // Create note (Automerge or demo) + this.shadow.getElementById("create-note")?.addEventListener("click", () => { + isDemo ? this.demoCreateNote() : this.createNoteViaSync(); + }); // Search const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; @@ -636,7 +847,9 @@ class FolkNotesApp extends HTMLElement { searchInput?.addEventListener("input", () => { clearTimeout(searchTimeout); this.searchQuery = searchInput.value; - searchTimeout = setTimeout(() => this.searchNotes(this.searchQuery), 300); + searchTimeout = setTimeout(() => { + isDemo ? this.demoSearchNotes(this.searchQuery) : this.searchNotes(this.searchQuery); + }, 300); }); // Notebook cards @@ -644,7 +857,7 @@ class FolkNotesApp extends HTMLElement { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.notebook!; this.view = "notebook"; - this.loadNotebook(id); + isDemo ? this.demoLoadNotebook(id) : this.loadNotebook(id); }); }); @@ -653,7 +866,7 @@ class FolkNotesApp extends HTMLElement { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.note!; this.view = "note"; - this.loadNote(id); + isDemo ? this.demoLoadNote(id) : this.loadNote(id); }); }); @@ -664,7 +877,7 @@ class FolkNotesApp extends HTMLElement { const target = (el as HTMLElement).dataset.back; if (target === "notebooks") { this.view = "notebooks"; - this.unsubscribeNotebook(); + if (!isDemo) this.unsubscribeNotebook(); this.selectedNotebook = null; this.selectedNote = null; this.render(); @@ -673,7 +886,7 @@ class FolkNotesApp extends HTMLElement { }); }); - // Editable note title (debounced) + // Editable note title (debounced) โ€” demo: update local data; live: Automerge const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; if (titleInput && this.selectedNote) { let titleTimeout: any; @@ -681,12 +894,16 @@ class FolkNotesApp extends HTMLElement { titleInput.addEventListener("input", () => { clearTimeout(titleTimeout); titleTimeout = setTimeout(() => { - this.updateNoteField(noteId, "title", titleInput.value); + if (isDemo) { + this.demoUpdateNoteField(noteId, "title", titleInput.value); + } else { + this.updateNoteField(noteId, "title", titleInput.value); + } }, 500); }); } - // Editable note content (debounced) + // Editable note content (debounced) โ€” demo: update local data; live: Automerge const contentEl = this.shadow.getElementById("note-content-editable"); if (contentEl && this.selectedNote) { let contentTimeout: any; @@ -695,15 +912,44 @@ class FolkNotesApp extends HTMLElement { clearTimeout(contentTimeout); contentTimeout = setTimeout(() => { const html = contentEl.innerHTML; - this.updateNoteField(noteId, "content", html); - // Also update plain text const plain = contentEl.textContent?.trim() || ""; - this.updateNoteField(noteId, "contentPlain", plain); + if (isDemo) { + this.demoUpdateNoteField(noteId, "content", html); + this.demoUpdateNoteField(noteId, "content_plain", plain); + } else { + this.updateNoteField(noteId, "content", html); + this.updateNoteField(noteId, "contentPlain", plain); + } }, 800); }); } } + private demoUpdateNoteField(noteId: string, field: string, value: string) { + // Update in the selectedNote + if (this.selectedNote && this.selectedNote.id === noteId) { + (this.selectedNote as any)[field] = value; + this.selectedNote.updated_at = new Date().toISOString(); + } + // Update in the matching demoNotebook + for (const nb of this.demoNotebooks) { + const note = nb.notes.find(n => n.id === noteId); + if (note) { + (note as any)[field] = value; + note.updated_at = new Date().toISOString(); + break; + } + } + // Update in selectedNotebook notes + if (this.selectedNotebook?.notes) { + const note = this.selectedNotebook.notes.find(n => n.id === noteId); + if (note) { + (note as any)[field] = value; + note.updated_at = new Date().toISOString(); + } + } + } + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; diff --git a/modules/photos/components/folk-photo-gallery.ts b/modules/photos/components/folk-photo-gallery.ts index 412744f..b9a1ff8 100644 --- a/modules/photos/components/folk-photo-gallery.ts +++ b/modules/photos/components/folk-photo-gallery.ts @@ -51,9 +51,66 @@ class FolkPhotoGallery extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + if (this.space === "demo") { + this.loadDemoData(); + return; + } this.loadGallery(); } + private loadDemoData() { + this.albums = [ + { id: "demo-album-1", albumName: "Community Gathering", description: "Photos from our community events", assetCount: 12, albumThumbnailAssetId: null, updatedAt: "2026-02-15T10:00:00Z", shared: true }, + { id: "demo-album-2", albumName: "Workshop Series", description: "Hands-on learning sessions", assetCount: 8, albumThumbnailAssetId: null, updatedAt: "2026-02-10T14:30:00Z", shared: true }, + { id: "demo-album-3", albumName: "Nature Walks", description: "Exploring local ecosystems", assetCount: 15, albumThumbnailAssetId: null, updatedAt: "2026-02-20T09:15:00Z", shared: true }, + ]; + this.assets = [ + { id: "demo-asset-1", type: "IMAGE", originalFileName: "sunrise-over-commons.jpg", fileCreatedAt: "2026-02-25T06:30:00Z", exifInfo: { city: "Portland", country: "USA", make: "Fujifilm", model: "X-T5" } }, + { id: "demo-asset-2", type: "IMAGE", originalFileName: "workshop-group-photo.jpg", fileCreatedAt: "2026-02-24T15:00:00Z", exifInfo: { city: "Portland", country: "USA" } }, + { id: "demo-asset-3", type: "IMAGE", originalFileName: "mycelium-closeup.jpg", fileCreatedAt: "2026-02-23T11:20:00Z", exifInfo: { make: "Canon", model: "EOS R5" } }, + { id: "demo-asset-4", type: "IMAGE", originalFileName: "community-garden.jpg", fileCreatedAt: "2026-02-22T09:45:00Z", exifInfo: { city: "Seattle", country: "USA" } }, + { id: "demo-asset-5", type: "IMAGE", originalFileName: "maker-space-tools.jpg", fileCreatedAt: "2026-02-21T14:10:00Z", exifInfo: {} }, + { id: "demo-asset-6", type: "IMAGE", originalFileName: "sunset-gathering.jpg", fileCreatedAt: "2026-02-20T18:30:00Z", exifInfo: { city: "Vancouver", country: "Canada", make: "Sony", model: "A7IV" } }, + { id: "demo-asset-7", type: "IMAGE", originalFileName: "seed-library.jpg", fileCreatedAt: "2026-02-19T10:00:00Z", exifInfo: {} }, + { id: "demo-asset-8", type: "IMAGE", originalFileName: "potluck-spread.jpg", fileCreatedAt: "2026-02-18T12:00:00Z", exifInfo: { city: "Portland", country: "USA" } }, + ]; + this.render(); + } + + private getDemoAssetMeta(id: string): { width: number; height: number; color: string } { + const meta: Record = { + "demo-asset-1": { width: 4000, height: 2667, color: "#f59e0b" }, + "demo-asset-2": { width: 3200, height: 2400, color: "#6366f1" }, + "demo-asset-3": { width: 2400, height: 2400, color: "#22c55e" }, + "demo-asset-4": { width: 3600, height: 2400, color: "#10b981" }, + "demo-asset-5": { width: 2800, height: 1867, color: "#8b5cf6" }, + "demo-asset-6": { width: 4000, height: 2667, color: "#ef4444" }, + "demo-asset-7": { width: 2000, height: 2000, color: "#14b8a6" }, + "demo-asset-8": { width: 3200, height: 2133, color: "#f97316" }, + }; + return meta[id] || { width: 2000, height: 2000, color: "#64748b" }; + } + + private getDemoAlbumColor(id: string): string { + const colors: Record = { + "demo-album-1": "#6366f1", + "demo-album-2": "#22c55e", + "demo-album-3": "#f59e0b", + }; + return colors[id] || "#64748b"; + } + + private getDemoAlbumAssets(albumId: string): Asset[] { + if (albumId === "demo-album-1") return this.assets.slice(0, 6); + if (albumId === "demo-album-2") return this.assets.slice(2, 6); + if (albumId === "demo-album-3") return this.assets.slice(0, 8); + return []; + } + + private isDemo(): boolean { + return this.space === "demo"; + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/photos/); @@ -89,6 +146,13 @@ class FolkPhotoGallery extends HTMLElement { private async loadAlbum(album: Album) { this.selectedAlbum = album; this.view = "album"; + + if (this.isDemo()) { + this.albumAssets = this.getDemoAlbumAssets(album.id); + this.render(); + return; + } + this.loading = true; this.render(); @@ -249,6 +313,27 @@ class FolkPhotoGallery extends HTMLElement { .loading { text-align: center; color: #64748b; padding: 3rem; } .error { text-align: center; color: #f87171; padding: 1.5rem; background: rgba(248,113,113,0.08); border-radius: 8px; margin-bottom: 16px; font-size: 14px; } + + .demo-thumb { + width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; + font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.85); + text-align: center; padding: 8px; + text-shadow: 0 1px 3px rgba(0,0,0,0.4); + word-break: break-word; line-height: 1.3; + } + .demo-lightbox-img { + width: 80vw; max-width: 900px; aspect-ratio: 3/2; + display: flex; align-items: center; justify-content: center; + border-radius: 8px; + font-size: 20px; font-weight: 600; color: rgba(255,255,255,0.9); + text-shadow: 0 2px 6px rgba(0,0,0,0.5); + } + + @media (max-width: 480px) { + .albums-grid { grid-template-columns: 1fr; } + .photo-grid { grid-template-columns: repeat(2, 1fr); } + } ${this.error ? `
    ${this.esc(this.error)}
    ` : ""} @@ -296,9 +381,11 @@ class FolkPhotoGallery extends HTMLElement { ${this.albums.map((a) => `
    - ${a.albumThumbnailAssetId - ? `${this.esc(a.albumName)}` - : '๐Ÿ“ท'} + ${this.isDemo() + ? `
    ${this.esc(a.albumName)}
    ` + : a.albumThumbnailAssetId + ? `${this.esc(a.albumName)}` + : '๐Ÿ“ท'}
    ${this.esc(a.albumName)}
    @@ -315,7 +402,9 @@ class FolkPhotoGallery extends HTMLElement {
    ${this.assets.map((a) => `
    - ${this.esc(a.originalFileName)} + ${this.isDemo() + ? `
    ${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}
    ` + : `${this.esc(a.originalFileName)}`}
    `).join("")}
    @@ -346,7 +435,9 @@ class FolkPhotoGallery extends HTMLElement {
    ${this.albumAssets.map((a) => `
    - ${this.esc(a.originalFileName)} + ${this.isDemo() + ? `
    ${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}
    ` + : `${this.esc(a.originalFileName)}`}
    `).join("")}
    @@ -360,12 +451,17 @@ class FolkPhotoGallery extends HTMLElement { const location = [info?.city, info?.country].filter(Boolean).join(", "); const camera = [info?.make, info?.model].filter(Boolean).join(" "); + const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null; + const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "); + return `