feat: add interactive demo modes to all 20 rApps + mobile responsive CSS
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 <noreply@anthropic.com>
This commit is contained in:
parent
5b7ee71eb9
commit
f9a36b9d3e
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, { phase: string; illumination: number }> = {};
|
||||
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/);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
|
|
@ -109,9 +252,11 @@ class FolkCartShop extends HTMLElement {
|
|||
<div class="card-meta">
|
||||
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
||||
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
|
||||
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")}
|
||||
</div>
|
||||
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
|
||||
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
|
||||
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
|
||||
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
|
||||
</div>
|
||||
`).join("")}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
/* ===== 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 = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; }
|
||||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: #6366f1; color: #fff; font-weight: 500; }
|
||||
|
||||
/* Tabs */
|
||||
.demo-tabs { display: flex; gap: 4px; margin-bottom: 1.5rem; background: #0f172a; border-radius: 10px; padding: 4px; }
|
||||
.demo-tab { flex: 1; text-align: center; padding: 0.6rem 0.75rem; border-radius: 8px; border: none; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
|
||||
.demo-tab:hover { color: #e2e8f0; background: #1e293b; }
|
||||
.demo-tab.active { background: #1e293b; color: #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.demo-tab-icon { margin-right: 6px; }
|
||||
|
||||
/* Spider chart */
|
||||
.spider-wrap { display: flex; flex-direction: column; align-items: center; }
|
||||
.spider-svg { width: 100%; max-width: 420px; }
|
||||
.spider-legend { display: flex; gap: 1.25rem; margin-top: 1rem; justify-content: center; flex-wrap: wrap; }
|
||||
.spider-legend-item { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 4px 10px; border-radius: 6px; transition: background 0.15s; font-size: 0.875rem; color: #e2e8f0; }
|
||||
.spider-legend-item:hover { background: #1e293b; }
|
||||
.spider-legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.spider-axis-label { fill: #94a3b8; font-size: 13px; font-family: inherit; }
|
||||
|
||||
/* Ranking */
|
||||
.rank-list { list-style: none; padding: 0; margin: 0; max-width: 440px; margin-inline: auto; }
|
||||
.rank-item { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1rem; margin-bottom: 6px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; cursor: grab; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; user-select: none; }
|
||||
.rank-item:active { cursor: grabbing; }
|
||||
.rank-item.dragging { opacity: 0.4; transform: scale(0.97); }
|
||||
.rank-item.drag-over { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.3); }
|
||||
.rank-pos { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.8rem; color: #0f172a; flex-shrink: 0; }
|
||||
.rank-pos.gold { background: #fbbf24; }
|
||||
.rank-pos.silver { background: #94a3b8; }
|
||||
.rank-pos.bronze { background: #d97706; }
|
||||
.rank-pos.plain { background: #334155; color: #94a3b8; }
|
||||
.rank-emoji { font-size: 1.5rem; flex-shrink: 0; }
|
||||
.rank-name { flex: 1; color: #f1f5f9; font-weight: 600; font-size: 1rem; }
|
||||
.rank-grip { color: #475569; font-size: 1.1rem; flex-shrink: 0; letter-spacing: 2px; }
|
||||
|
||||
/* Voting */
|
||||
.vote-wrap { max-width: 480px; margin-inline: auto; }
|
||||
.vote-option { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1rem; margin-bottom: 8px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; cursor: pointer; position: relative; overflow: hidden; transition: border-color 0.15s; }
|
||||
.vote-option:hover { border-color: #6366f1; }
|
||||
.vote-option.voted { border-color: #6366f1; }
|
||||
.vote-fill { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(99,102,241,0.12); transition: width 0.4s ease; pointer-events: none; }
|
||||
.vote-emoji { font-size: 1.5rem; flex-shrink: 0; position: relative; z-index: 1; }
|
||||
.vote-name { flex: 1; color: #f1f5f9; font-weight: 600; font-size: 1rem; position: relative; z-index: 1; }
|
||||
.vote-count { color: #818cf8; font-weight: 700; font-size: 1rem; min-width: 36px; text-align: right; position: relative; z-index: 1; }
|
||||
.vote-badge { font-size: 0.65rem; padding: 2px 7px; border-radius: 999px; background: #6366f1; color: #fff; margin-left: 6px; position: relative; z-index: 1; font-weight: 500; }
|
||||
.vote-actions { display: flex; justify-content: center; margin-top: 1rem; }
|
||||
.vote-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
|
||||
.vote-reset:hover { border-color: #ef4444; color: #fca5a5; }
|
||||
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: #64748b; }
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Choices</span>
|
||||
<span class="demo-badge">DEMO</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-tabs">
|
||||
${tabs.map((t) => `<button class="demo-tab${this.demoTab === t.key ? " active" : ""}" data-tab="${t.key}"><span class="demo-tab-icon">${t.icon}</span>${this.esc(t.label)}</button>`).join("")}
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 += `<polygon points="${pts}" fill="none" stroke="#334155" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
// 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 += `<line x1="${cx}" y1="${cy}" x2="${tip.x}" y2="${tip.y}" stroke="#334155" stroke-width="1"/>`;
|
||||
const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle);
|
||||
axisLines += `<text x="${lp.x}" y="${lp.y}" text-anchor="middle" dominant-baseline="central" class="spider-axis-label">${this.esc(label)}</text>`;
|
||||
});
|
||||
|
||||
// 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 += `<polygon points="${pts}" fill="${person.color}" fill-opacity="${opacity}" stroke="${person.color}" stroke-opacity="${strokeOpacity}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
|
||||
|
||||
// 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 += `<circle cx="${p.x}" cy="${p.y}" r="4" fill="${person.color}" opacity="${dotOpacity}"/>`;
|
||||
});
|
||||
});
|
||||
|
||||
const legend = people.map((p) =>
|
||||
`<div class="spider-legend-item" data-person="${this.esc(p.name)}"><span class="spider-legend-dot" style="background:${p.color}"></span>${this.esc(p.name)}</div>`
|
||||
).join("");
|
||||
|
||||
return `<div class="spider-wrap">
|
||||
<svg class="spider-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
${gridLines}
|
||||
${axisLines}
|
||||
${polygons}
|
||||
</svg>
|
||||
<div class="spider-legend">${legend}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* -- 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) =>
|
||||
`<li class="rank-item" draggable="true" data-rank-id="${item.id}">
|
||||
<span class="rank-pos ${medalClass(i)}">${i + 1}</span>
|
||||
<span class="rank-emoji">${item.emoji}</span>
|
||||
<span class="rank-name">${this.esc(item.name)}</span>
|
||||
<span class="rank-grip">⠿</span>
|
||||
</li>`
|
||||
).join("");
|
||||
return `<ul class="rank-list">${items}</ul>`;
|
||||
}
|
||||
|
||||
/* -- 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 `<div class="vote-option${this.voted ? " voted" : ""}" data-vote-id="${opt.id}">
|
||||
<div class="vote-fill" style="width:${pct}%"></div>
|
||||
<span class="vote-emoji">${opt.emoji}</span>
|
||||
<span class="vote-name">${this.esc(opt.name)}${isLeader ? `<span class="vote-badge">Leading</span>` : ""}</span>
|
||||
<span class="vote-count">${opt.votes}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const status = this.voted
|
||||
? "You voted! Simulation paused."
|
||||
: "Votes are rolling in live...";
|
||||
|
||||
return `<div class="vote-wrap">
|
||||
<div class="vote-status">${status}</div>
|
||||
${items}
|
||||
<div class="vote-actions">
|
||||
<button class="vote-reset">Reset</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* -- Demo event binding -- */
|
||||
|
||||
private bindDemoEvents() {
|
||||
// Tab switching
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>(".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<HTMLElement>(".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<HTMLLIElement>(".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<HTMLElement>(".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<HTMLButtonElement>(".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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<p class="desc">Zero-knowledge, cookieless, self-hosted analytics for the r* ecosystem. Know how your tools are used without compromising anyone's privacy.</p>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any[]> = {
|
||||
"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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.view === "list" ? this.renderList() : ""}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any[]> = {
|
||||
team: [
|
||||
{ id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] },
|
||||
{ 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; }
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
${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`, {
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<circle cx="${x}" cy="${y}" r="6" fill="${p.color}" stroke="#fff" stroke-width="1.5" opacity="0.9">
|
||||
<animate attributeName="r" values="6;8;6" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<text x="${labelX}" y="${labelY}" fill="${p.color}" font-size="10" font-weight="600" font-family="system-ui, sans-serif">${this.esc(p.name)}</text>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const legendItems = this.providers.map((p) => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">
|
||||
<div style="width:10px;height:10px;border-radius:50%;background:${p.color};flex-shrink:0;"></div>
|
||||
<div>
|
||||
<span style="font-weight:600;font-size:13px;color:#e2e8f0;">${this.esc(p.name)}</span>
|
||||
<span style="font-size:12px;color:#64748b;margin-left:8px;">${this.esc(p.city)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.status-connected { background: #22c55e; }
|
||||
.map-container {
|
||||
width: 100%; border-radius: 10px;
|
||||
background: #1a1a2e; border: 1px solid #333;
|
||||
overflow: hidden; padding: 16px;
|
||||
}
|
||||
.map-svg { width: 100%; height: auto; }
|
||||
.legend {
|
||||
background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 10px;
|
||||
padding: 16px; margin-top: 16px;
|
||||
}
|
||||
.legend-title { font-size: 13px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-container { height: 300px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Cosmolocal Print Network</span>
|
||||
<span class="status-dot status-connected"></span>
|
||||
<span style="font-size:12px;color:#888;margin-left:4px;">6 providers</span>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<svg class="map-svg" viewBox="0 0 ${mapWidth} ${mapHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grid lines -->
|
||||
<line x1="0" y1="${mapHeight / 2}" x2="${mapWidth}" y2="${mapHeight / 2}" stroke="#2a2a4e" stroke-width="0.5" stroke-dasharray="4,4" />
|
||||
<line x1="${mapWidth / 2}" y1="0" x2="${mapWidth / 2}" y2="${mapHeight}" stroke="#2a2a4e" stroke-width="0.5" stroke-dasharray="4,4" />
|
||||
<line x1="0" y1="${mapHeight / 4}" x2="${mapWidth}" y2="${mapHeight / 4}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
|
||||
<line x1="0" y1="${mapHeight * 3 / 4}" x2="${mapWidth}" y2="${mapHeight * 3 / 4}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
|
||||
<line x1="${mapWidth / 4}" y1="0" x2="${mapWidth / 4}" y2="${mapHeight}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
|
||||
<line x1="${mapWidth * 3 / 4}" y1="0" x2="${mapWidth * 3 / 4}" y2="${mapHeight}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
|
||||
|
||||
<!-- Simplified continent outlines -->
|
||||
<!-- North America -->
|
||||
<path d="M80,60 Q120,55 140,80 L160,90 Q170,110 150,130 L130,150 Q110,160 100,150 L80,120 Q60,90 80,60Z" fill="#2a2a4e" opacity="0.5" />
|
||||
<!-- South America -->
|
||||
<path d="M160,180 Q170,170 175,190 L180,230 Q185,260 170,280 L160,290 Q145,280 150,250 L155,220 Q150,200 160,180Z" fill="#2a2a4e" opacity="0.5" />
|
||||
<!-- Europe -->
|
||||
<path d="M380,65 Q400,55 420,60 L440,70 Q445,85 430,95 L410,100 Q390,95 380,80Z" fill="#2a2a4e" opacity="0.5" />
|
||||
<!-- Africa -->
|
||||
<path d="M390,110 Q410,105 430,115 L440,140 Q445,180 430,220 L415,240 Q400,245 390,230 L385,190 Q380,150 390,110Z" fill="#2a2a4e" opacity="0.5" />
|
||||
<!-- Asia -->
|
||||
<path d="M440,50 Q500,40 560,55 L600,70 Q640,80 660,100 L670,130 Q660,140 640,130 L600,110 Q550,100 500,90 L460,85 Q440,75 440,50Z" fill="#2a2a4e" opacity="0.5" />
|
||||
<!-- Australia -->
|
||||
<path d="M600,220 Q630,210 650,220 L660,240 Q655,260 640,265 L620,260 Q600,250 600,220Z" fill="#2a2a4e" opacity="0.5" />
|
||||
<!-- Japan -->
|
||||
<path d="M665,85 Q670,80 672,90 L670,100 Q665,105 663,95Z" fill="#2a2a4e" opacity="0.5" />
|
||||
|
||||
<!-- Provider pins -->
|
||||
${providerDots}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-title">Print Providers</div>
|
||||
${legendItems}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:12px">${this.esc(this.error)}</div>` : ""}
|
||||
|
|
|
|||
|
|
@ -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 `<div class="placeholder"><p style="font-size:14px;color:#888">No nodes match current filter.</p></div>`;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
return `
|
||||
<div class="placeholder">
|
||||
<p style="font-size:48px">🕸️</p>
|
||||
<p style="font-size:16px">Community Relationship Graph</p>
|
||||
<p>Connect the force-directed layout engine to visualize your network.</p>
|
||||
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" opacity="0.8"/>
|
||||
<text x="${x}" y="${y + radius + 14}" fill="#aaa" font-size="10" text-anchor="middle">${this.esc(node.name)}</text>
|
||||
`;
|
||||
}).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(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#333" stroke-width="1" opacity="0.4"/>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `<svg viewBox="0 0 500 500" width="100%" height="100%" style="max-height:500px">${edgesSvg.join("")}${nodesSvg}</svg>`;
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
|
|
@ -95,14 +191,32 @@ class FolkGraphViewer extends HTMLElement {
|
|||
.stat { text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #6366f1; }
|
||||
.stat-label { font-size: 11px; color: #888; }
|
||||
|
||||
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.graph-canvas { height: 350px; }
|
||||
.workspace-list { grid-template-columns: 1fr; }
|
||||
.stats { flex-wrap: wrap; gap: 12px; }
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.search-input { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Network Graph</span>
|
||||
<span class="rapp-nav__title">Network Graph${this.space === "demo" ? '<span class="demo-badge">Demo</span>' : ""}</span>
|
||||
</div>
|
||||
|
||||
${this.info ? `
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">Members</div></div>
|
||||
<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Companies</div></div>
|
||||
<div class="stat"><div class="stat-value">${this.info.opportunity_count || 0}</div><div class="stat-label">Opportunities</div></div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="toolbar">
|
||||
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="${this.esc(this.searchQuery)}">
|
||||
${(["all", "person", "company", "opportunity"] as const).map(f =>
|
||||
|
|
@ -111,12 +225,14 @@ class FolkGraphViewer extends HTMLElement {
|
|||
</div>
|
||||
|
||||
<div class="graph-canvas">
|
||||
${this.nodes.length > 0 ? this.renderGraphNodes() : `
|
||||
<div class="placeholder">
|
||||
<p style="font-size:48px">🕸️</p>
|
||||
<p style="font-size:48px">🕸️</p>
|
||||
<p style="font-size:16px">Community Relationship Graph</p>
|
||||
<p>Connect the force-directed layout engine to visualize your network.</p>
|
||||
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
|
|
@ -131,7 +247,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
${this.workspaces.map(ws => `
|
||||
<div class="ws-card">
|
||||
<div class="ws-name">${this.esc(ws.name || ws.slug)}</div>
|
||||
<div class="ws-meta">${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges</div>
|
||||
<div class="ws-meta">${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">← ${this.selectedNotebook ? this.esc(this.selectedNotebook.title) : "Notebooks"}</button>
|
||||
${isAutomerge
|
||||
${isEditable
|
||||
? `<input class="editable-title" id="note-title-input" value="${this.esc(n.title)}" placeholder="Note title...">`
|
||||
: `<span class="rapp-nav__title">${this.getNoteIcon(n.type)} ${this.esc(n.title)}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="note-content" ${isAutomerge ? 'contenteditable="true" id="note-content-editable"' : ""}>${n.content || '<em style="color:#666">Empty note</em>'}</div>
|
||||
<div class="note-content" ${isEditable ? 'contenteditable="true" id="note-content-editable"' : ""}>${n.content || '<em style="color:#666">Empty note</em>'}</div>
|
||||
<div style="margin-top:12px;font-size:12px;color:#666;display:flex;gap:12px">
|
||||
<span>Type: ${n.type}</span>
|
||||
<span>Created: ${this.formatDate(n.created_at)}</span>
|
||||
<span>Updated: ${this.formatDate(n.updated_at)}</span>
|
||||
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
|
||||
${isAutomerge ? '<span style="color:#10b981">Live</span>' : ""}
|
||||
${isDemo ? '<span style="color:#f59e0b">Demo</span>' : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 || "";
|
||||
|
|
|
|||
|
|
@ -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<string, { width: number; height: number; color: string }> = {
|
||||
"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<string, string> = {
|
||||
"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); }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
||||
|
|
@ -296,9 +381,11 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
${this.albums.map((a) => `
|
||||
<div class="album-card" data-album-id="${a.id}">
|
||||
<div class="album-thumb">
|
||||
${a.albumThumbnailAssetId
|
||||
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
|
||||
: '<span class="album-thumb-empty">📷</span>'}
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
|
||||
: a.albumThumbnailAssetId
|
||||
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
|
||||
: '<span class="album-thumb-empty">📷</span>'}
|
||||
</div>
|
||||
<div class="album-info">
|
||||
<div class="album-name">${this.esc(a.albumName)}</div>
|
||||
|
|
@ -315,7 +402,9 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<div class="photo-grid">
|
||||
${this.assets.map((a) => `
|
||||
<div class="photo-cell" data-asset-id="${a.id}">
|
||||
<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
||||
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
|
|
@ -346,7 +435,9 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<div class="photo-grid">
|
||||
${this.albumAssets.map((a) => `
|
||||
<div class="photo-cell" data-asset-id="${a.id}">
|
||||
<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
||||
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
|
|
@ -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 `
|
||||
<div class="lightbox" data-lightbox>
|
||||
<button class="lightbox-close" data-close-lightbox>✕</button>
|
||||
<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">
|
||||
${demoMeta
|
||||
? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>`
|
||||
: `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">`}
|
||||
<div class="lightbox-info">
|
||||
${asset.originalFileName}
|
||||
${asset.originalFileName}${demoMeta ? ` · ${demoMeta.width}x${demoMeta.height}` : ""}
|
||||
${location ? ` · ${this.esc(location)}` : ""}
|
||||
${camera ? ` · ${this.esc(camera)}` : ""}
|
||||
· ${this.formatDate(asset.fileCreatedAt)}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.render();
|
||||
const space = this.getAttribute("space") || "";
|
||||
if (space === "demo") {
|
||||
this.loadDemoContent();
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoContent() {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.shadowRoot) return;
|
||||
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
||||
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
|
||||
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
|
||||
if (textarea) textarea.value = SAMPLE_CONTENT;
|
||||
if (titleInput) titleInput.value = "The Commons";
|
||||
if (authorInput) authorInput.value = "rSpace Community";
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
|
|||
|
|
@ -73,17 +73,104 @@ routes.get("/api/feed", (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Demo feed data (server-rendered, no API calls) ──
|
||||
const DEMO_FEED = [
|
||||
{
|
||||
username: "@alice",
|
||||
initial: "A",
|
||||
color: "#6366f1",
|
||||
content: "Just deployed the new rFunds river view! The enoughness score is such a powerful concept. \u{1F30A}",
|
||||
timeAgo: "2 hours ago",
|
||||
likes: 5,
|
||||
replies: 2,
|
||||
},
|
||||
{
|
||||
username: "@bob",
|
||||
initial: "B",
|
||||
color: "#f59e0b",
|
||||
content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!",
|
||||
timeAgo: "5 hours ago",
|
||||
likes: 8,
|
||||
replies: 4,
|
||||
},
|
||||
{
|
||||
username: "@carol",
|
||||
initial: "C",
|
||||
color: "#10b981",
|
||||
content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}",
|
||||
timeAgo: "1 day ago",
|
||||
likes: 12,
|
||||
replies: 3,
|
||||
},
|
||||
{
|
||||
username: "@diana",
|
||||
initial: "D",
|
||||
color: "#ec4899",
|
||||
content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.",
|
||||
timeAgo: "1 day ago",
|
||||
likes: 7,
|
||||
replies: 5,
|
||||
},
|
||||
{
|
||||
username: "@eve",
|
||||
initial: "E",
|
||||
color: "#14b8a6",
|
||||
content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!",
|
||||
timeAgo: "2 days ago",
|
||||
likes: 3,
|
||||
replies: 1,
|
||||
},
|
||||
{
|
||||
username: "@frank",
|
||||
initial: "F",
|
||||
color: "#8b5cf6",
|
||||
content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}",
|
||||
timeAgo: "3 days ago",
|
||||
likes: 15,
|
||||
replies: 6,
|
||||
},
|
||||
];
|
||||
|
||||
function renderDemoFeedHTML(): string {
|
||||
const cards = DEMO_FEED.map(
|
||||
(post) => `
|
||||
<article class="rsocials-item">
|
||||
<div class="rsocials-item-header">
|
||||
<div class="rsocials-avatar" style="background:${post.color}">${post.initial}</div>
|
||||
<div class="rsocials-meta">
|
||||
<strong>${post.username}</strong>
|
||||
<time>${post.timeAgo}</time>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rsocials-item-content">${post.content}</p>
|
||||
<div class="rsocials-item-actions">
|
||||
<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> ${post.likes}</span>
|
||||
<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> ${post.replies}</span>
|
||||
</div>
|
||||
</article>`,
|
||||
).join("\n");
|
||||
|
||||
return `
|
||||
<div class="rsocials-app rsocials-demo">
|
||||
<div class="rsocials-header">
|
||||
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
|
||||
<p class="rsocials-subtitle">A preview of your community's social timeline</p>
|
||||
</div>
|
||||
<div class="rsocials-feed">
|
||||
${cards}
|
||||
</div>
|
||||
<p class="rsocials-demo-notice">This is demo data. Connect ActivityPub or RSS feeds in your own space.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(
|
||||
renderShell({
|
||||
title: `${space} — Socials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `
|
||||
const isDemo = space === "demo";
|
||||
|
||||
const body = isDemo
|
||||
? renderDemoFeedHTML()
|
||||
: `
|
||||
<div class="rsocials-app" data-space="${space}">
|
||||
<div class="rsocials-header">
|
||||
<h2>Community Feed</h2>
|
||||
|
|
@ -134,28 +221,57 @@ routes.get("/", (c) => {
|
|||
} catch(e) {
|
||||
feedEl.innerHTML = '<p class="rsocials-empty">Failed to load feed.</p>';
|
||||
}
|
||||
</script>`,
|
||||
</script>`;
|
||||
|
||||
return c.html(
|
||||
renderShell({
|
||||
title: `${space} — Socials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body,
|
||||
styles: `<style>
|
||||
.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.rsocials-header { margin-bottom: 1.5rem; }
|
||||
.rsocials-header h2 {
|
||||
font-size: 1.5rem; margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem; margin: 0 0 0.25rem; display: flex; align-items: center; gap: 0.75rem;
|
||||
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.rsocials-demo-badge {
|
||||
font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
background: #6366f1; color: white;
|
||||
-webkit-text-fill-color: white;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
text-transform: uppercase; line-height: 1.6;
|
||||
}
|
||||
.rsocials-subtitle { color: #64748b; font-size: 0.85rem; margin: 0; }
|
||||
.rsocials-feed { display: flex; flex-direction: column; gap: 1px; }
|
||||
.rsocials-loading { color: #64748b; padding: 2rem 0; text-align: center; }
|
||||
.rsocials-empty { color: #64748b; padding: 2rem 0; text-align: center; }
|
||||
.rsocials-item {
|
||||
padding: 1rem; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
||||
background: #1e293b; border: 1px solid rgba(255,255,255,0.06);
|
||||
margin-bottom: 0.5rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.rsocials-item:hover { border-color: rgba(99,102,241,0.3); }
|
||||
.rsocials-item-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.5rem;
|
||||
}
|
||||
.rsocials-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-weight: 700; font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rsocials-meta {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.rsocials-meta strong { color: #e2e8f0; font-size: 0.9rem; }
|
||||
.rsocials-meta time { font-size: 0.75rem; color: #64748b; }
|
||||
.rsocials-item-header strong { color: #e2e8f0; }
|
||||
.rsocials-item-header time { margin-left: auto; font-size: 0.75rem; }
|
||||
.rsocials-source {
|
||||
|
|
@ -163,7 +279,7 @@ routes.get("/", (c) => {
|
|||
background: rgba(124,58,237,0.15); color: #c4b5fd;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.rsocials-item-content { margin: 0 0 0.5rem; color: #cbd5e1; line-height: 1.5; font-size: 0.9rem; }
|
||||
.rsocials-item-content { margin: 0 0 0.75rem; color: #cbd5e1; line-height: 1.6; font-size: 0.9rem; }
|
||||
.rsocials-item-link {
|
||||
display: block; font-size: 0.8rem; color: #7dd3fc;
|
||||
text-decoration: none; margin-bottom: 0.5rem; word-break: break-all;
|
||||
|
|
@ -172,6 +288,11 @@ routes.get("/", (c) => {
|
|||
.rsocials-item-actions {
|
||||
display: flex; gap: 1rem; font-size: 0.8rem; color: #64748b;
|
||||
}
|
||||
.rsocials-action {
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
cursor: default;
|
||||
}
|
||||
.rsocials-action svg { opacity: 0.7; }
|
||||
.rsocials-demo-notice {
|
||||
text-align: center; font-size: 0.75rem; color: #475569;
|
||||
padding: 1rem 0; border-top: 1px solid rgba(255,255,255,0.05); margin-top: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
class FolkSwagDesigner extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private selectedProduct = "sticker";
|
||||
private imageFile: File | null = null;
|
||||
private imagePreview = "";
|
||||
|
|
@ -20,9 +21,44 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "";
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.selectedProduct = "sticker";
|
||||
this.designTitle = "Cosmolocal Network";
|
||||
this.imagePreview = "";
|
||||
this.render();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Set the title input value
|
||||
const titleInput = this.shadow.querySelector(".title-input") as HTMLInputElement;
|
||||
if (titleInput) titleInput.value = this.designTitle;
|
||||
|
||||
// Show a demo artifact result
|
||||
this.artifact = {
|
||||
title: "Cosmolocal Network",
|
||||
product: "sticker",
|
||||
payload: { title: "Cosmolocal Network" },
|
||||
spec: {
|
||||
product_type: "sticker",
|
||||
dimensions: { width_mm: 76, height_mm: 76 },
|
||||
dpi: 300,
|
||||
},
|
||||
render_targets: {
|
||||
pdf: { format: "pdf", label: "Print-Ready PDF", size: "3x3 in", url: "#demo-pdf" },
|
||||
png: { format: "png", label: "Preview PNG", size: "900x900 px", url: "#demo-png" },
|
||||
},
|
||||
};
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
|
|
@ -30,6 +66,32 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
}
|
||||
|
||||
private async generate() {
|
||||
if (this.space === "demo") {
|
||||
this.generating = true;
|
||||
this.error = "";
|
||||
this.artifact = null;
|
||||
this.render();
|
||||
// Simulate a short delay, then show demo artifact
|
||||
setTimeout(() => {
|
||||
this.artifact = {
|
||||
title: this.designTitle || "Untitled Design",
|
||||
product: this.selectedProduct,
|
||||
payload: { title: this.designTitle || "Untitled Design" },
|
||||
spec: {
|
||||
product_type: this.selectedProduct,
|
||||
dimensions: { width_mm: this.selectedProduct === "tee" ? 305 : this.selectedProduct === "poster" ? 297 : 76, height_mm: this.selectedProduct === "tee" ? 406 : this.selectedProduct === "poster" ? 420 : 76 },
|
||||
dpi: 300,
|
||||
},
|
||||
render_targets: {
|
||||
pdf: { format: "pdf", label: "Print-Ready PDF", size: this.selectedProduct === "tee" ? "12x16 in" : this.selectedProduct === "poster" ? "A3" : "3x3 in", url: "#demo-pdf" },
|
||||
png: { format: "png", label: "Preview PNG", size: this.selectedProduct === "tee" ? "3600x4800 px" : this.selectedProduct === "poster" ? "3508x4961 px" : "900x900 px", url: "#demo-png" },
|
||||
},
|
||||
};
|
||||
this.generating = false;
|
||||
this.render();
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
if (!this.imageFile || this.generating) return;
|
||||
this.generating = true;
|
||||
this.error = "";
|
||||
|
|
@ -102,6 +164,9 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
.json-pre { background: #0f172a; border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: #94a3b8; margin-top: 0.5rem; max-height: 300px; display: none; }
|
||||
.json-pre.visible { display: block; }
|
||||
input[type="file"] { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.products { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="products">
|
||||
|
|
@ -123,7 +188,7 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
|
||||
<input class="title-input" type="text" placeholder="Design title" value="${this.esc(this.designTitle)}">
|
||||
|
||||
<button class="generate-btn" ${!this.imageFile || this.generating ? 'disabled' : ''}>
|
||||
<button class="generate-btn" ${(this.space !== "demo" && !this.imageFile) || this.generating ? 'disabled' : ''}>
|
||||
${this.generating ? '⏳ Generating...' : '🚀 Generate Print-Ready Files'}
|
||||
</button>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,101 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadTrips();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.trips = [
|
||||
{ id: "t1", title: "Berlin Maker Week", status: "PLANNING", start_date: "2026-04-14", end_date: "2026-04-20", budget_total: "2500", total_spent: "850", destination_count: 3, description: "Visiting makerspaces and cosmolocal print providers across Berlin" },
|
||||
{ id: "t2", title: "Mediterranean Commons Tour", status: "BOOKED", start_date: "2026-06-01", end_date: "2026-06-15", budget_total: "4000", total_spent: "3200", destination_count: 4, description: "Connecting with commons communities in Barcelona, Marseille, and Athens" }
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getDemoTripDetail(id: string): any {
|
||||
if (id === "t1") {
|
||||
return {
|
||||
id: "t1",
|
||||
title: "Berlin Maker Week",
|
||||
status: "PLANNING",
|
||||
start_date: "2026-04-14",
|
||||
end_date: "2026-04-20",
|
||||
budget_total: "2500",
|
||||
description: "Visiting makerspaces and cosmolocal print providers across Berlin",
|
||||
destinations: [
|
||||
{ id: "d1", name: "Druckwerkstatt Berlin", country: "Germany", arrival_date: "2026-04-14" },
|
||||
{ id: "d2", name: "c-base Hackerspace", country: "Germany", arrival_date: "2026-04-16" },
|
||||
{ id: "d3", name: "Fab Lab Berlin", country: "Germany", arrival_date: "2026-04-18" }
|
||||
],
|
||||
itinerary: [
|
||||
{ id: "i1", title: "Visit Druckwerkstatt", category: "ACTIVITY", date: "2026-04-14", start_time: "10:00" },
|
||||
{ id: "i2", title: "Print workshop", category: "WORKSHOP", date: "2026-04-15", start_time: "09:00" },
|
||||
{ id: "i3", title: "c-base tour", category: "ACTIVITY", date: "2026-04-16", start_time: "14:00" },
|
||||
{ id: "i4", title: "Fab Lab session", category: "WORKSHOP", date: "2026-04-18", start_time: "10:00" },
|
||||
{ id: "i5", title: "Team dinner", category: "SOCIAL", date: "2026-04-19", start_time: "19:00" }
|
||||
],
|
||||
bookings: [
|
||||
{ id: "bk1", type: "HOTEL", provider: "Hotel Amano", confirmation_number: "AMN-29381", cost: "560" },
|
||||
{ id: "bk2", type: "TRANSPORT", provider: "Deutsche Bahn", confirmation_number: "DB-773920", cost: "85" }
|
||||
],
|
||||
expenses: [
|
||||
{ id: "e1", category: "TRANSPORT", description: "Flights", amount: "450", date: "2026-04-14" },
|
||||
{ id: "e2", category: "ACCOMMODATION", description: "Accommodation", amount: "280", date: "2026-04-14" },
|
||||
{ id: "e3", category: "ACTIVITY", description: "Workshop fee", amount: "120", date: "2026-04-15" }
|
||||
],
|
||||
packing: [
|
||||
{ id: "pk1", name: "Laptop", category: "TECH", quantity: 1, packed: true },
|
||||
{ id: "pk2", name: "Notebook", category: "SUPPLIES", quantity: 1, packed: true },
|
||||
{ id: "pk3", name: "USB drives", category: "TECH", quantity: 3, packed: false },
|
||||
{ id: "pk4", name: "Camera", category: "TECH", quantity: 1, packed: false },
|
||||
{ id: "pk5", name: "Adapters", category: "TECH", quantity: 2, packed: false }
|
||||
]
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "t2",
|
||||
title: "Mediterranean Commons Tour",
|
||||
status: "BOOKED",
|
||||
start_date: "2026-06-01",
|
||||
end_date: "2026-06-15",
|
||||
budget_total: "4000",
|
||||
description: "Connecting with commons communities in Barcelona, Marseille, and Athens",
|
||||
destinations: [
|
||||
{ id: "d4", name: "Fab Lab Barcelona", country: "Spain", arrival_date: "2026-06-01" },
|
||||
{ id: "d5", name: "La Coop des Communs", country: "France", arrival_date: "2026-06-05" },
|
||||
{ id: "d6", name: "P2P Foundation Athens", country: "Greece", arrival_date: "2026-06-09" },
|
||||
{ id: "d7", name: "Synergatika Cooperative", country: "Greece", arrival_date: "2026-06-12" }
|
||||
],
|
||||
itinerary: [
|
||||
{ id: "i6", title: "Fab Lab Barcelona tour", category: "ACTIVITY", date: "2026-06-01", start_time: "10:00" },
|
||||
{ id: "i7", title: "Commons workshop", category: "WORKSHOP", date: "2026-06-02", start_time: "09:00" },
|
||||
{ id: "i8", title: "Marseille meetup", category: "SOCIAL", date: "2026-06-05", start_time: "18:00" },
|
||||
{ id: "i9", title: "P2P Foundation visit", category: "ACTIVITY", date: "2026-06-09", start_time: "11:00" },
|
||||
{ id: "i10", title: "Cooperative workshop", category: "WORKSHOP", date: "2026-06-12", start_time: "10:00" }
|
||||
],
|
||||
bookings: [
|
||||
{ id: "bk3", type: "TRANSPORT", provider: "Vueling Airlines", confirmation_number: "VY-482910", cost: "320" },
|
||||
{ id: "bk4", type: "HOTEL", provider: "Hotel Casa Bonay", confirmation_number: "CB-11204", cost: "780" },
|
||||
{ id: "bk5", type: "TRANSPORT", provider: "Eurostar", confirmation_number: "ES-556271", cost: "190" }
|
||||
],
|
||||
expenses: [
|
||||
{ id: "e4", category: "TRANSPORT", description: "Flights and trains", amount: "1200", date: "2026-06-01" },
|
||||
{ id: "e5", category: "ACCOMMODATION", description: "Hotels and hostels", amount: "1400", date: "2026-06-01" },
|
||||
{ id: "e6", category: "FOOD", description: "Meals and groceries", amount: "450", date: "2026-06-01" },
|
||||
{ id: "e7", category: "ACTIVITY", description: "Workshop fees", amount: "150", date: "2026-06-02" }
|
||||
],
|
||||
packing: [
|
||||
{ id: "pk6", name: "Laptop", category: "TECH", quantity: 1, packed: true },
|
||||
{ id: "pk7", name: "Sunscreen", category: "PERSONAL", quantity: 1, packed: true },
|
||||
{ id: "pk8", name: "Phrasebooks", category: "SUPPLIES", quantity: 2, packed: false },
|
||||
{ id: "pk9", name: "Camera", category: "TECH", quantity: 1, packed: true },
|
||||
{ id: "pk10", name: "Power bank", category: "TECH", quantity: 1, packed: false }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/trips/);
|
||||
|
|
@ -41,6 +132,11 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
}
|
||||
|
||||
private async loadTrip(id: string) {
|
||||
if (this.space === "demo") {
|
||||
this.trip = this.getDemoTripDetail(id);
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/trips/${id}`);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
private currentVideo: string | null = null;
|
||||
private mode: "library" | "live" = "library";
|
||||
private streamKey = "";
|
||||
private searchTerm = "";
|
||||
private isDemo = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -20,9 +22,23 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadVideos();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.isDemo = true;
|
||||
this.videos = [
|
||||
{ name: "community-meeting-2026-02-15.mp4", size: 524288000 },
|
||||
{ name: "rspace-demo-walkthrough.mp4", size: 157286400 },
|
||||
{ name: "design-sprint-day1.webm", size: 892108800 },
|
||||
{ name: "interview-cosmolocal-founders.mp4", size: 1073741824 },
|
||||
{ name: "workshop-local-first-data.mp4", size: 734003200 },
|
||||
{ name: "lightning-talks-feb2026.webm", size: 445644800 },
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadVideos() {
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
|
|
@ -102,18 +118,30 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
}
|
||||
|
||||
private renderLibrary(): string {
|
||||
const sidebar = this.videos.length === 0
|
||||
? `<div class="empty">No videos yet</div>`
|
||||
: this.videos.map((v) => `
|
||||
const filteredVideos = this.searchTerm
|
||||
? this.videos.filter(v => v.name.toLowerCase().includes(this.searchTerm.toLowerCase()))
|
||||
: this.videos;
|
||||
|
||||
const searchInput = `<input type="text" placeholder="Search videos..." data-input="search" value="${this.searchTerm}" style="margin-bottom:0.75rem" />`;
|
||||
|
||||
const videoList = filteredVideos.length === 0
|
||||
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
|
||||
: filteredVideos.map((v) => `
|
||||
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}">
|
||||
<div class="video-name">${v.name}</div>
|
||||
<div class="video-meta">${this.getExtension(v.name).toUpperCase()} · ${this.formatSize(v.size)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
const sidebar = searchInput + videoList;
|
||||
|
||||
let player: string;
|
||||
if (!this.currentVideo) {
|
||||
player = `<div class="placeholder"><div class="placeholder-icon">🎬</div><p>Select a video to play</p></div>`;
|
||||
} else if (this.isDemo) {
|
||||
const selectedVideo = this.videos.find(v => v.name === this.currentVideo);
|
||||
const sizeStr = selectedVideo ? this.formatSize(selectedVideo.size) : "";
|
||||
player = `<div class="placeholder"><div class="placeholder-icon">🎦</div><p style="font-size:1rem;font-weight:500;margin-bottom:0.5rem">${this.currentVideo}</p><p style="font-size:0.85rem;color:#64748b">${this.getExtension(this.currentVideo).toUpperCase()} · ${sizeStr}</p><p style="font-size:0.75rem;color:#475569;margin-top:1rem">Demo mode — no actual video file</p></div>`;
|
||||
} else if (!this.isPlayable(this.currentVideo)) {
|
||||
player = `<div class="placeholder"><div class="placeholder-icon">⚠️</div><p><strong>${this.getExtension(this.currentVideo).toUpperCase()}</strong> files cannot play in browsers</p><p style="font-size:0.8rem;color:#475569;margin-top:0.5rem">Download to play locally</p></div>`;
|
||||
} else {
|
||||
|
|
@ -124,9 +152,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
const infoBar = this.currentVideo ? `
|
||||
<div class="info-bar">
|
||||
<span class="info-name">${this.currentVideo}</span>
|
||||
<div class="actions">
|
||||
<button class="btn" data-action="copy">Copy Link</button>
|
||||
</div>
|
||||
${!this.isDemo ? `<div class="actions"><button class="btn" data-action="copy">Copy Link</button></div>` : ""}
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
|
|
@ -172,6 +198,17 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
const searchInput = this.shadow.querySelector('[data-input="search"]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
this.searchTerm = searchInput.value;
|
||||
this.render();
|
||||
// Restore focus and cursor position after re-render
|
||||
const newInput = this.shadow.querySelector('[data-input="search"]') as HTMLInputElement;
|
||||
if (newInput) { newInput.focus(); newInput.selectionStart = newInput.selectionEnd = newInput.value.length; }
|
||||
});
|
||||
}
|
||||
|
||||
this.shadow.querySelectorAll(".video-item").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
this.currentVideo = (item as HTMLElement).dataset.name || null;
|
||||
|
|
|
|||
|
|
@ -45,9 +45,28 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadSpaces();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.spaces = [
|
||||
{ slug: "rspace", name: "rSpace Governance", description: "Community proposals for the rSpace ecosystem", promotion_threshold: 100, voting_period_days: 7, credits_per_day: 10 }
|
||||
];
|
||||
this.selectedSpace = this.spaces[0];
|
||||
this.view = "proposals";
|
||||
|
||||
const now = Date.now();
|
||||
const day = 86400000;
|
||||
this.proposals = [
|
||||
{ id: "p1", title: "Fund local-first infrastructure sprint", description: "Allocate 5000 DAI for a 2-week sprint on Automerge integration", status: "RANKING", score: 73, vote_count: "12", final_yes: 0, final_no: 0, final_abstain: 0, created_at: new Date(now - 2 * day).toISOString(), voting_ends_at: null },
|
||||
{ id: "p2", title: "Add dark mode to all rApps", description: "Standardize dark theme tokens across the ecosystem", status: "VOTING", score: 100, vote_count: "8", final_yes: 14, final_no: 3, final_abstain: 2, created_at: new Date(now - 5 * day).toISOString(), voting_ends_at: new Date(now + 3 * day).toISOString() },
|
||||
{ id: "p3", title: "Create onboarding tutorial series", description: "Video walkthrough series for new community members", status: "PASSED", score: 100, vote_count: "15", final_yes: 22, final_no: 4, final_abstain: 1, created_at: new Date(now - 14 * day).toISOString(), voting_ends_at: new Date(now - 3 * day).toISOString() },
|
||||
{ id: "p4", title: "Migrate to paid hosting tier", description: "Move from shared to dedicated infrastructure", status: "FAILED", score: 100, vote_count: "11", final_yes: 5, final_no: 18, final_abstain: 3, created_at: new Date(now - 14 * day).toISOString(), voting_ends_at: new Date(now - 5 * day).toISOString() }
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/vote/);
|
||||
|
|
@ -99,6 +118,12 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private async castVote(proposalId: string, weight: number) {
|
||||
if (this.space === "demo") {
|
||||
const p = this.proposals.find(p => p.id === proposalId);
|
||||
if (p) { p.score += weight; this.selectedProposal = p; }
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/proposals/${proposalId}/vote`, {
|
||||
|
|
@ -114,6 +139,17 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private async castFinalVote(proposalId: string, vote: string) {
|
||||
if (this.space === "demo") {
|
||||
const p = this.proposals.find(p => p.id === proposalId);
|
||||
if (p) {
|
||||
if (vote === "YES") p.final_yes++;
|
||||
else if (vote === "NO") p.final_no++;
|
||||
else if (vote === "ABSTAIN") p.final_abstain++;
|
||||
this.selectedProposal = p;
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/proposals/${proposalId}/final-vote`, {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
private balances: BalanceItem[] = [];
|
||||
private loading = false;
|
||||
private error = "";
|
||||
private isDemo = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -48,6 +49,11 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
const space = this.getAttribute("space") || "";
|
||||
if (space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
// Check URL params for initial address
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.address = params.get("address") || "";
|
||||
|
|
@ -55,6 +61,25 @@ class FolkWalletViewer extends HTMLElement {
|
|||
if (this.address) this.detectChains();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.isDemo = true;
|
||||
this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1";
|
||||
this.detectedChains = [
|
||||
{ chainId: "1", name: "Ethereum", prefix: "eth", color: "#627eea" },
|
||||
{ chainId: "100", name: "Gnosis Chain", prefix: "gno", color: "#04795b" },
|
||||
{ chainId: "137", name: "Polygon", prefix: "matic", color: "#8247e5" },
|
||||
];
|
||||
this.selectedChain = "100";
|
||||
this.balances = [
|
||||
{ tokenAddress: null, token: { name: "xDAI", symbol: "XDAI", decimals: 18 }, balance: "45230000000000000000000", fiatBalance: "45230", fiatConversion: "1" },
|
||||
{ tokenAddress: "0x5dF8339c5E282ee48c0c7cE252A7842F74e378b2", token: { name: "Token Engineering Commons", symbol: "TEC", decimals: 18 }, balance: "1250000000000000000000000", fiatBalance: "12500", fiatConversion: "0.01" },
|
||||
{ tokenAddress: "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", token: { name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, balance: "8500000000000000000", fiatBalance: "28050", fiatConversion: "3300" },
|
||||
{ tokenAddress: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", token: { name: "USD Coin", symbol: "USDC", decimals: 6 }, balance: "15750000000", fiatBalance: "15750", fiatConversion: "1" },
|
||||
{ tokenAddress: "0x4f4F9b8D5B4d0Dc10506e5551B0513B61fD59e75", token: { name: "Giveth", symbol: "GIV", decimals: 18 }, balance: "500000000000000000000000", fiatBalance: "2500", fiatConversion: "0.005" },
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/wallet/);
|
||||
|
|
@ -62,6 +87,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
private async detectChains() {
|
||||
if (this.isDemo) return;
|
||||
if (!this.address || !/^0x[a-fA-F0-9]{40}$/.test(this.address)) {
|
||||
this.error = "Please enter a valid Ethereum address (0x...)";
|
||||
this.render();
|
||||
|
|
@ -100,6 +126,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
private async loadBalances() {
|
||||
if (this.isDemo) return;
|
||||
if (!this.selectedChain) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
|
|
@ -147,6 +174,10 @@ class FolkWalletViewer extends HTMLElement {
|
|||
|
||||
private async handleChainSelect(chainId: string) {
|
||||
this.selectedChain = chainId;
|
||||
if (this.isDemo) {
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.render();
|
||||
await this.loadBalances();
|
||||
|
|
@ -209,6 +240,13 @@ class FolkWalletViewer extends HTMLElement {
|
|||
.loading { text-align: center; color: #888; padding: 40px; }
|
||||
.error { text-align: center; color: #ef5350; padding: 20px; }
|
||||
.demo-link { color: #00d4ff; cursor: pointer; text-decoration: underline; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.address-bar { flex-wrap: wrap; }
|
||||
.address-bar input { min-width: 0; }
|
||||
.chains { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<form class="address-bar" id="address-form">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private statuses: string[] = ["TODO", "IN_PROGRESS", "REVIEW", "DONE"];
|
||||
private loading = false;
|
||||
private error = "";
|
||||
private isDemo = false;
|
||||
private dragTaskId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -23,10 +25,32 @@ class FolkWorkBoard extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadWorkspaces();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.isDemo = true;
|
||||
this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 4 }];
|
||||
this.view = "board";
|
||||
this.workspaceSlug = "rspace-dev";
|
||||
this.tasks = [
|
||||
{ id: "d1", title: "Add dark theme toggle", status: "TODO", priority: "NORMAL", labels: ["frontend"] },
|
||||
{ id: "d2", title: "Write API docs", status: "TODO", priority: "NORMAL", labels: ["backend", "docs"] },
|
||||
{ id: "d3", title: "Set up CI pipeline", status: "TODO", priority: "NORMAL", labels: ["devops"] },
|
||||
{ id: "d4", title: "Port canvas to WebGL", status: "IN_PROGRESS", priority: "NORMAL", labels: ["frontend"] },
|
||||
{ id: "d5", title: "Implement search API", status: "IN_PROGRESS", priority: "HIGH", labels: ["backend"] },
|
||||
{ id: "d6", title: "Design mobile nav", status: "IN_PROGRESS", priority: "NORMAL", labels: ["frontend", "design"] },
|
||||
{ id: "d7", title: "Fix WebSocket reconnection", status: "REVIEW", priority: "URGENT", labels: ["backend"] },
|
||||
{ id: "d8", title: "Add rate limiting", status: "REVIEW", priority: "NORMAL", labels: ["backend", "security"] },
|
||||
{ id: "d9", title: "Deploy auth service", status: "DONE", priority: "NORMAL", labels: ["devops"] },
|
||||
{ id: "d10", title: "Set up monitoring", status: "DONE", priority: "NORMAL", labels: ["devops"] },
|
||||
{ id: "d11", title: "Create landing page", status: "DONE", priority: "NORMAL", labels: ["frontend", "design"] },
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/work/);
|
||||
|
|
@ -61,6 +85,12 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private async createWorkspace() {
|
||||
const name = prompt("Workspace name:");
|
||||
if (!name?.trim()) return;
|
||||
if (this.isDemo) {
|
||||
const slug = name.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
this.workspaces.push({ slug, name: name.trim(), icon: "\u{1F4CB}", task_count: 0, member_count: 1 });
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/spaces`, {
|
||||
|
|
@ -75,6 +105,11 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private async createTask() {
|
||||
const title = prompt("Task title:");
|
||||
if (!title?.trim()) return;
|
||||
if (this.isDemo) {
|
||||
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority: "NORMAL", labels: [] });
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
|
||||
|
|
@ -87,6 +122,11 @@ class FolkWorkBoard extends HTMLElement {
|
|||
}
|
||||
|
||||
private async moveTask(taskId: string, newStatus: string) {
|
||||
if (this.isDemo) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) { task.status = newStatus; this.render(); }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/tasks/${taskId}`, {
|
||||
|
|
@ -134,10 +174,14 @@ class FolkWorkBoard extends HTMLElement {
|
|||
.col-header { font-size: 13px; font-weight: 600; text-transform: uppercase; color: #888; margin-bottom: 10px; display: flex; justify-content: space-between; }
|
||||
.col-count { background: #2a2a3a; border-radius: 10px; padding: 0 8px; font-size: 11px; }
|
||||
|
||||
.column.drag-over { background: #1a1a2e; border-color: #4f46e5; }
|
||||
|
||||
.task-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
||||
padding: 10px 12px; margin-bottom: 8px; cursor: grab;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.task-card.dragging { opacity: 0.4; }
|
||||
.task-card:hover { border-color: #555; }
|
||||
.task-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
|
||||
.task-meta { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
|
@ -150,6 +194,11 @@ class FolkWorkBoard extends HTMLElement {
|
|||
.move-btn:hover { border-color: #555; color: #ccc; }
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.board { flex-direction: column; overflow-x: visible; }
|
||||
.column { min-width: 100%; max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
|
@ -189,7 +238,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
${this.statuses.map(status => {
|
||||
const columnTasks = this.tasks.filter(t => t.status === status);
|
||||
return `
|
||||
<div class="column">
|
||||
<div class="column" data-status="${status}">
|
||||
<div class="col-header">
|
||||
<span>${this.esc(status.replace(/_/g, " "))}</span>
|
||||
<span class="col-count">${columnTasks.length}</span>
|
||||
|
|
@ -205,7 +254,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private renderTaskCard(task: any, currentStatus: string): string {
|
||||
const otherStatuses = this.statuses.filter(s => s !== currentStatus);
|
||||
return `
|
||||
<div class="task-card">
|
||||
<div class="task-card" draggable="true" data-task-id="${task.id}">
|
||||
<div class="task-title">${this.esc(task.title)}</div>
|
||||
<div class="task-meta">
|
||||
${task.priority === "URGENT" ? '<span class="badge badge-urgent">URGENT</span>' : ""}
|
||||
|
|
@ -236,6 +285,39 @@ class FolkWorkBoard extends HTMLElement {
|
|||
this.moveTask(btn.dataset.move!, btn.dataset.to!);
|
||||
});
|
||||
});
|
||||
|
||||
// HTML5 drag-and-drop on task cards
|
||||
this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => {
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
const el = card as HTMLElement;
|
||||
this.dragTaskId = el.dataset.taskId || null;
|
||||
el.classList.add("dragging");
|
||||
(e as DragEvent).dataTransfer?.setData("text/plain", this.dragTaskId || "");
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
(card as HTMLElement).classList.remove("dragging");
|
||||
this.dragTaskId = null;
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll(".column[data-status]").forEach(col => {
|
||||
col.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
(col as HTMLElement).classList.add("drag-over");
|
||||
});
|
||||
col.addEventListener("dragleave", () => {
|
||||
(col as HTMLElement).classList.remove("drag-over");
|
||||
});
|
||||
col.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
(col as HTMLElement).classList.remove("drag-over");
|
||||
const status = (col as HTMLElement).dataset.status!;
|
||||
if (this.dragTaskId) {
|
||||
this.moveTask(this.dragTaskId, status);
|
||||
this.dragTaskId = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
|
|
|
|||
Loading…
Reference in New Issue