feat(rvote): enhanced landing, demo page, and dashboard to match rvote.online quality
- Add /demo route with interactive poll page (connection status, reset, live sync) - Full rd-* CSS system for demo poll cards, badges, loading states - Fix landing page links: demo.rspace.online/rvote → /rvote/demo - Enhanced folk-vote-dashboard: inline voting on proposal cards, grouped by status (voting/ranking/decided), create-proposal form, tally bars, downvote support, richer visual design with progress indicators - Add vote-demo.ts build step in vite.config.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a5c7bb784e
commit
192659b49c
|
|
@ -2,6 +2,7 @@
|
|||
* <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";
|
||||
|
|
@ -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 = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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; }
|
||||
|
||||
.card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
||||
padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
|
||||
/* Space cards */
|
||||
.space-card {
|
||||
background: #0f172a; border: 1px solid #1e293b; border-radius: 12px;
|
||||
padding: 20px; margin-bottom: 12px; cursor: pointer; transition: all 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; }
|
||||
.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-block; padding: 2px 8px; border-radius: 4px;
|
||||
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-bar { height: 6px; border-radius: 3px; background: #333; margin-top: 6px; overflow: hidden; }
|
||||
.score-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||
/* 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-controls { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
/* Vote buttons */
|
||||
.vote-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.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;
|
||||
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: #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; }
|
||||
.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 { display: flex; gap: 16px; margin-top: 12px; }
|
||||
/* Tally */
|
||||
.tally { display: flex; gap: 20px; margin: 10px 0 8px; }
|
||||
.tally-item { text-align: center; }
|
||||
.tally-value { font-size: 24px; font-weight: 700; }
|
||||
.tally-label { font-size: 11px; color: #888; text-transform: uppercase; }
|
||||
.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; }
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px; }
|
||||
.loading { text-align: center; color: #888; padding: 40px; }
|
||||
.error { text-align: center; color: #ef5350; padding: 20px; }
|
||||
/* 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>` : ""}
|
||||
|
|
@ -361,23 +494,25 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
|
||||
private renderSpaces(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Voting Spaces</span>
|
||||
<div class="header">
|
||||
<span class="header-title">Voting Spaces</span>
|
||||
</div>
|
||||
${this.spaces.length === 0 ? '<div class="empty">No voting spaces yet. Create one to get started.</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="card" data-space="${s.slug}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="card-title">${this.esc(s.name)}</div>
|
||||
<span class="badge" style="background:rgba(129,140,248,0.15);color:#818cf8">${s.visibility === "public" ? "👁 Public" : s.visibility === "permissioned" ? "🔑 Permissioned" : s.visibility === "private" ? "🔒 Private" : s.visibility}</span>
|
||||
<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="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>
|
||||
<span>Max: ${s.max_credits}</span>
|
||||
<span>Start: ${s.starting_credits}</span>
|
||||
<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("")}
|
||||
|
|
@ -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 `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="spaces">← Spaces</button>
|
||||
<span class="rapp-nav__title">${this.esc(s.name)} — Proposals</span>
|
||||
<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.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>
|
||||
<span>${p.vote_count} votes</span>
|
||||
<span>${this.relativeTime(p.created_at)}</span>
|
||||
</div>
|
||||
` : ""}
|
||||
${p.status === "VOTING" ? `
|
||||
<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>
|
||||
${p.voting_ends_at ? `<span>Ends: ${new Date(p.voting_ends_at).toLocaleDateString()}</span>` : ""}
|
||||
</div>
|
||||
` : ""}
|
||||
${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>
|
||||
<span>${this.relativeTime(p.created_at)}</span>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${this.space === "demo" ? `
|
||||
<div class="demo-banner">
|
||||
<strong>Demo mode</strong> — votes modify local state only. Create a space for real governance.
|
||||
</div>
|
||||
`).join("")}
|
||||
` : ""}
|
||||
|
||||
${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 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 `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="proposals">← Proposals</button>
|
||||
<span class="rapp-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 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="card" style="cursor:default">
|
||||
<div class="card-desc" style="font-size:14px;color:#ccc;margin-bottom:12px">${this.esc(p.description || "No description")}</div>
|
||||
<div class="card-meta" style="margin-bottom:12px">
|
||||
<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 ${ageText}</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 style="margin-bottom:8px;font-size:13px;color:#888">
|
||||
Conviction score: <strong style="color:#3b82f6">${Math.round(p.score)}</strong> / ${threshold}
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" style="width:${Math.min(100, (p.score / threshold) * 100)}%;background:#3b82f6"></div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#666;margin-top:4px">
|
||||
Needs ${threshold} to advance to voting (quadratic cost: weight² credits)
|
||||
</div>
|
||||
<div class="vote-controls">
|
||||
${[1, 2, 3, 5].map((w) => `
|
||||
<button class="vote-btn" data-vote-weight="${w}">
|
||||
+${w} (${w * w} credits)
|
||||
</button>
|
||||
`).join("")}
|
||||
<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 style="margin-bottom:8px;font-size:13px;color:#888">
|
||||
Promoted with conviction score <strong style="color:#f59e0b">${Math.round(p.score)}</strong> — now in ${this.selectedSpace?.voting_period_days || 7}-day final vote
|
||||
<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>
|
||||
<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 style="margin-bottom:8px;font-size:13px;color:#888">
|
||||
Final result: <strong style="color:${this.getStatusColor(p.status)}">${p.status}</strong>
|
||||
(conviction score was ${Math.round(p.score)})
|
||||
</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 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function renderLanding(): string {
|
|||
then advance to final voting.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rvote" class="rl-cta-primary" id="ml-primary"
|
||||
<a href="/rvote/demo" class="rl-cta-primary" id="ml-primary"
|
||||
style="background:linear-gradient(to right,#818cf8,#6366f1);color:white">
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
|
|
@ -101,7 +101,7 @@ export function renderLanding(): string {
|
|||
<p style="color:#94a3b8;max-width:640px;margin:0 auto 2rem">
|
||||
Vote on live polls synced across the r* ecosystem. Changes appear in real-time for everyone.
|
||||
</p>
|
||||
<a href="https://demo.rspace.online/rvote" class="rl-cta-primary"
|
||||
<a href="/rvote/demo" class="rl-cta-primary"
|
||||
style="background:linear-gradient(to right,#818cf8,#6366f1);color:white">
|
||||
Open Interactive Demo
|
||||
</a>
|
||||
|
|
@ -375,7 +375,7 @@ export function renderLanding(): string {
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
</span>
|
||||
</a>
|
||||
<a href="https://demo.rspace.online/rvote" class="rl-cta-secondary">
|
||||
<a href="/rvote/demo" class="rl-cta-secondary">
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
Interactive Demo
|
||||
|
|
|
|||
|
|
@ -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: `
|
||||
<div class="rd-page">
|
||||
<div class="rd-hero">
|
||||
<h1>rVote Demo</h1>
|
||||
<p>Vote on live proposals synced across the r* ecosystem. Changes appear in real-time for everyone.</p>
|
||||
</div>
|
||||
|
||||
<div class="rd-toolbar">
|
||||
<span id="rd-conn-badge" class="rd-status rd-status--disconnected">Connecting</span>
|
||||
<button id="rd-reset-btn" class="rd-btn" disabled>Reset Demo</button>
|
||||
<a href="/rvote" class="rd-btn" style="text-decoration:none;margin-left:auto">← About rVote</a>
|
||||
</div>
|
||||
|
||||
<div id="rd-loading" class="rd-loading">
|
||||
<div class="rd-loading-spinner"></div>
|
||||
Loading polls...
|
||||
</div>
|
||||
|
||||
<div id="rd-empty" class="rd-empty" style="display:none">
|
||||
<div class="rd-empty-icon">🗳</div>
|
||||
No polls found. Click <strong>Reset Demo</strong> to seed some sample polls.
|
||||
</div>
|
||||
|
||||
<div id="rd-polls-container"></div>
|
||||
|
||||
<div class="rd-footer">
|
||||
<a href="/rvote">← Back to rVote</a>
|
||||
</div>
|
||||
</div>`,
|
||||
scripts: `<script type="module" src="/modules/rvote/vote-demo.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rvote/vote.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
// Dashboard — full voting app with spaces, proposals, conviction voting
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue