842 lines
34 KiB
TypeScript
842 lines
34 KiB
TypeScript
/**
|
|
* <folk-vote-dashboard> — conviction voting dashboard.
|
|
*
|
|
* Browse spaces, create/view proposals, cast votes (ranking + final).
|
|
* Enhanced: inline voting, create proposal form, richer UI.
|
|
*/
|
|
|
|
import { proposalSchema, type ProposalDoc } from "../schemas";
|
|
import type { DocumentId } from "../../../shared/local-first/document";
|
|
|
|
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 = "";
|
|
private showCreateForm = false;
|
|
private _offlineUnsubs: (() => void)[] = [];
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
|
this.subscribeOffline();
|
|
this.loadSpaces();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
for (const unsub of this._offlineUnsubs) unsub();
|
|
this._offlineUnsubs = [];
|
|
}
|
|
|
|
private async subscribeOffline() {
|
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
|
if (!runtime?.isInitialized) return;
|
|
|
|
try {
|
|
const docs = await runtime.subscribeModule('vote', 'proposals', proposalSchema);
|
|
if (docs.size > 0 && this.proposals.length === 0) {
|
|
const fromDocs: Proposal[] = [];
|
|
for (const [docId, doc] of docs) {
|
|
const d = doc as ProposalDoc;
|
|
if (!d?.proposal) continue;
|
|
fromDocs.push({
|
|
id: d.proposal.id, title: d.proposal.title,
|
|
description: d.proposal.description, status: d.proposal.status,
|
|
score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length),
|
|
final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo,
|
|
final_abstain: d.proposal.finalAbstain,
|
|
created_at: new Date(d.proposal.createdAt).toISOString(),
|
|
voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null,
|
|
});
|
|
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
|
|
}
|
|
if (fromDocs.length > 0) {
|
|
this.proposals = fromDocs;
|
|
this.view = "proposals";
|
|
this.render();
|
|
}
|
|
}
|
|
} catch { /* runtime unavailable */ }
|
|
}
|
|
|
|
private loadDemoData() {
|
|
this.spaces = [
|
|
{
|
|
slug: "community",
|
|
name: "Community Governance",
|
|
description: "Proposals for the rSpace ecosystem",
|
|
visibility: "public",
|
|
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(/^(\/[^/]+)?\/rvote/);
|
|
return match ? match[0] : "";
|
|
}
|
|
|
|
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);
|
|
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();
|
|
}
|
|
if (this.selectedProposal?.id === proposalId) 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 }),
|
|
});
|
|
if (this.view === "proposal") await this.loadProposal(proposalId);
|
|
else if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug);
|
|
} 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++;
|
|
if (this.selectedProposal?.id === proposalId) 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 }),
|
|
});
|
|
if (this.view === "proposal") await this.loadProposal(proposalId);
|
|
else if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug);
|
|
} catch {
|
|
this.error = "Failed to cast vote";
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async createProposal(title: string, description: string) {
|
|
if (this.space === "demo") {
|
|
const now = Date.now();
|
|
this.proposals.unshift({
|
|
id: `p${now}`,
|
|
title,
|
|
description,
|
|
status: "RANKING",
|
|
score: 0,
|
|
vote_count: "0",
|
|
final_yes: 0, final_no: 0, final_abstain: 0,
|
|
created_at: new Date(now).toISOString(),
|
|
voting_ends_at: null,
|
|
});
|
|
this.showCreateForm = false;
|
|
this.render();
|
|
return;
|
|
}
|
|
try {
|
|
const base = this.getApiBase();
|
|
await fetch(`${base}/api/proposals`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ space_slug: this.selectedSpace?.slug, title, description }),
|
|
});
|
|
this.showCreateForm = false;
|
|
if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug);
|
|
} catch {
|
|
this.error = "Failed to create proposal";
|
|
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 getStatusIcon(status: string): string {
|
|
switch (status) {
|
|
case "RANKING": return "📊";
|
|
case "VOTING": return "🗳";
|
|
case "PASSED": return "✅";
|
|
case "FAILED": return "❌";
|
|
case "ARCHIVED": return "📦";
|
|
default: return "📄";
|
|
}
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
.header-back { padding: 6px 12px; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; transition: all 0.15s; }
|
|
.header-back:hover { color: #e2e8f0; border-color: #818cf8; }
|
|
.header-title { font-size: 18px; font-weight: 700; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.header-sub { font-size: 13px; color: #64748b; }
|
|
|
|
/* Space cards */
|
|
.space-card {
|
|
background: #0f172a; border: 1px solid #1e293b; border-radius: 12px;
|
|
padding: 20px; margin-bottom: 12px; cursor: pointer; transition: all 0.2s;
|
|
}
|
|
.space-card:hover { border-color: #818cf8; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.1); }
|
|
.space-name { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
|
|
.space-desc { font-size: 13px; color: #94a3b8; margin-bottom: 12px; }
|
|
.space-meta { display: flex; gap: 16px; flex-wrap: wrap; }
|
|
.space-stat { display: flex; flex-direction: column; gap: 2px; }
|
|
.space-stat-value { font-size: 15px; font-weight: 600; color: #818cf8; }
|
|
.space-stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
|
|
/* Proposal cards */
|
|
.proposal {
|
|
background: #0f172a; border: 1px solid #1e293b; border-radius: 12px;
|
|
padding: 16px 20px; margin-bottom: 10px; transition: border-color 0.2s;
|
|
}
|
|
.proposal:hover { border-color: #334155; }
|
|
.proposal-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 6px; }
|
|
.proposal-title { font-size: 15px; font-weight: 600; color: #e2e8f0; cursor: pointer; line-height: 1.4; }
|
|
.proposal-title:hover { color: #818cf8; }
|
|
.proposal-desc { font-size: 13px; color: #64748b; margin-bottom: 10px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
|
|
/* Status badge */
|
|
.badge {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 3px 10px; border-radius: 6px;
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: 0.03em; white-space: nowrap; flex-shrink: 0;
|
|
}
|
|
|
|
/* Score / progress */
|
|
.score-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
|
.score-label { font-size: 13px; color: #94a3b8; white-space: nowrap; }
|
|
.score-value { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.score-bar { flex: 1; height: 8px; border-radius: 4px; background: #1e293b; overflow: hidden; min-width: 100px; }
|
|
.score-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; }
|
|
|
|
/* Vote buttons */
|
|
.vote-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
.vote-btn {
|
|
padding: 6px 14px; border-radius: 8px; border: 1px solid #334155;
|
|
background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 13px;
|
|
font-weight: 500; transition: all 0.15s; white-space: nowrap;
|
|
}
|
|
.vote-btn:hover { border-color: #818cf8; color: #e2e8f0; background: rgba(129,140,248,0.1); }
|
|
.vote-btn:active { transform: scale(0.97); }
|
|
.vote-btn.yes { border-color: rgba(34,197,94,0.4); color: #22c55e; }
|
|
.vote-btn.yes:hover { background: rgba(34,197,94,0.1); border-color: #22c55e; }
|
|
.vote-btn.no { border-color: rgba(239,68,68,0.4); color: #ef4444; }
|
|
.vote-btn.no:hover { background: rgba(239,68,68,0.1); border-color: #ef4444; }
|
|
.vote-btn.abstain { border-color: rgba(245,158,11,0.4); color: #f59e0b; }
|
|
.vote-btn.abstain:hover { background: rgba(245,158,11,0.1); border-color: #f59e0b; }
|
|
.vote-sep { color: #334155; font-size: 12px; }
|
|
|
|
/* Tally */
|
|
.tally { display: flex; gap: 20px; margin: 10px 0 8px; }
|
|
.tally-item { text-align: center; }
|
|
.tally-value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.tally-label { font-size: 11px; color: #64748b; text-transform: uppercase; }
|
|
.tally-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1e293b; margin-top: 6px; }
|
|
.tally-bar-yes { background: #22c55e; transition: width 0.3s; }
|
|
.tally-bar-no { background: #ef4444; transition: width 0.3s; }
|
|
.tally-bar-abstain { background: #f59e0b; transition: width 0.3s; }
|
|
|
|
/* Meta row */
|
|
.meta { display: flex; gap: 16px; font-size: 12px; color: #475569; margin-top: 8px; flex-wrap: wrap; }
|
|
.meta span { white-space: nowrap; }
|
|
|
|
/* Create form */
|
|
.create-form { background: #0f172a; border: 2px solid #818cf8; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
|
.create-form h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #818cf8; }
|
|
.form-field { margin-bottom: 12px; }
|
|
.form-label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 4px; font-weight: 500; }
|
|
.form-input, .form-textarea {
|
|
width: 100%; padding: 10px 12px; border-radius: 8px;
|
|
border: 1px solid #334155; background: #1e293b; color: #e2e8f0;
|
|
font-size: 14px; font-family: inherit; transition: border-color 0.15s;
|
|
}
|
|
.form-input:focus, .form-textarea:focus { outline: none; border-color: #818cf8; }
|
|
.form-textarea { resize: vertical; min-height: 80px; }
|
|
.form-actions { display: flex; gap: 8px; }
|
|
.btn-primary {
|
|
padding: 8px 20px; border-radius: 8px; border: none;
|
|
background: linear-gradient(to right, #818cf8, #6366f1); color: white;
|
|
font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
|
|
}
|
|
.btn-primary:hover { opacity: 0.9; }
|
|
.btn-ghost {
|
|
padding: 8px 16px; border-radius: 8px; border: 1px solid #334155;
|
|
background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px;
|
|
}
|
|
.btn-ghost:hover { border-color: #475569; color: #e2e8f0; }
|
|
.btn-new {
|
|
padding: 8px 16px; border-radius: 8px; border: 1px dashed #334155;
|
|
background: transparent; color: #818cf8; cursor: pointer; font-size: 13px;
|
|
font-weight: 500; transition: all 0.15s;
|
|
}
|
|
.btn-new:hover { border-color: #818cf8; background: rgba(129,140,248,0.05); }
|
|
|
|
/* Detail view */
|
|
.detail { background: #0f172a; border: 1px solid #1e293b; border-radius: 12px; padding: 24px; }
|
|
.detail-title { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
|
|
.detail-desc { font-size: 14px; color: #94a3b8; line-height: 1.6; margin-bottom: 16px; }
|
|
.detail-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid #1e293b; }
|
|
.detail-section-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 10px; }
|
|
|
|
.empty { text-align: center; color: #475569; padding: 48px 20px; }
|
|
.empty-icon { font-size: 2.5rem; margin-bottom: 8px; }
|
|
.empty-text { font-size: 14px; }
|
|
.loading { text-align: center; color: #64748b; padding: 48px 20px; }
|
|
.error { text-align: center; color: #ef4444; padding: 16px; background: rgba(239,68,68,0.05); border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
|
|
|
|
.demo-banner {
|
|
background: linear-gradient(135deg, rgba(129,140,248,0.08), rgba(192,132,252,0.05));
|
|
border: 1px solid rgba(129,140,248,0.2); border-radius: 10px;
|
|
padding: 12px 16px; margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
|
|
font-size: 13px; color: #94a3b8;
|
|
}
|
|
.demo-banner strong { color: #818cf8; }
|
|
</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="header">
|
|
<span class="header-title">Voting Spaces</span>
|
|
</div>
|
|
${this.spaces.length === 0 ? '<div class="empty"><div class="empty-icon">🗳</div><div class="empty-text">No voting spaces yet. Create one to get started.</div></div>' : ""}
|
|
${this.spaces.map((s) => `
|
|
<div class="space-card" data-space="${s.slug}">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<div class="space-name">${this.esc(s.name)}</div>
|
|
<span class="badge" style="background:rgba(129,140,248,0.1);color:#818cf8">
|
|
${s.visibility === "public" ? "Public" : s.visibility === "permissioned" ? "Permissioned" : "Private"}
|
|
</span>
|
|
</div>
|
|
<div class="space-desc">${this.esc(s.description || "")}</div>
|
|
<div class="space-meta">
|
|
<div class="space-stat"><span class="space-stat-value">${s.promotion_threshold}</span><span class="space-stat-label">Threshold</span></div>
|
|
<div class="space-stat"><span class="space-stat-value">${s.voting_period_days}d</span><span class="space-stat-label">Vote Period</span></div>
|
|
<div class="space-stat"><span class="space-stat-value">${s.credits_per_day}/day</span><span class="space-stat-label">Credits</span></div>
|
|
<div class="space-stat"><span class="space-stat-value">${s.starting_credits}</span><span class="space-stat-label">Starting</span></div>
|
|
<div class="space-stat"><span class="space-stat-value">${s.max_credits}</span><span class="space-stat-label">Max</span></div>
|
|
</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 "yesterday";
|
|
return `${daysAgo}d ago`;
|
|
}
|
|
|
|
private daysLeft(dateStr: string | null): string {
|
|
if (!dateStr) return "";
|
|
const diff = new Date(dateStr).getTime() - Date.now();
|
|
if (diff <= 0) return "ended";
|
|
const days = Math.ceil(diff / 86400000);
|
|
return `${days}d left`;
|
|
}
|
|
|
|
private renderProposals(): string {
|
|
const s = this.selectedSpace!;
|
|
const threshold = s.promotion_threshold || 100;
|
|
|
|
// Group proposals by status
|
|
const ranking = this.proposals.filter(p => p.status === "RANKING");
|
|
const voting = this.proposals.filter(p => p.status === "VOTING");
|
|
const decided = this.proposals.filter(p => p.status === "PASSED" || p.status === "FAILED");
|
|
|
|
return `
|
|
<div class="header">
|
|
${this.spaces.length > 1 ? `<button class="header-back" data-back="spaces">←</button>` : ""}
|
|
<span class="header-title">${this.esc(s.name)}</span>
|
|
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
|
|
<button class="btn-new" data-toggle-create>+ New Proposal</button>
|
|
</div>
|
|
|
|
${this.space === "demo" ? `
|
|
<div class="demo-banner">
|
|
<strong>Demo mode</strong> — votes modify local state only. Create a space for real governance.
|
|
</div>
|
|
` : ""}
|
|
|
|
${this.showCreateForm ? this.renderCreateForm() : ""}
|
|
|
|
${voting.length > 0 ? `
|
|
<div style="font-size:12px;font-weight:600;color:#f59e0b;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
|
|
<span>🗳</span> Final Voting (${voting.length})
|
|
</div>
|
|
${voting.map(p => this.renderProposalCard(p, threshold)).join("")}
|
|
` : ""}
|
|
|
|
${ranking.length > 0 ? `
|
|
<div style="font-size:12px;font-weight:600;color:#3b82f6;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
|
|
<span>📊</span> Ranking (${ranking.length})
|
|
</div>
|
|
${ranking.map(p => this.renderProposalCard(p, threshold)).join("")}
|
|
` : ""}
|
|
|
|
${decided.length > 0 ? `
|
|
<div style="font-size:12px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
|
|
<span>📋</span> Decided (${decided.length})
|
|
</div>
|
|
${decided.map(p => this.renderProposalCard(p, threshold)).join("")}
|
|
` : ""}
|
|
|
|
${this.proposals.length === 0 && !this.showCreateForm ? '<div class="empty"><div class="empty-icon">📝</div><div class="empty-text">No proposals yet. Create one to get started.</div></div>' : ""}
|
|
`;
|
|
}
|
|
|
|
private renderProposalCard(p: Proposal, threshold: number): string {
|
|
const statusColor = this.getStatusColor(p.status);
|
|
const pct = Math.min(100, (p.score / threshold) * 100);
|
|
const totalFinal = p.final_yes + p.final_no + p.final_abstain;
|
|
|
|
return `
|
|
<div class="proposal" data-pid="${p.id}">
|
|
<div class="proposal-top">
|
|
<span class="proposal-title" data-proposal="${p.id}">${this.esc(p.title)}</span>
|
|
<span class="badge" style="background:${statusColor}18;color:${statusColor}">${this.getStatusIcon(p.status)} ${p.status}</span>
|
|
</div>
|
|
<div class="proposal-desc">${this.esc(p.description || "")}</div>
|
|
|
|
${p.status === "RANKING" ? `
|
|
<div class="score-row">
|
|
<span class="score-label">Score: <span class="score-value" style="color:#3b82f6">${Math.round(p.score)}</span> / ${threshold}</span>
|
|
<div class="score-bar"><div class="score-fill" style="width:${pct}%;background:linear-gradient(90deg,#3b82f6,#818cf8)"></div></div>
|
|
</div>
|
|
<div class="vote-row">
|
|
${[1, 2, 3, 5].map(w => `<button class="vote-btn" data-vote-weight="${w}" data-vote-id="${p.id}">+${w} <span style="font-size:11px;color:#64748b">(${w * w}cr)</span></button>`).join("")}
|
|
<span class="vote-sep">|</span>
|
|
${[-1, -2].map(w => `<button class="vote-btn" data-vote-weight="${w}" data-vote-id="${p.id}" style="border-color:rgba(239,68,68,0.3);color:#ef4444">${w}</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>
|
|
${totalFinal > 0 ? `<div class="tally-bar">
|
|
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
|
|
</div>` : ""}
|
|
<div class="vote-row" style="margin-top:10px">
|
|
<button class="vote-btn yes" data-final-vote="YES" data-vote-id="${p.id}">Vote Yes</button>
|
|
<button class="vote-btn no" data-final-vote="NO" data-vote-id="${p.id}">Vote No</button>
|
|
<button class="vote-btn abstain" data-final-vote="ABSTAIN" data-vote-id="${p.id}">Abstain</button>
|
|
</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>
|
|
${totalFinal > 0 ? `<div class="tally-bar">
|
|
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
|
|
</div>` : ""}
|
|
` : ""}
|
|
|
|
<div class="meta">
|
|
<span>${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span>
|
|
<span>${this.relativeTime(p.created_at)}</span>
|
|
${p.status === "VOTING" && p.voting_ends_at ? `<span style="color:#f59e0b">${this.daysLeft(p.voting_ends_at)}</span>` : ""}
|
|
${p.status === "RANKING" ? `<span style="color:#3b82f6">${Math.round(threshold - p.score)} to advance</span>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderCreateForm(): string {
|
|
return `
|
|
<div class="create-form">
|
|
<h3>New Proposal</h3>
|
|
<div class="form-field">
|
|
<label class="form-label">Title</label>
|
|
<input class="form-input" id="create-title" placeholder="What should we do?" autofocus>
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="form-label">Description</label>
|
|
<textarea class="form-textarea" id="create-desc" placeholder="Explain the proposal and why it matters..."></textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn-primary" data-submit-create>Submit Proposal</button>
|
|
<button class="btn-ghost" data-cancel-create>Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderProposal(): string {
|
|
const p = this.selectedProposal!;
|
|
const threshold = this.selectedSpace?.promotion_threshold || 100;
|
|
const pct = Math.min(100, (p.score / threshold) * 100);
|
|
const totalFinal = p.final_yes + p.final_no + p.final_abstain;
|
|
|
|
return `
|
|
<div class="header">
|
|
<button class="header-back" data-back="proposals">← Proposals</button>
|
|
<span class="header-title">${this.esc(p.title)}</span>
|
|
<span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span>
|
|
</div>
|
|
<div class="detail">
|
|
<div class="detail-desc">${this.esc(p.description || "No description provided.")}</div>
|
|
<div class="meta" style="margin-top:0">
|
|
<span>${p.vote_count} votes</span>
|
|
<span>Created ${this.relativeTime(p.created_at)}</span>
|
|
${p.voting_ends_at ? `<span>Voting ${this.daysLeft(p.voting_ends_at)}</span>` : ""}
|
|
</div>
|
|
|
|
${p.status === "RANKING" ? `
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Conviction Score</div>
|
|
<div class="score-row">
|
|
<span class="score-label"><span class="score-value" style="color:#3b82f6;font-size:24px">${Math.round(p.score)}</span> / ${threshold}</span>
|
|
<div class="score-bar" style="height:10px"><div class="score-fill" style="width:${pct}%;background:linear-gradient(90deg,#3b82f6,#818cf8)"></div></div>
|
|
</div>
|
|
<div style="font-size:12px;color:#475569;margin:8px 0 12px">
|
|
Needs ${Math.max(0, Math.round(threshold - p.score))} more to advance to final vote. Cost: weight² credits.
|
|
</div>
|
|
<div class="vote-row">
|
|
${[1, 2, 3, 5].map(w => `<button class="vote-btn" data-vote-weight="${w}">+${w} (${w * w} credits)</button>`).join("")}
|
|
<span class="vote-sep">|</span>
|
|
${[-1, -2].map(w => `<button class="vote-btn" data-vote-weight="${w}" style="border-color:rgba(239,68,68,0.3);color:#ef4444">${w}</button>`).join("")}
|
|
</div>
|
|
</div>
|
|
` : ""}
|
|
|
|
${p.status === "VOTING" ? `
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Final Vote — ${this.selectedSpace?.voting_period_days || 7}-day period</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>
|
|
${totalFinal > 0 ? `<div class="tally-bar" style="margin:8px 0">
|
|
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
|
|
</div>` : ""}
|
|
<div class="vote-row">
|
|
<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>
|
|
</div>
|
|
` : ""}
|
|
|
|
${p.status === "PASSED" || p.status === "FAILED" ? `
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Result: ${p.status}</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>
|
|
${totalFinal > 0 ? `<div class="tally-bar">
|
|
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
|
|
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
|
|
</div>` : ""}
|
|
<div style="font-size:13px;color:#475569;margin-top:8px">
|
|
Conviction score was <strong style="color:${this.getStatusColor(p.status)}">${Math.round(p.score)}</strong>
|
|
</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 title click → detail view
|
|
this.shadow.querySelectorAll("[data-proposal]").forEach((el) => {
|
|
el.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
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(); }
|
|
});
|
|
});
|
|
|
|
// Inline conviction vote buttons (on proposal cards)
|
|
this.shadow.querySelectorAll("[data-vote-weight]").forEach((el) => {
|
|
el.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const weight = parseInt((el as HTMLElement).dataset.voteWeight!);
|
|
const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id;
|
|
if (id) this.castVote(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!;
|
|
const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id;
|
|
if (id) this.castFinalVote(id, vote);
|
|
});
|
|
});
|
|
|
|
// Toggle create form
|
|
this.shadow.querySelector("[data-toggle-create]")?.addEventListener("click", () => {
|
|
this.showCreateForm = !this.showCreateForm;
|
|
this.render();
|
|
});
|
|
|
|
// Cancel create
|
|
this.shadow.querySelector("[data-cancel-create]")?.addEventListener("click", () => {
|
|
this.showCreateForm = false;
|
|
this.render();
|
|
});
|
|
|
|
// Submit create
|
|
this.shadow.querySelector("[data-submit-create]")?.addEventListener("click", () => {
|
|
const titleInput = this.shadow.getElementById("create-title") as HTMLInputElement;
|
|
const descInput = this.shadow.getElementById("create-desc") as HTMLTextAreaElement;
|
|
const title = titleInput?.value?.trim();
|
|
const desc = descInput?.value?.trim();
|
|
if (!title) return;
|
|
this.createProposal(title, desc || "");
|
|
});
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s || "";
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-vote-dashboard", FolkVoteDashboard);
|