310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
/**
|
|
* rVote demo — client-side WebSocket controller.
|
|
*
|
|
* Connects via DemoSync, renders poll cards into #rd-polls-container,
|
|
* and handles vote +/- button clicks via event delegation.
|
|
*/
|
|
|
|
import { DemoSync } from "@lib/demo-sync-vanilla";
|
|
import type { DemoShape } from "@lib/demo-sync-vanilla";
|
|
|
|
// ── Types ──
|
|
|
|
interface PollOption {
|
|
label: string;
|
|
votes: number;
|
|
}
|
|
|
|
interface DemoPoll extends DemoShape {
|
|
question: string;
|
|
options: PollOption[];
|
|
totalVoters: number;
|
|
status: "active" | "closed";
|
|
endsAt: string;
|
|
}
|
|
|
|
function isPoll(shape: DemoShape): shape is DemoPoll {
|
|
return shape.type === "demo-poll" && Array.isArray((shape as DemoPoll).options);
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function formatDeadline(dateStr: string): string {
|
|
const diff = new Date(dateStr).getTime() - Date.now();
|
|
if (diff <= 0) return "Voting closed";
|
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
return `${days} day${days !== 1 ? "s" : ""} left`;
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
// ── DOM refs ──
|
|
|
|
const connBadge = document.getElementById("rd-conn-badge") as HTMLElement;
|
|
const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement;
|
|
const loadingEl = document.getElementById("rd-loading") as HTMLElement;
|
|
const emptyEl = document.getElementById("rd-empty") as HTMLElement;
|
|
const container = document.getElementById("rd-polls-container") as HTMLElement;
|
|
|
|
// ── DemoSync ──
|
|
|
|
const sync = new DemoSync({ filter: ["demo-poll"] });
|
|
|
|
// Show loading spinner immediately
|
|
loadingEl.style.display = "";
|
|
|
|
// ── Connection events ──
|
|
|
|
sync.addEventListener("connected", () => {
|
|
connBadge.className = "rd-status rd-status--connected";
|
|
connBadge.textContent = "Connected";
|
|
resetBtn.disabled = false;
|
|
});
|
|
|
|
sync.addEventListener("disconnected", () => {
|
|
connBadge.className = "rd-status rd-status--disconnected";
|
|
connBadge.textContent = "Disconnected";
|
|
resetBtn.disabled = true;
|
|
});
|
|
|
|
// ── Snapshot → render ──
|
|
|
|
sync.addEventListener("snapshot", ((e: CustomEvent) => {
|
|
const shapes: Record<string, DemoShape> = e.detail.shapes;
|
|
const polls = Object.values(shapes).filter(isPoll);
|
|
|
|
// Hide loading
|
|
loadingEl.style.display = "none";
|
|
|
|
// Show/hide empty state
|
|
if (polls.length === 0) {
|
|
emptyEl.style.display = "";
|
|
container.innerHTML = "";
|
|
return;
|
|
}
|
|
emptyEl.style.display = "none";
|
|
|
|
// Render poll cards
|
|
container.innerHTML = polls.map((poll) => renderPollCard(poll)).join("");
|
|
}) as EventListener);
|
|
|
|
// ── Render a single poll card ──
|
|
|
|
function renderPollCard(poll: DemoPoll): string {
|
|
const total = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
|
|
const maxVotes = Math.max(...poll.options.map((o) => o.votes), 1);
|
|
const statusBadge =
|
|
poll.status === "active"
|
|
? `<span class="rd-badge rd-badge--orange">Active</span>`
|
|
: `<span class="rd-badge rd-badge--slate">Closed</span>`;
|
|
|
|
const optionsHTML = poll.options
|
|
.map((opt, idx) => {
|
|
const pct = total > 0 ? (opt.votes / total) * 100 : 0;
|
|
const barPct = maxVotes > 0 ? (opt.votes / maxVotes) * 100 : 0;
|
|
|
|
return `<div style="display:flex; align-items:center; gap:0.75rem">
|
|
<div style="display:flex; align-items:center; gap:0.25rem; flex-shrink:0">
|
|
<button class="rd-btn rd-btn--ghost" data-vote="-1" data-poll="${esc(poll.id)}" data-opt="${idx}" style="width:2rem;height:2rem;padding:0;justify-content:center"${!sync.connected || opt.votes <= 0 ? " disabled" : ""}>−</button>
|
|
<span style="width:2rem; text-align:center; font-weight:700; font-size:0.875rem">${opt.votes}</span>
|
|
<button class="rd-btn rd-btn--ghost" data-vote="1" data-poll="${esc(poll.id)}" data-opt="${idx}" style="width:2rem;height:2rem;padding:0;justify-content:center"${!sync.connected ? " disabled" : ""}>+</button>
|
|
</div>
|
|
<div style="flex:1; min-width:0">
|
|
<div style="display:flex; justify-content:space-between; margin-bottom:0.25rem">
|
|
<span style="font-size:0.875rem; font-weight:500">${esc(opt.label)}</span>
|
|
<span style="font-size:0.75rem; color:#94a3b8">${Math.round(pct)}%</span>
|
|
</div>
|
|
<div class="rd-progress--sm" style="height:0.625rem; border-radius:9999px; background:#334155; overflow:hidden">
|
|
<div style="height:100%; border-radius:9999px; background:linear-gradient(90deg,#fb923c,#f97316); width:${barPct}%; transition:width 0.3s"></div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
|
|
return `<div class="rd-card" data-poll-id="${esc(poll.id)}" style="border:2px solid rgba(249,115,22,0.2)">
|
|
<div class="rd-card-header">
|
|
<div class="rd-card-title"><span class="rd-icon">🗳</span> ${esc(poll.question)}</div>
|
|
${statusBadge}
|
|
</div>
|
|
<div style="padding:0.5rem 1.25rem; display:flex; gap:1rem; font-size:0.875rem; color:#94a3b8">
|
|
<span>👥 ${poll.totalVoters} voter${poll.totalVoters !== 1 ? "s" : ""}</span>
|
|
<span>⏱ ${formatDeadline(poll.endsAt)}</span>
|
|
<span style="margin-left:auto; color:rgba(255,255,255,0.7); font-weight:500">${total} total vote${total !== 1 ? "s" : ""}</span>
|
|
</div>
|
|
<div class="rd-card-body" style="display:flex; flex-direction:column; gap:0.75rem">
|
|
${optionsHTML}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Event delegation for vote buttons ──
|
|
|
|
container.addEventListener("click", (e) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("button[data-vote]");
|
|
if (!btn || btn.disabled) return;
|
|
|
|
const pollId = btn.dataset.poll!;
|
|
const optIdx = parseInt(btn.dataset.opt!, 10);
|
|
const delta = parseInt(btn.dataset.vote!, 10);
|
|
|
|
const shape = sync.shapes[pollId];
|
|
if (!shape || !isPoll(shape)) return;
|
|
|
|
const updatedOptions = shape.options.map((opt, i) => {
|
|
if (i !== optIdx) return opt;
|
|
return { ...opt, votes: Math.max(0, opt.votes + delta) };
|
|
});
|
|
|
|
sync.updateShape(pollId, { options: updatedOptions });
|
|
});
|
|
|
|
// ── Reset button ──
|
|
|
|
resetBtn.addEventListener("click", async () => {
|
|
resetBtn.disabled = true;
|
|
try {
|
|
await sync.resetDemo();
|
|
} catch (err) {
|
|
console.error("Reset failed:", err);
|
|
} finally {
|
|
if (sync.connected) resetBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// ── Connect ──
|
|
|
|
sync.connect();
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Conviction Voting Simulator (local-only, no sync)
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
interface SimProposal {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
baseScore: number;
|
|
myVotes: number; // how many times user has upvoted (0..5)
|
|
}
|
|
|
|
const MAX_CREDITS = 50;
|
|
let simCredits = MAX_CREDITS;
|
|
let simProposals: SimProposal[] = getInitialProposals();
|
|
|
|
function getInitialProposals(): SimProposal[] {
|
|
return [
|
|
{ id: "s1", title: "Add dark mode across all r* modules", description: "CSS custom properties for consistent theming", baseScore: 45, myVotes: 0 },
|
|
{ id: "s2", title: "Real-time collaboration in rNotes", description: "Automerge CRDT simultaneous editing", baseScore: 72, myVotes: 0 },
|
|
{ id: "s3", title: "Cosmolocal print-on-demand for merch", description: "Route orders to closest provider", baseScore: 38, myVotes: 0 },
|
|
{ id: "s4", title: "EncryptID passkeys for all auth", description: "One WebAuthn passkey, all apps", baseScore: 61, myVotes: 0 },
|
|
{ id: "s5", title: "Federated moderation tools", description: "Community-driven content policies", baseScore: 29, myVotes: 0 },
|
|
];
|
|
}
|
|
|
|
function quadraticCost(votes: number): number {
|
|
return votes * votes;
|
|
}
|
|
|
|
function totalSpent(): number {
|
|
return simProposals.reduce((sum, p) => sum + quadraticCost(p.myVotes), 0);
|
|
}
|
|
|
|
function effectiveScore(p: SimProposal): number {
|
|
return p.baseScore + p.myVotes;
|
|
}
|
|
|
|
function costOfNextVote(p: SimProposal): number {
|
|
return quadraticCost(p.myVotes + 1) - quadraticCost(p.myVotes);
|
|
}
|
|
|
|
function canAffordNextVote(p: SimProposal): boolean {
|
|
return costOfNextVote(p) <= simCredits && p.myVotes < 5;
|
|
}
|
|
|
|
function renderSimulator(): void {
|
|
const simContainer = document.getElementById("rd-sim-proposals");
|
|
const creditsEl = document.getElementById("rd-credits");
|
|
if (!simContainer || !creditsEl) return;
|
|
|
|
simCredits = MAX_CREDITS - totalSpent();
|
|
creditsEl.textContent = String(simCredits);
|
|
creditsEl.style.color = simCredits < 10 ? "#ef4444" : simCredits < 20 ? "#f97316" : "#22c55e";
|
|
|
|
// Sort by effective score descending
|
|
const sorted = [...simProposals].sort((a, b) => effectiveScore(b) - effectiveScore(a));
|
|
|
|
simContainer.innerHTML = sorted.map((p, rank) => {
|
|
const score = effectiveScore(p);
|
|
const mySpent = quadraticCost(p.myVotes);
|
|
const nextCost = costOfNextVote(p);
|
|
const canVote = canAffordNextVote(p);
|
|
const canUnvote = p.myVotes > 0;
|
|
const promotionPct = Math.min(100, (score / 100) * 100);
|
|
|
|
return `<div class="rd-card rd-sim-card" data-sim-id="${p.id}" style="border:2px solid rgba(129,140,248,0.2);margin-bottom:0.75rem;transition:all 0.3s">
|
|
<div style="display:flex;align-items:stretch">
|
|
<!-- Vote column -->
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0.75rem 0.5rem;min-width:3.5rem;border-right:1px solid #1e293b;gap:0.15rem">
|
|
<button class="rd-btn rd-btn--ghost rd-sim-vote" data-sim-dir="up" data-sim-id="${p.id}"
|
|
style="width:1.75rem;height:1.75rem;padding:0;justify-content:center;font-size:1rem;${p.myVotes > 0 ? 'border-color:#f97316;color:#fb923c' : ''}"
|
|
${!canVote ? 'disabled' : ''}>▲</button>
|
|
<span style="font-weight:700;font-size:1.1rem;color:${p.myVotes > 0 ? '#fb923c' : '#e2e8f0'};font-family:monospace">${score}</span>
|
|
<button class="rd-btn rd-btn--ghost rd-sim-vote" data-sim-dir="down" data-sim-id="${p.id}"
|
|
style="width:1.75rem;height:1.75rem;padding:0;justify-content:center;font-size:1rem"
|
|
${!canUnvote ? 'disabled' : ''}>▼</button>
|
|
</div>
|
|
<!-- Content column -->
|
|
<div style="flex:1;padding:0.75rem 1rem;min-width:0">
|
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;flex-wrap:wrap">
|
|
<span style="font-size:0.65rem;color:#475569;font-weight:700;font-family:monospace">#${rank + 1}</span>
|
|
<span style="font-size:0.9rem;font-weight:600;color:#e2e8f0">${esc(p.title)}</span>
|
|
${p.myVotes > 0 ? `<span class="rd-badge rd-badge--orange" style="font-size:0.6rem">You: ${p.myVotes} vote${p.myVotes > 1 ? 's' : ''} (${mySpent} credit${mySpent > 1 ? 's' : ''})</span>` : ''}
|
|
</div>
|
|
<p style="font-size:0.78rem;color:#64748b;margin:0 0 0.5rem">${esc(p.description)}</p>
|
|
<!-- Promotion progress bar -->
|
|
<div style="display:flex;align-items:center;gap:0.5rem">
|
|
<div style="flex:1;height:4px;border-radius:2px;background:#1e293b;overflow:hidden">
|
|
<div style="height:100%;border-radius:2px;background:${score >= 100 ? '#22c55e' : 'linear-gradient(90deg,#818cf8,#c084fc)'};width:${promotionPct}%;transition:width 0.3s"></div>
|
|
</div>
|
|
<span style="font-size:0.65rem;color:#475569;white-space:nowrap;font-family:monospace">${score}/100</span>
|
|
${score >= 100 ? '<span class="rd-badge rd-badge--green" style="font-size:0.55rem">PROMOTED</span>' : ''}
|
|
</div>
|
|
${canVote ? `<div style="font-size:0.65rem;color:#475569;margin-top:0.35rem">Next vote costs <strong style="color:#c084fc">${nextCost}</strong> credit${nextCost > 1 ? 's' : ''}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
// Event delegation for simulator votes
|
|
document.getElementById("rd-sim-proposals")?.addEventListener("click", (e) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("button.rd-sim-vote");
|
|
if (!btn || btn.disabled) return;
|
|
|
|
const id = btn.dataset.simId!;
|
|
const dir = btn.dataset.simDir!;
|
|
const proposal = simProposals.find((p) => p.id === id);
|
|
if (!proposal) return;
|
|
|
|
if (dir === "up" && canAffordNextVote(proposal)) {
|
|
proposal.myVotes++;
|
|
} else if (dir === "down" && proposal.myVotes > 0) {
|
|
proposal.myVotes--;
|
|
}
|
|
|
|
renderSimulator();
|
|
});
|
|
|
|
// Reset simulator
|
|
document.getElementById("rd-sim-reset")?.addEventListener("click", () => {
|
|
simProposals = getInitialProposals();
|
|
simCredits = MAX_CREDITS;
|
|
renderSimulator();
|
|
});
|
|
|
|
// Initial render
|
|
renderSimulator();
|