diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts index edbfab5..e4b48cd 100644 --- a/modules/rvote/components/folk-vote-dashboard.ts +++ b/modules/rvote/components/folk-vote-dashboard.ts @@ -2,6 +2,7 @@ * — 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"; @@ -43,6 +44,7 @@ class FolkVoteDashboard extends HTMLElement { private selectedProposal: Proposal | null = null; private loading = false; private error = ""; + private showCreateForm = false; private _offlineUnsubs: (() => void)[] = []; constructor() { @@ -228,14 +230,13 @@ class FolkVoteDashboard extends HTMLElement { 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; + if (this.selectedProposal?.id === proposalId) this.selectedProposal = p; } this.render(); return; @@ -247,7 +248,8 @@ class FolkVoteDashboard extends HTMLElement { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ weight }), }); - await this.loadProposal(proposalId); + 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(); @@ -261,7 +263,7 @@ class FolkVoteDashboard extends HTMLElement { if (vote === "YES") p.final_yes++; else if (vote === "NO") p.final_no++; else if (vote === "ABSTAIN") p.final_abstain++; - this.selectedProposal = p; + if (this.selectedProposal?.id === proposalId) this.selectedProposal = p; } this.render(); return; @@ -273,13 +275,47 @@ class FolkVoteDashboard extends HTMLElement { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vote }), }); - await this.loadProposal(proposalId); + 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"; @@ -291,53 +327,150 @@ class FolkVoteDashboard extends HTMLElement { } } + 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 = ` ${this.error ? `
${this.esc(this.error)}
` : ""} @@ -361,23 +494,25 @@ class FolkVoteDashboard extends HTMLElement { private renderSpaces(): string { return ` -
- Voting Spaces +
+ Voting Spaces
- ${this.spaces.length === 0 ? '
No voting spaces yet. Create one to get started.
' : ""} + ${this.spaces.length === 0 ? '
🗳
No voting spaces yet. Create one to get started.
' : ""} ${this.spaces.map((s) => ` -
-
-
${this.esc(s.name)}
- ${s.visibility === "public" ? "👁 Public" : s.visibility === "permissioned" ? "🔑 Permissioned" : s.visibility === "private" ? "🔒 Private" : s.visibility} +
+
+
${this.esc(s.name)}
+ + ${s.visibility === "public" ? "Public" : s.visibility === "permissioned" ? "Permissioned" : "Private"} +
-
${this.esc(s.description || "")}
-
- Threshold: ${s.promotion_threshold} - Voting: ${s.voting_period_days}d - ${s.credits_per_day} credits/day - Max: ${s.max_credits} - Start: ${s.starting_credits} +
${this.esc(s.description || "")}
+
+
${s.promotion_threshold}Threshold
+
${s.voting_period_days}dVote Period
+
${s.credits_per_day}/dayCredits
+
${s.starting_credits}Starting
+
${s.max_credits}Max
`).join("")} @@ -387,121 +522,229 @@ class FolkVoteDashboard extends HTMLElement { 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`; + 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 ` -
- - ${this.esc(s.name)} — Proposals +
+ ${this.spaces.length > 1 ? `` : ""} + ${this.esc(s.name)} + ${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""} +
- ${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.vote_count} votes - ${this.relativeTime(p.created_at)} -
- ` : ""} - ${p.status === "VOTING" ? ` -
- Yes: ${p.final_yes} - No: ${p.final_no} - Abstain: ${p.final_abstain} - ${p.voting_ends_at ? `Ends: ${new Date(p.voting_ends_at).toLocaleDateString()}` : ""} -
- ` : ""} - ${p.status === "PASSED" || p.status === "FAILED" ? ` -
- Yes: ${p.final_yes} - No: ${p.final_no} - Abstain: ${p.final_abstain} - ${this.relativeTime(p.created_at)} -
- ` : ""} + + ${this.space === "demo" ? ` +
+ Demo mode — votes modify local state only. Create a space for real governance.
- `).join("")} + ` : ""} + + ${this.showCreateForm ? this.renderCreateForm() : ""} + + ${voting.length > 0 ? ` +
+ 🗳 Final Voting (${voting.length}) +
+ ${voting.map(p => this.renderProposalCard(p, threshold)).join("")} + ` : ""} + + ${ranking.length > 0 ? ` +
+ 📊 Ranking (${ranking.length}) +
+ ${ranking.map(p => this.renderProposalCard(p, threshold)).join("")} + ` : ""} + + ${decided.length > 0 ? ` +
+ 📋 Decided (${decided.length}) +
+ ${decided.map(p => this.renderProposalCard(p, threshold)).join("")} + ` : ""} + + ${this.proposals.length === 0 && !this.showCreateForm ? '
📝
No proposals yet. Create one to get started.
' : ""} + `; + } + + 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 ` +
+
+ ${this.esc(p.title)} + ${this.getStatusIcon(p.status)} ${p.status} +
+
${this.esc(p.description || "")}
+ + ${p.status === "RANKING" ? ` +
+ Score: ${Math.round(p.score)} / ${threshold} +
+
+
+ ${[1, 2, 3, 5].map(w => ``).join("")} + | + ${[-1, -2].map(w => ``).join("")} +
+ ` : ""} + + ${p.status === "VOTING" ? ` +
+
${p.final_yes}
Yes
+
${p.final_no}
No
+
${p.final_abstain}
Abstain
+
+ ${totalFinal > 0 ? `
+
+
+
+
` : ""} +
+ + + +
+ ` : ""} + + ${p.status === "PASSED" || p.status === "FAILED" ? ` +
+
${p.final_yes}
Yes
+
${p.final_no}
No
+
${p.final_abstain}
Abstain
+
+ ${totalFinal > 0 ? `
+
+
+
+
` : ""} + ` : ""} + +
+ ${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""} + ${this.relativeTime(p.created_at)} + ${p.status === "VOTING" && p.voting_ends_at ? `${this.daysLeft(p.voting_ends_at)}` : ""} + ${p.status === "RANKING" ? `${Math.round(threshold - p.score)} to advance` : ""} +
+
+ `; + } + + private renderCreateForm(): string { + return ` +
+

New Proposal

+
+ + +
+
+ + +
+
+ + +
+
`; } 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`; + const pct = Math.min(100, (p.score / threshold) * 100); + const totalFinal = p.final_yes + p.final_no + p.final_abstain; return ` -
- - ${this.esc(p.title)} - ${p.status} +
+ + ${this.esc(p.title)} + ${this.getStatusIcon(p.status)} ${p.status}
-
-
${this.esc(p.description || "No description")}
-
+
+
${this.esc(p.description || "No description provided.")}
+
${p.vote_count} votes - Created ${ageText} + Created ${this.relativeTime(p.created_at)} + ${p.voting_ends_at ? `Voting ${this.daysLeft(p.voting_ends_at)}` : ""}
${p.status === "RANKING" ? ` -
- Conviction score: ${Math.round(p.score)} / ${threshold} -
-
-
-
-
- Needs ${threshold} to advance to voting (quadratic cost: weight² credits) -
-
- ${[1, 2, 3, 5].map((w) => ` - - `).join("")} +
+
Conviction Score
+
+ ${Math.round(p.score)} / ${threshold} +
+
+
+ Needs ${Math.max(0, Math.round(threshold - p.score))} more to advance to final vote. Cost: weight² credits. +
+
+ ${[1, 2, 3, 5].map(w => ``).join("")} + | + ${[-1, -2].map(w => ``).join("")} +
` : ""} ${p.status === "VOTING" ? ` -
- Promoted with conviction score ${Math.round(p.score)} — now in ${this.selectedSpace?.voting_period_days || 7}-day final vote +
+
Final Vote — ${this.selectedSpace?.voting_period_days || 7}-day period
+
+
${p.final_yes}
Yes
+
${p.final_no}
No
+
${p.final_abstain}
Abstain
+
+ ${totalFinal > 0 ? `
+
+
+
+
` : ""} +
+ + + +
-
-
${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" ? ` -
- Final result: ${p.status} - (conviction score was ${Math.round(p.score)}) -
-
-
${p.final_yes}
Yes
-
${p.final_no}
No
-
${p.final_abstain}
Abstain
+
+
Result: ${p.status}
+
+
${p.final_yes}
Yes
+
${p.final_no}
No
+
${p.final_abstain}
Abstain
+
+ ${totalFinal > 0 ? `
+
+
+
+
` : ""} +
+ Conviction score was ${Math.round(p.score)} +
` : ""}
@@ -515,17 +758,15 @@ class FolkVoteDashboard extends HTMLElement { 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); - } + if (this.space === "demo") this.render(); + else this.loadProposals(slug); }); }); - // Proposal cards + // Proposal title click → detail view this.shadow.querySelectorAll("[data-proposal]").forEach((el) => { - el.addEventListener("click", () => { + el.addEventListener("click", (e) => { + e.stopPropagation(); const id = (el as HTMLElement).dataset.proposal!; this.view = "proposal"; if (this.space === "demo") { @@ -547,12 +788,13 @@ class FolkVoteDashboard extends HTMLElement { }); }); - // Conviction vote buttons + // 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!); - if (this.selectedProposal) this.castVote(this.selectedProposal.id, weight); + const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id; + if (id) this.castVote(id, weight); }); }); @@ -561,9 +803,32 @@ class FolkVoteDashboard extends HTMLElement { el.addEventListener("click", (e) => { e.stopPropagation(); const vote = (el as HTMLElement).dataset.finalVote!; - if (this.selectedProposal) this.castFinalVote(this.selectedProposal.id, vote); + 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 { diff --git a/modules/rvote/components/vote.css b/modules/rvote/components/vote.css index 4cb6b3e..a13943e 100644 --- a/modules/rvote/components/vote.css +++ b/modules/rvote/components/vote.css @@ -4,3 +4,198 @@ folk-vote-dashboard { min-height: 400px; padding: 20px; } + +/* ── Demo page layout ── */ +.rd-page { + max-width: 720px; + margin: 0 auto; + padding: 2rem 1rem 4rem; + font-family: system-ui, -apple-system, sans-serif; + color: #e2e8f0; +} + +.rd-hero { + text-align: center; + margin-bottom: 2rem; +} +.rd-hero h1 { + font-size: 2rem; + font-weight: 700; + margin: 0 0 0.5rem; + background: linear-gradient(to right, #818cf8, #c084fc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.rd-hero p { + color: #94a3b8; + font-size: 1rem; + margin: 0; +} + +.rd-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +/* ── Connection badge ── */ +.rd-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.rd-status::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; +} +.rd-status--connected { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.25); +} +.rd-status--connected::before { background: #22c55e; } +.rd-status--disconnected { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.25); +} +.rd-status--disconnected::before { background: #ef4444; } + +/* ── Buttons ── */ +.rd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.4rem 1rem; + border-radius: 8px; + border: 1px solid #475569; + background: #1e293b; + color: #e2e8f0; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} +.rd-btn:hover:not(:disabled) { border-color: #818cf8; color: #818cf8; } +.rd-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.rd-btn--primary { + background: linear-gradient(to right, #818cf8, #6366f1); + border-color: transparent; + color: white; +} +.rd-btn--primary:hover:not(:disabled) { opacity: 0.9; color: white; } +.rd-btn--ghost { + background: transparent; + border: 1px solid #334155; + color: #94a3b8; + padding: 0; + border-radius: 6px; + font-weight: 700; + font-size: 1rem; +} +.rd-btn--ghost:hover:not(:disabled) { border-color: #f97316; color: #fb923c; } + +/* ── Poll cards ── */ +.rd-card { + background: #0f172a; + border: 1px solid #1e293b; + border-radius: 12px; + margin-bottom: 1rem; + overflow: hidden; + transition: border-color 0.2s; +} +.rd-card:hover { border-color: rgba(249, 115, 22, 0.35); } + +.rd-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem 1.25rem 0.5rem; + gap: 0.75rem; +} +.rd-card-title { + font-size: 1rem; + font-weight: 600; + color: #e2e8f0; + line-height: 1.4; +} +.rd-icon { margin-right: 0.35rem; } +.rd-card-body { + padding: 0.5rem 1.25rem 1.25rem; +} + +/* ── Badges ── */ +.rd-badge { + display: inline-flex; + padding: 0.15rem 0.5rem; + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; + white-space: nowrap; +} +.rd-badge--orange { background: rgba(249, 115, 22, 0.15); color: #fb923c; } +.rd-badge--green { background: rgba(34, 197, 94, 0.15); color: #22c55e; } +.rd-badge--red { background: rgba(239, 68, 68, 0.15); color: #ef4444; } +.rd-badge--blue { background: rgba(59, 130, 246, 0.15); color: #60a5fa; } +.rd-badge--slate { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } + +/* ── Loading / empty ── */ +.rd-loading { + text-align: center; + padding: 3rem 1rem; + color: #64748b; +} +.rd-loading-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(129, 140, 248, 0.2); + border-top-color: #818cf8; + border-radius: 50%; + animation: rd-spin 0.8s linear infinite; + margin: 0 auto 1rem; +} +@keyframes rd-spin { to { transform: rotate(360deg); } } +.rd-empty { + text-align: center; + padding: 3rem 1rem; + color: #475569; + font-size: 0.9rem; +} +.rd-empty-icon { font-size: 2rem; margin-bottom: 0.5rem; } + +/* ── Footer link ── */ +.rd-footer { + text-align: center; + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 1px solid #1e293b; +} +.rd-footer a { + color: #64748b; + text-decoration: none; + font-size: 0.85rem; +} +.rd-footer a:hover { color: #818cf8; } + +/* ── Responsive ── */ +@media (max-width: 600px) { + .rd-page { padding: 1rem 0.75rem 3rem; } + .rd-hero h1 { font-size: 1.5rem; } + .rd-card-header { padding: 0.75rem 1rem 0.4rem; } + .rd-card-body { padding: 0.4rem 1rem 1rem; } +} diff --git a/modules/rvote/landing.ts b/modules/rvote/landing.ts index dd82ae4..5df1d5d 100644 --- a/modules/rvote/landing.ts +++ b/modules/rvote/landing.ts @@ -18,7 +18,7 @@ export function renderLanding(): string { then advance to final voting.

- @@ -101,7 +101,7 @@ export function renderLanding(): string {

Vote on live polls synced across the r* ecosystem. Changes appear in real-time for everyone.

-
Open Interactive Demo @@ -375,7 +375,7 @@ export function renderLanding(): string { - + Interactive Demo diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index a7ee473..a31dad8 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -506,7 +506,51 @@ routes.post("/api/proposals/:id/final-vote", async (c) => { return c.json({ ok: true, tally }); }); -// ── Page route ── +// ── Page routes ── + +// Demo page — interactive polls with live sync +routes.get("/demo", (c) => { + return c.html(renderShell({ + title: "rVote Demo — Interactive Polls | rSpace", + moduleId: "rvote", + spaceSlug: "demo", + modules: getModuleInfoList(), + theme: "dark", + body: ` +`, + scripts: ``, + styles: ``, + })); +}); + +// Dashboard — full voting app with spaces, proposals, conviction voting routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ diff --git a/vite.config.ts b/vite.config.ts index 49a8bb9..fcd0fc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -372,6 +372,31 @@ export default defineConfig({ }, }); + // Build vote demo page script + await build({ + configFile: false, + root: resolve(__dirname, "modules/rvote/components"), + resolve: { + alias: { + "@lib": resolve(__dirname, "./lib"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rvote"), + lib: { + entry: resolve(__dirname, "modules/rvote/components/vote-demo.ts"), + formats: ["es"], + fileName: () => "vote-demo.js", + }, + rollupOptions: { + output: { + entryFileNames: "vote-demo.js", + }, + }, + }, + }); + // Copy vote CSS mkdirSync(resolve(__dirname, "dist/modules/rvote"), { recursive: true }); copyFileSync(