653 lines
16 KiB
TypeScript
653 lines
16 KiB
TypeScript
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
|
|
const USER_ID_KEY = "folk-choice-userid";
|
|
const USER_NAME_KEY = "folk-choice-username";
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
min-width: 320px;
|
|
min-height: 300px;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: #0d9488;
|
|
color: white;
|
|
border-radius: 8px 8px 0 0;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: move;
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.header-actions button {
|
|
background: transparent;
|
|
border: none;
|
|
color: white;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.header-actions button:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100% - 36px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.mode-tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.mode-tab {
|
|
flex: 1;
|
|
padding: 6px 8px;
|
|
text-align: center;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: none;
|
|
background: transparent;
|
|
color: #64748b;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.mode-tab.active {
|
|
color: #0d9488;
|
|
border-bottom: 2px solid #0d9488;
|
|
background: #f0fdfa;
|
|
}
|
|
|
|
.options-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.option-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px;
|
|
border-radius: 6px;
|
|
margin-bottom: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.option-row:hover {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.option-row.voted {
|
|
background: #f0fdfa;
|
|
}
|
|
|
|
.bar-bg {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
border-radius: 6px;
|
|
opacity: 0.12;
|
|
transition: width 0.4s ease;
|
|
}
|
|
|
|
.option-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.option-label {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
z-index: 1;
|
|
color: #1e293b;
|
|
}
|
|
|
|
.option-count {
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
font-variant-numeric: tabular-nums;
|
|
z-index: 1;
|
|
min-width: 20px;
|
|
text-align: right;
|
|
}
|
|
|
|
.option-pct {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
font-variant-numeric: tabular-nums;
|
|
z-index: 1;
|
|
min-width: 32px;
|
|
text-align: right;
|
|
}
|
|
|
|
.qv-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
z-index: 1;
|
|
}
|
|
|
|
.qv-btn {
|
|
width: 22px;
|
|
height: 22px;
|
|
border: 1px solid #cbd5e1;
|
|
border-radius: 4px;
|
|
background: white;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #475569;
|
|
}
|
|
|
|
.qv-btn:hover { background: #f1f5f9; }
|
|
.qv-btn:disabled { opacity: 0.3; cursor: default; }
|
|
|
|
.qv-count {
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 14px;
|
|
text-align: center;
|
|
color: #1e293b;
|
|
}
|
|
|
|
.budget-bar {
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
color: #64748b;
|
|
border-top: 1px solid #e2e8f0;
|
|
text-align: center;
|
|
}
|
|
|
|
.budget-bar .used {
|
|
font-weight: 600;
|
|
color: #0d9488;
|
|
}
|
|
|
|
.voters-count {
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
text-align: center;
|
|
}
|
|
|
|
.add-form {
|
|
display: flex;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-top: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.add-input {
|
|
flex: 1;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
outline: none;
|
|
}
|
|
|
|
.add-input:focus { border-color: #0d9488; }
|
|
|
|
.add-btn {
|
|
background: #0d9488;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 6px 12px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.add-btn:hover { background: #0f766e; }
|
|
|
|
.username-prompt {
|
|
padding: 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
.username-prompt p {
|
|
font-size: 13px;
|
|
color: #64748b;
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.username-input {
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
outline: none;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.username-btn {
|
|
background: #0d9488;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
font-size: 13px;
|
|
}
|
|
`;
|
|
|
|
// -- Data types --
|
|
|
|
export interface VoteOption {
|
|
id: string;
|
|
label: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface UserVote {
|
|
userId: string;
|
|
userName: string;
|
|
allocations: Record<string, number>;
|
|
timestamp: number;
|
|
}
|
|
|
|
export type VoteMode = "plurality" | "approval" | "quadratic";
|
|
|
|
// -- Pure aggregation functions --
|
|
|
|
export function tallyVotes(
|
|
votes: UserVote[],
|
|
options: VoteOption[],
|
|
): Map<string, number> {
|
|
const tally = new Map<string, number>();
|
|
for (const opt of options) tally.set(opt.id, 0);
|
|
for (const v of votes) {
|
|
for (const [optId, count] of Object.entries(v.allocations)) {
|
|
if (tally.has(optId)) {
|
|
tally.set(optId, tally.get(optId)! + count);
|
|
}
|
|
}
|
|
}
|
|
return tally;
|
|
}
|
|
|
|
export function quadraticCost(allocations: Record<string, number>): number {
|
|
let total = 0;
|
|
for (const k of Object.values(allocations)) {
|
|
total += k * k;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
// -- Component --
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-choice-vote": FolkChoiceVote;
|
|
}
|
|
}
|
|
|
|
const DEFAULT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
|
|
|
export class FolkChoiceVote extends FolkShape {
|
|
static override tagName = "folk-choice-vote";
|
|
|
|
static {
|
|
const sheet = new CSSStyleSheet();
|
|
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
|
|
const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n");
|
|
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
|
this.styles = sheet;
|
|
}
|
|
|
|
#title = "Quick Poll";
|
|
#options: VoteOption[] = [];
|
|
#mode: VoteMode = "plurality";
|
|
#budget = 100;
|
|
#votes: UserVote[] = [];
|
|
#userId = "";
|
|
#userName = "";
|
|
|
|
// DOM refs
|
|
#bodyEl: HTMLElement | null = null;
|
|
#optionsEl: HTMLElement | null = null;
|
|
#budgetEl: HTMLElement | null = null;
|
|
#votersEl: HTMLElement | null = null;
|
|
|
|
get title() { return this.#title; }
|
|
set title(v: string) {
|
|
this.#title = v;
|
|
this.requestUpdate("title");
|
|
}
|
|
|
|
get options() { return this.#options; }
|
|
set options(v: VoteOption[]) {
|
|
this.#options = v;
|
|
this.#render();
|
|
this.requestUpdate("options");
|
|
}
|
|
|
|
get mode() { return this.#mode; }
|
|
set mode(v: VoteMode) {
|
|
this.#mode = v;
|
|
this.#render();
|
|
this.requestUpdate("mode");
|
|
}
|
|
|
|
get budget() { return this.#budget; }
|
|
set budget(v: number) {
|
|
this.#budget = v;
|
|
this.#render();
|
|
this.requestUpdate("budget");
|
|
}
|
|
|
|
get votes() { return this.#votes; }
|
|
set votes(v: UserVote[]) {
|
|
this.#votes = v;
|
|
this.#render();
|
|
this.requestUpdate("votes");
|
|
}
|
|
|
|
#ensureIdentity(): boolean {
|
|
if (this.#userId && this.#userName) return true;
|
|
this.#userId = localStorage.getItem(USER_ID_KEY) || "";
|
|
this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || "";
|
|
if (!this.#userId) {
|
|
this.#userId = crypto.randomUUID().slice(0, 8);
|
|
localStorage.setItem(USER_ID_KEY, this.#userId);
|
|
}
|
|
return !!this.#userName;
|
|
}
|
|
|
|
#setUserName(name: string) {
|
|
this.#userName = name;
|
|
localStorage.setItem(USER_NAME_KEY, name);
|
|
localStorage.setItem("rspace-username", name);
|
|
}
|
|
|
|
#getUserVote(): UserVote | undefined {
|
|
return this.#votes.find((v) => v.userId === this.#userId);
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
this.#ensureIdentity();
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.innerHTML = html`
|
|
<div class="header">
|
|
<span class="header-title">
|
|
<span>☑</span>
|
|
<span class="title-text">Poll</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<button class="close-btn" title="Close">×</button>
|
|
</div>
|
|
</div>
|
|
<div class="body">
|
|
<div class="mode-tabs">
|
|
<button class="mode-tab active" data-mode="plurality">Plurality</button>
|
|
<button class="mode-tab" data-mode="approval">Approval</button>
|
|
<button class="mode-tab" data-mode="quadratic">Quadratic</button>
|
|
</div>
|
|
<div class="options-list"></div>
|
|
<div class="budget-bar" style="display:none;"></div>
|
|
<div class="voters-count"></div>
|
|
<div class="add-form">
|
|
<input type="text" class="add-input" placeholder="Add option..." />
|
|
<button class="add-btn">Add</button>
|
|
</div>
|
|
</div>
|
|
<div class="username-prompt" style="display: none;">
|
|
<p>Enter your name to vote:</p>
|
|
<input type="text" class="username-input" placeholder="Your name..." />
|
|
<button class="username-btn">Join</button>
|
|
</div>
|
|
`;
|
|
|
|
const slot = root.querySelector("slot");
|
|
const containerDiv = slot?.parentElement as HTMLElement;
|
|
if (containerDiv) containerDiv.replaceWith(wrapper);
|
|
|
|
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
|
|
this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement;
|
|
this.#budgetEl = wrapper.querySelector(".budget-bar") as HTMLElement;
|
|
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
|
const titleEl = wrapper.querySelector(".title-text") as HTMLElement;
|
|
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
|
|
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
|
|
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
|
|
const addInput = wrapper.querySelector(".add-input") as HTMLInputElement;
|
|
const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement;
|
|
|
|
// Show username prompt if needed
|
|
if (!this.#userName) {
|
|
this.#bodyEl.style.display = "none";
|
|
usernamePrompt.style.display = "block";
|
|
}
|
|
|
|
const submitName = () => {
|
|
const name = usernameInput.value.trim();
|
|
if (name) {
|
|
this.#setUserName(name);
|
|
this.#bodyEl!.style.display = "flex";
|
|
usernamePrompt.style.display = "none";
|
|
this.#render();
|
|
}
|
|
};
|
|
usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); });
|
|
usernameInput.addEventListener("click", (e) => e.stopPropagation());
|
|
usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); });
|
|
|
|
// Mode tabs
|
|
wrapper.querySelectorAll(".mode-tab").forEach((tab) => {
|
|
tab.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const m = (tab as HTMLElement).dataset.mode as VoteMode;
|
|
this.#mode = m;
|
|
wrapper.querySelectorAll(".mode-tab").forEach((t) => t.classList.remove("active"));
|
|
tab.classList.add("active");
|
|
this.#render();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
});
|
|
|
|
// Add option
|
|
const addOption = () => {
|
|
const label = addInput.value.trim();
|
|
if (!label) return;
|
|
this.#options.push({
|
|
id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
label,
|
|
color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length],
|
|
});
|
|
addInput.value = "";
|
|
this.#render();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
};
|
|
addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); });
|
|
addInput.addEventListener("click", (e) => e.stopPropagation());
|
|
addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); });
|
|
|
|
// Close
|
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
// Title
|
|
if (this.#title) titleEl.textContent = this.#title;
|
|
|
|
this.#render();
|
|
return root;
|
|
}
|
|
|
|
#castVote(optionId: string, delta: number) {
|
|
if (!this.#ensureIdentity()) return;
|
|
|
|
let vote = this.#getUserVote();
|
|
if (!vote) {
|
|
vote = { userId: this.#userId, userName: this.#userName, allocations: {}, timestamp: Date.now() };
|
|
this.#votes.push(vote);
|
|
}
|
|
|
|
const current = vote.allocations[optionId] || 0;
|
|
|
|
if (this.#mode === "plurality") {
|
|
// Toggle: clear all, then set this one (or clear if already voted)
|
|
const wasVoted = current > 0;
|
|
for (const key of Object.keys(vote.allocations)) vote.allocations[key] = 0;
|
|
if (!wasVoted) vote.allocations[optionId] = 1;
|
|
} else if (this.#mode === "approval") {
|
|
// Toggle this option
|
|
vote.allocations[optionId] = current > 0 ? 0 : 1;
|
|
} else {
|
|
// Quadratic: increment/decrement
|
|
const next = Math.max(0, current + delta);
|
|
const testAlloc = { ...vote.allocations, [optionId]: next };
|
|
if (quadraticCost(testAlloc) <= this.#budget) {
|
|
vote.allocations[optionId] = next;
|
|
}
|
|
}
|
|
|
|
vote.timestamp = Date.now();
|
|
this.#render();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
|
|
#render() {
|
|
if (!this.#optionsEl) return;
|
|
|
|
const tally = tallyVotes(this.#votes, this.#options);
|
|
const maxVotes = Math.max(1, ...tally.values());
|
|
const totalVotes = [...tally.values()].reduce((a, b) => a + b, 0);
|
|
const myVote = this.#getUserVote();
|
|
const uniqueVoters = new Set(this.#votes.map((v) => v.userId)).size;
|
|
|
|
this.#optionsEl.innerHTML = this.#options
|
|
.map((opt) => {
|
|
const count = tally.get(opt.id) || 0;
|
|
const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
|
|
const barWidth = (count / maxVotes) * 100;
|
|
const myAlloc = myVote?.allocations[opt.id] || 0;
|
|
const isVoted = myAlloc > 0;
|
|
|
|
let controls = "";
|
|
if (this.#mode === "quadratic") {
|
|
controls = `
|
|
<div class="qv-controls">
|
|
<button class="qv-btn qv-minus" data-opt="${opt.id}">−</button>
|
|
<span class="qv-count">${myAlloc}</span>
|
|
<button class="qv-btn qv-plus" data-opt="${opt.id}">+</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="option-row ${isVoted ? "voted" : ""}" data-opt="${opt.id}">
|
|
<div class="bar-bg" style="width:${barWidth}%;background:${opt.color};"></div>
|
|
<span class="option-dot" style="background:${opt.color}"></span>
|
|
<span class="option-label">${this.#escapeHtml(opt.label)}</span>
|
|
${controls}
|
|
<span class="option-count">${count}</span>
|
|
<span class="option-pct" style="color:${opt.color}">${pct.toFixed(0)}%</span>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
// Budget display for quadratic
|
|
if (this.#budgetEl) {
|
|
if (this.#mode === "quadratic") {
|
|
const used = myVote ? quadraticCost(myVote.allocations) : 0;
|
|
this.#budgetEl.style.display = "block";
|
|
this.#budgetEl.innerHTML = `Credits: <span class="used">${used}</span> / ${this.#budget}`;
|
|
} else {
|
|
this.#budgetEl.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// Voter count
|
|
if (this.#votersEl) {
|
|
this.#votersEl.textContent = uniqueVoters === 0
|
|
? "No votes yet"
|
|
: `${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}`;
|
|
}
|
|
|
|
// Wire click events
|
|
if (this.#mode !== "quadratic") {
|
|
this.#optionsEl.querySelectorAll(".option-row").forEach((row) => {
|
|
row.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const optId = (row as HTMLElement).dataset.opt!;
|
|
this.#castVote(optId, 1);
|
|
});
|
|
});
|
|
} else {
|
|
this.#optionsEl.querySelectorAll(".qv-plus").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#castVote((btn as HTMLElement).dataset.opt!, 1);
|
|
});
|
|
});
|
|
this.#optionsEl.querySelectorAll(".qv-minus").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#castVote((btn as HTMLElement).dataset.opt!, -1);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
#escapeHtml(text: string): string {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-choice-vote",
|
|
title: this.#title,
|
|
options: this.#options,
|
|
mode: this.#mode,
|
|
budget: this.#budget,
|
|
votes: this.#votes,
|
|
};
|
|
}
|
|
}
|