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

375 lines
13 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;
promotion_threshold: number;
voting_period_days: number;
credits_per_day: 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";
this.loadSpaces();
}
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) {
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) {
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; }
.nav { display: flex; gap: 8px; margin-bottom: 20px; align-items: center; }
.nav-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid #444;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
}
.nav-btn:hover { border-color: #666; }
.nav-btn.active { border-color: #6366f1; color: #a5b4fc; }
.nav-title { font-size: 18px; font-weight: 600; margin-left: 8px; }
.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="nav">
<span class="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 class="card-title">${this.esc(s.name)}</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>
</div>
</div>
`).join("")}
`;
}
private renderProposals(): string {
const s = this.selectedSpace!;
return `
<div class="nav">
<button class="nav-btn" data-back="spaces">Back</button>
<span class="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></div>
` : ""}
${p.status === "VOTING" || 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>
</div>
` : ""}
</div>
`).join("")}
`;
}
private renderProposal(): string {
const p = this.selectedProposal!;
return `
<div class="nav">
<button class="nav-btn" data-back="proposals">Back</button>
<span class="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>
${p.status === "RANKING" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">Conviction score: <strong style="color:#3b82f6">${Math.round(p.score)}</strong></div>
<div class="score-bar">
<div class="score-fill" style="width:${Math.min(100, (p.score / 100) * 100)}%;background:#3b82f6"></div>
</div>
<div style="font-size:12px;color:#666;margin-top:4px">Needs 100 to advance to voting</div>
<div class="vote-controls">
${[1, 2, 3, 5].map((w) => `
<button class="vote-btn" data-vote-weight="${w}">
Vote +${w} (${w * w} credits)
</button>
`).join("")}
</div>
` : ""}
${p.status === "VOTING" ? `
<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 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";
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";
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);