375 lines
13 KiB
TypeScript
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);
|