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 `