/** * — 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 = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Loading...
' : ""} ${!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 ` ${this.spaces.length === 0 ? '
No voting spaces yet. Create one to get started.
' : ""} ${this.spaces.map((s) => `
${this.esc(s.name)}
${this.esc(s.description || "")}
Threshold: ${s.promotion_threshold} Voting: ${s.voting_period_days}d ${s.credits_per_day} credits/day
`).join("")} `; } private renderProposals(): string { const s = this.selectedSpace!; return ` ${this.proposals.length === 0 ? '
No proposals yet.
' : ""} ${this.proposals.map((p) => `
${this.esc(p.title)}
${p.status}
${this.esc(p.description || "")}
${p.status === "RANKING" ? `
Score: ${Math.round(p.score)} / ${s.promotion_threshold}
` : ""} ${p.status === "VOTING" || p.status === "PASSED" || p.status === "FAILED" ? `
Yes: ${p.final_yes} No: ${p.final_no} Abstain: ${p.final_abstain}
` : ""}
`).join("")} `; } private renderProposal(): string { const p = this.selectedProposal!; return `
${this.esc(p.description || "No description")}
${p.status === "RANKING" ? `
Conviction score: ${Math.round(p.score)}
Needs 100 to advance to voting
${[1, 2, 3, 5].map((w) => ` `).join("")}
` : ""} ${p.status === "VOTING" ? `
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
${p.voting_ends_at ? `
Voting ends: ${new Date(p.voting_ends_at).toLocaleDateString()}
` : ""} ` : ""} ${p.status === "PASSED" || p.status === "FAILED" ? `
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
` : ""}
`; } 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);