rspace-online/modules/vote/components/folk-vote-dashboard.ts

536 lines
19 KiB
TypeScript

/**
* <folk-vote-dashboard> — conviction voting dashboard.
*
* Browse spaces, create/view proposals, cast votes (ranking + final).
*/
interface VoteSpace {
slug: string;
name: string;
description: string;
visibility: string;
promotion_threshold: number;
voting_period_days: number;
credits_per_day: number;
max_credits: number;
starting_credits: number;
}
interface Proposal {
id: string;
title: string;
description: string;
status: string;
score: number;
vote_count: string;
final_yes: number;
final_no: number;
final_abstain: number;
created_at: string;
voting_ends_at: string | null;
}
class FolkVoteDashboard extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: "spaces" | "proposals" | "proposal" = "spaces";
private spaces: VoteSpace[] = [];
private selectedSpace: VoteSpace | null = null;
private proposals: Proposal[] = [];
private selectedProposal: Proposal | null = null;
private loading = false;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; }
this.loadSpaces();
}
private loadDemoData() {
this.spaces = [
{
slug: "community",
name: "Community Governance",
description: "Proposals for the rSpace ecosystem",
visibility: "public_read",
promotion_threshold: 100,
voting_period_days: 7,
credits_per_day: 10,
max_credits: 500,
starting_credits: 50,
},
];
this.selectedSpace = this.spaces[0];
this.view = "proposals";
const now = Date.now();
const day = 86400000;
this.proposals = [
{
id: "p1",
title: "Implement real-time collaboration in rNotes",
description: "Use Automerge CRDTs (already in the stack) to enable simultaneous editing of notes, similar to how rSpace canvas works.",
status: "RANKING",
score: 72,
vote_count: "9",
final_yes: 0, final_no: 0, final_abstain: 0,
created_at: new Date(now - 3 * day).toISOString(),
voting_ends_at: null,
},
{
id: "p2",
title: "Add dark mode across all r* modules",
description: "Implement a consistent dark theme with a toggle in shell.css. Use CSS custom properties for theming so each module inherits automatically.",
status: "RANKING",
score: 45,
vote_count: "6",
final_yes: 0, final_no: 0, final_abstain: 0,
created_at: new Date(now - 5 * day).toISOString(),
voting_ends_at: null,
},
{
id: "p3",
title: "Adopt cosmolocal print-on-demand for all merch",
description: "Route all merchandise orders through the provider registry to find the closest printer. Reduces shipping emissions and supports local economies.",
status: "VOTING",
score: 105,
vote_count: "14",
final_yes: 5, final_no: 2, final_abstain: 0,
created_at: new Date(now - 10 * day).toISOString(),
voting_ends_at: new Date(now + 5 * day).toISOString(),
},
{
id: "p4",
title: "Use EncryptID passkeys for all authentication",
description: "Standardize on WebAuthn passkeys via EncryptID across the entire r* ecosystem. One passkey, all apps.",
status: "PASSED",
score: 150,
vote_count: "17",
final_yes: 12, final_no: 3, final_abstain: 2,
created_at: new Date(now - 21 * day).toISOString(),
voting_ends_at: new Date(now - 7 * day).toISOString(),
},
{
id: "p5",
title: "Switch from PostgreSQL to SQLite for simpler deployment",
description: "Evaluate replacing PostgreSQL with SQLite for modules that don't need concurrent writes.",
status: "FAILED",
score: 30,
vote_count: "11",
final_yes: 2, final_no: 8, final_abstain: 1,
created_at: new Date(now - 18 * day).toISOString(),
voting_ends_at: new Date(now - 4 * day).toISOString(),
},
];
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/vote/);
return match ? `/${match[1]}/vote` : "";
}
private async loadSpaces() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/spaces`);
const data = await res.json();
this.spaces = data.spaces || [];
} catch {
this.error = "Failed to load spaces";
}
this.loading = false;
this.render();
}
private async loadProposals(slug: string) {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/proposals?space_slug=${slug}`);
const data = await res.json();
this.proposals = data.proposals || [];
} catch {
this.error = "Failed to load proposals";
}
this.loading = false;
this.render();
}
private async loadProposal(id: string) {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/proposals/${id}`);
this.selectedProposal = await res.json();
} catch {
this.error = "Failed to load proposal";
}
this.loading = false;
this.render();
}
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;
p.vote_count = String(parseInt(p.vote_count) + 1);
// Auto-promote if score reaches threshold (matches server-side behavior)
const threshold = this.selectedSpace?.promotion_threshold || 100;
if (p.score >= threshold && p.status === "RANKING") {
p.status = "VOTING";
const votingDays = this.selectedSpace?.voting_period_days || 7;
p.voting_ends_at = new Date(Date.now() + votingDays * 86400000).toISOString();
}
this.selectedProposal = p;
}
this.render();
return;
}
try {
const base = this.getApiBase();
await fetch(`${base}/api/proposals/${proposalId}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weight }),
});
await this.loadProposal(proposalId);
} catch {
this.error = "Failed to cast vote";
this.render();
}
}
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote }),
});
await this.loadProposal(proposalId);
} catch {
this.error = "Failed to cast vote";
this.render();
}
}
private getStatusColor(status: string): string {
switch (status) {
case "RANKING": return "#3b82f6";
case "VOTING": return "#f59e0b";
case "PASSED": return "#22c55e";
case "FAILED": return "#ef4444";
case "ARCHIVED": return "#6b7280";
default: return "#888";
}
}
private render() {
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__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
}
.card:hover { border-color: #555; }
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.card-desc { font-size: 13px; color: #888; }
.card-meta { display: flex; gap: 12px; margin-top: 8px; font-size: 12px; color: #666; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
}
.score-bar { height: 6px; border-radius: 3px; background: #333; margin-top: 6px; overflow: hidden; }
.score-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.vote-controls { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.vote-btn {
padding: 8px 16px; border-radius: 8px; border: 2px solid #333;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 14px;
transition: all 0.2s;
}
.vote-btn:hover { border-color: #6366f1; }
.vote-btn.yes { border-color: #22c55e; color: #22c55e; }
.vote-btn.no { border-color: #ef4444; color: #ef4444; }
.vote-btn.abstain { border-color: #f59e0b; color: #f59e0b; }
.tally { display: flex; gap: 16px; margin-top: 12px; }
.tally-item { text-align: center; }
.tally-value { font-size: 24px; font-weight: 700; }
.tally-label { font-size: 11px; color: #888; text-transform: uppercase; }
.empty { text-align: center; color: #666; padding: 40px; }
.loading { text-align: center; color: #888; padding: 40px; }
.error { text-align: center; color: #ef5350; padding: 20px; }
</style>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading">Loading...</div>' : ""}
${!this.loading ? this.renderView() : ""}
`;
this.attachListeners();
}
private renderView(): string {
if (this.view === "proposal" && this.selectedProposal) {
return this.renderProposal();
}
if (this.view === "proposals" && this.selectedSpace) {
return this.renderProposals();
}
return this.renderSpaces();
}
private renderSpaces(): string {
return `
<div class="rapp-nav">
<span class="rapp-nav__title">Voting Spaces</span>
</div>
${this.spaces.length === 0 ? '<div class="empty">No voting spaces yet. Create one to get started.</div>' : ""}
${this.spaces.map((s) => `
<div class="card" data-space="${s.slug}">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="card-title">${this.esc(s.name)}</div>
<span class="badge" style="background:rgba(129,140,248,0.15);color:#818cf8">${s.visibility === "public_read" ? "Public" : s.visibility}</span>
</div>
<div class="card-desc">${this.esc(s.description || "")}</div>
<div class="card-meta">
<span>Threshold: ${s.promotion_threshold}</span>
<span>Voting: ${s.voting_period_days}d</span>
<span>${s.credits_per_day} credits/day</span>
<span>Max: ${s.max_credits}</span>
<span>Start: ${s.starting_credits}</span>
</div>
</div>
`).join("")}
`;
}
private relativeTime(dateStr: string): string {
const daysAgo = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
if (daysAgo === 0) return "today";
if (daysAgo === 1) return "1 day ago";
return `${daysAgo} days ago`;
}
private renderProposals(): string {
const s = this.selectedSpace!;
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="spaces">← Spaces</button>
<span class="rapp-nav__title">${this.esc(s.name)} — Proposals</span>
</div>
${this.proposals.length === 0 ? '<div class="empty">No proposals yet.</div>' : ""}
${this.proposals.map((p) => `
<div class="card" data-proposal="${p.id}">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="card-title">${this.esc(p.title)}</div>
<span class="badge" style="background:${this.getStatusColor(p.status)}20;color:${this.getStatusColor(p.status)}">${p.status}</span>
</div>
<div class="card-desc">${this.esc(p.description || "")}</div>
${p.status === "RANKING" ? `
<div class="score-bar">
<div class="score-fill" style="width:${Math.min(100, (p.score / (s.promotion_threshold || 100)) * 100)}%;background:${this.getStatusColor(p.status)}"></div>
</div>
<div class="card-meta">
<span>Score: ${Math.round(p.score)} / ${s.promotion_threshold}</span>
<span>${p.vote_count} votes</span>
<span>${this.relativeTime(p.created_at)}</span>
</div>
` : ""}
${p.status === "VOTING" ? `
<div class="card-meta">
<span style="color:#22c55e">Yes: ${p.final_yes}</span>
<span style="color:#ef4444">No: ${p.final_no}</span>
<span style="color:#f59e0b">Abstain: ${p.final_abstain}</span>
${p.voting_ends_at ? `<span>Ends: ${new Date(p.voting_ends_at).toLocaleDateString()}</span>` : ""}
</div>
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? `
<div class="card-meta">
<span style="color:#22c55e">Yes: ${p.final_yes}</span>
<span style="color:#ef4444">No: ${p.final_no}</span>
<span style="color:#f59e0b">Abstain: ${p.final_abstain}</span>
<span>${this.relativeTime(p.created_at)}</span>
</div>
` : ""}
</div>
`).join("")}
`;
}
private renderProposal(): string {
const p = this.selectedProposal!;
const threshold = this.selectedSpace?.promotion_threshold || 100;
const created = new Date(p.created_at);
const daysAgo = Math.floor((Date.now() - created.getTime()) / 86400000);
const ageText = daysAgo === 0 ? "today" : daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`;
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="proposals">← Proposals</button>
<span class="rapp-nav__title">${this.esc(p.title)}</span>
<span class="badge" style="background:${this.getStatusColor(p.status)}20;color:${this.getStatusColor(p.status)};margin-left:8px">${p.status}</span>
</div>
<div class="card" style="cursor:default">
<div class="card-desc" style="font-size:14px;color:#ccc;margin-bottom:12px">${this.esc(p.description || "No description")}</div>
<div class="card-meta" style="margin-bottom:12px">
<span>${p.vote_count} votes</span>
<span>Created ${ageText}</span>
</div>
${p.status === "RANKING" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">
Conviction score: <strong style="color:#3b82f6">${Math.round(p.score)}</strong> / ${threshold}
</div>
<div class="score-bar">
<div class="score-fill" style="width:${Math.min(100, (p.score / threshold) * 100)}%;background:#3b82f6"></div>
</div>
<div style="font-size:12px;color:#666;margin-top:4px">
Needs ${threshold} to advance to voting (quadratic cost: weight&sup2; credits)
</div>
<div class="vote-controls">
${[1, 2, 3, 5].map((w) => `
<button class="vote-btn" data-vote-weight="${w}">
+${w} (${w * w} credits)
</button>
`).join("")}
</div>
` : ""}
${p.status === "VOTING" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">
Promoted with conviction score <strong style="color:#f59e0b">${Math.round(p.score)}</strong> — now in ${this.selectedSpace?.voting_period_days || 7}-day final vote
</div>
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
<div class="vote-controls">
<button class="vote-btn yes" data-final-vote="YES">Vote Yes</button>
<button class="vote-btn no" data-final-vote="NO">Vote No</button>
<button class="vote-btn abstain" data-final-vote="ABSTAIN">Abstain</button>
</div>
${p.voting_ends_at ? `<div style="font-size:12px;color:#666;margin-top:8px">Voting ends: ${new Date(p.voting_ends_at).toLocaleDateString()}</div>` : ""}
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">
Final result: <strong style="color:${this.getStatusColor(p.status)}">${p.status}</strong>
(conviction score was ${Math.round(p.score)})
</div>
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
` : ""}
</div>
`;
}
private attachListeners() {
// Space cards
this.shadow.querySelectorAll("[data-space]").forEach((el) => {
el.addEventListener("click", () => {
const slug = (el as HTMLElement).dataset.space!;
this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null;
this.view = "proposals";
if (this.space === "demo") {
this.render();
} else {
this.loadProposals(slug);
}
});
});
// Proposal cards
this.shadow.querySelectorAll("[data-proposal]").forEach((el) => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.proposal!;
this.view = "proposal";
if (this.space === "demo") {
this.selectedProposal = this.proposals.find((p) => p.id === id) || null;
this.render();
} else {
this.loadProposal(id);
}
});
});
// Back buttons
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const target = (el as HTMLElement).dataset.back;
if (target === "spaces") { this.view = "spaces"; this.render(); }
else if (target === "proposals") { this.view = "proposals"; this.render(); }
});
});
// Conviction vote buttons
this.shadow.querySelectorAll("[data-vote-weight]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const weight = parseInt((el as HTMLElement).dataset.voteWeight!);
if (this.selectedProposal) this.castVote(this.selectedProposal.id, weight);
});
});
// Final vote buttons
this.shadow.querySelectorAll("[data-final-vote]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const vote = (el as HTMLElement).dataset.finalVote!;
if (this.selectedProposal) this.castFinalVote(this.selectedProposal.id, vote);
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-vote-dashboard", FolkVoteDashboard);