497 lines
20 KiB
TypeScript
497 lines
20 KiB
TypeScript
/**
|
||
* <folk-choices-dashboard> — lists choice shapes (polls, rankings, spider charts)
|
||
* from the current space and links to the canvas to create/interact with them.
|
||
*/
|
||
|
||
class FolkChoicesDashboard extends HTMLElement {
|
||
private shadow: ShadowRoot;
|
||
private choices: any[] = [];
|
||
private loading = true;
|
||
private space: string;
|
||
|
||
/* Demo state */
|
||
private demoTab: "spider" | "ranking" | "voting" = "spider";
|
||
private hoveredPerson: string | null = null;
|
||
private rankItems: { id: number; name: string; emoji: string }[] = [];
|
||
private rankDragging: number | null = null;
|
||
private voteOptions: { id: string; name: string; color: string; votes: number }[] = [];
|
||
private voted = false;
|
||
private votedId: string | null = null;
|
||
private simTimer: number | null = null;
|
||
|
||
constructor() {
|
||
super();
|
||
this.shadow = this.attachShadow({ mode: "open" });
|
||
this.space = this.getAttribute("space") || "demo";
|
||
}
|
||
|
||
connectedCallback() {
|
||
if (this.space === "demo") {
|
||
this.loadDemoData();
|
||
return;
|
||
}
|
||
this.render();
|
||
this.loadChoices();
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
if (this.simTimer !== null) {
|
||
clearInterval(this.simTimer);
|
||
this.simTimer = null;
|
||
}
|
||
}
|
||
|
||
private getApiBase(): string {
|
||
const path = window.location.pathname;
|
||
const parts = path.split("/").filter(Boolean);
|
||
return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices";
|
||
}
|
||
|
||
private async loadChoices() {
|
||
this.loading = true;
|
||
this.render();
|
||
try {
|
||
const res = await fetch(`${this.getApiBase()}/api/choices`);
|
||
const data = await res.json();
|
||
this.choices = data.choices || [];
|
||
} catch (e) {
|
||
console.error("Failed to load choices:", e);
|
||
}
|
||
this.loading = false;
|
||
this.render();
|
||
}
|
||
|
||
private render() {
|
||
const typeIcons: Record<string, string> = {
|
||
"folk-choice-vote": "☑",
|
||
"folk-choice-rank": "📊",
|
||
"folk-choice-spider": "🕸",
|
||
};
|
||
const typeLabels: Record<string, string> = {
|
||
"folk-choice-vote": "Poll",
|
||
"folk-choice-rank": "Ranking",
|
||
"folk-choice-spider": "Spider Chart",
|
||
};
|
||
|
||
this.shadow.innerHTML = `
|
||
<style>
|
||
:host { display: block; padding: 1.5rem; }
|
||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||
.create-btns { display: flex; gap: 0.5rem; }
|
||
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; text-decoration: none; }
|
||
.create-btn:hover { border-color: #6366f1; color: #f1f5f9; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
|
||
.card:hover { border-color: #6366f1; }
|
||
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
|
||
.card-type { color: #818cf8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||
.card-meta { color: #94a3b8; font-size: 0.8125rem; }
|
||
.stat { display: inline-block; margin-right: 1rem; }
|
||
.empty { text-align: center; padding: 3rem; color: #64748b; }
|
||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||
.empty p { margin: 0.5rem 0; font-size: 0.875rem; }
|
||
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
|
||
.info { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; color: #a5b4fc; font-size: 0.8125rem; }
|
||
</style>
|
||
|
||
<div class="rapp-nav">
|
||
<span class="rapp-nav__title">Choices</span>
|
||
<div class="create-btns">
|
||
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">➕ New on Canvas</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info">
|
||
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas.
|
||
Create them there and they'll appear here for quick access.
|
||
</div>
|
||
|
||
${this.loading ? `<div class="loading">⏳ Loading choices...</div>` :
|
||
this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)}
|
||
`;
|
||
}
|
||
|
||
private renderEmpty(): string {
|
||
return `<div class="empty">
|
||
<div class="empty-icon">☑</div>
|
||
<p>No choices in this space yet.</p>
|
||
<p>Open the <a href="/${this.space}/rspace" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
|
||
</div>`;
|
||
}
|
||
|
||
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
||
return `<div class="grid">
|
||
${this.choices.map((ch) => `
|
||
<a class="card" href="/${this.space}/rspace">
|
||
<div class="card-icon">${icons[ch.type] || "☑"}</div>
|
||
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
||
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
||
<div class="card-meta">
|
||
<span class="stat">${ch.optionCount} options</span>
|
||
<span class="stat">${ch.voteCount} responses</span>
|
||
</div>
|
||
</a>
|
||
`).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
/* ===== Demo mode ===== */
|
||
|
||
private loadDemoData() {
|
||
this.rankItems = [
|
||
{ id: 1, name: "Thai Place", emoji: "🍜" },
|
||
{ id: 2, name: "Pizza", emoji: "🍕" },
|
||
{ id: 3, name: "Sushi Bar", emoji: "🍣" },
|
||
{ id: 4, name: "Tacos", emoji: "🌮" },
|
||
{ id: 5, name: "Burgers", emoji: "🍔" },
|
||
];
|
||
this.voteOptions = [
|
||
{ id: "action", name: "Action Movie", color: "#ef4444", votes: 2 },
|
||
{ id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 },
|
||
{ id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 },
|
||
{ id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 },
|
||
];
|
||
this.voted = false;
|
||
this.votedId = null;
|
||
this.startVoteSim();
|
||
this.renderDemo();
|
||
}
|
||
|
||
private startVoteSim() {
|
||
if (this.simTimer !== null) clearInterval(this.simTimer);
|
||
const tick = () => {
|
||
if (this.voted) return;
|
||
const idx = Math.floor(Math.random() * this.voteOptions.length);
|
||
this.voteOptions[idx].votes += 1;
|
||
if (this.demoTab === "voting") this.renderDemo();
|
||
};
|
||
const scheduleNext = () => {
|
||
const delay = 1200 + Math.random() * 2000;
|
||
this.simTimer = window.setTimeout(() => {
|
||
tick();
|
||
scheduleNext();
|
||
}, delay) as unknown as number;
|
||
};
|
||
scheduleNext();
|
||
}
|
||
|
||
private renderDemo() {
|
||
const tabs: { key: "spider" | "ranking" | "voting"; label: string; icon: string }[] = [
|
||
{ key: "spider", label: "Spider Chart", icon: "🕸" },
|
||
{ key: "ranking", label: "Ranking", icon: "📊" },
|
||
{ key: "voting", label: "Live Voting", icon: "☑" },
|
||
];
|
||
|
||
let content = "";
|
||
if (this.demoTab === "spider") content = this.renderSpider();
|
||
else if (this.demoTab === "ranking") content = this.renderRanking();
|
||
else content = this.renderVoting();
|
||
|
||
this.shadow.innerHTML = `
|
||
<style>
|
||
:host { display: block; padding: 1.5rem; }
|
||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||
.demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: #6366f1; color: #fff; font-weight: 500; }
|
||
|
||
/* Tabs */
|
||
.demo-tabs { display: flex; gap: 4px; margin-bottom: 1.5rem; background: #0f172a; border-radius: 10px; padding: 4px; }
|
||
.demo-tab { flex: 1; text-align: center; padding: 0.6rem 0.75rem; border-radius: 8px; border: none; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
|
||
.demo-tab:hover { color: #e2e8f0; background: #1e293b; }
|
||
.demo-tab.active { background: #1e293b; color: #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||
.demo-tab-icon { margin-right: 6px; }
|
||
|
||
/* Spider chart */
|
||
.spider-wrap { display: flex; flex-direction: column; align-items: center; }
|
||
.spider-svg { width: 100%; max-width: 420px; }
|
||
.spider-legend { display: flex; gap: 1.25rem; margin-top: 1rem; justify-content: center; flex-wrap: wrap; }
|
||
.spider-legend-item { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 4px 10px; border-radius: 6px; transition: background 0.15s; font-size: 0.875rem; color: #e2e8f0; }
|
||
.spider-legend-item:hover { background: #1e293b; }
|
||
.spider-legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||
.spider-axis-label { fill: #94a3b8; font-size: 13px; font-family: inherit; }
|
||
|
||
/* Ranking */
|
||
.rank-list { list-style: none; padding: 0; margin: 0; max-width: 440px; margin-inline: auto; }
|
||
.rank-item { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1rem; margin-bottom: 6px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; cursor: grab; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; user-select: none; }
|
||
.rank-item:active { cursor: grabbing; }
|
||
.rank-item.dragging { opacity: 0.4; transform: scale(0.97); }
|
||
.rank-item.drag-over { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.3); }
|
||
.rank-pos { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.8rem; color: #0f172a; flex-shrink: 0; }
|
||
.rank-pos.gold { background: #f59e0b; }
|
||
.rank-pos.silver { background: #94a3b8; }
|
||
.rank-pos.bronze { background: #cd7f32; }
|
||
.rank-pos.plain { background: #334155; color: #94a3b8; }
|
||
.rank-emoji { font-size: 1.5rem; flex-shrink: 0; }
|
||
.rank-name { flex: 1; color: #f1f5f9; font-weight: 600; font-size: 1rem; }
|
||
.rank-grip { color: #475569; font-size: 1.1rem; flex-shrink: 0; letter-spacing: 2px; }
|
||
|
||
/* Voting */
|
||
.vote-wrap { max-width: 480px; margin-inline: auto; }
|
||
.vote-option { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1rem; margin-bottom: 8px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; cursor: pointer; position: relative; overflow: hidden; transition: border-color 0.15s; }
|
||
.vote-option:hover { border-color: #6366f1; }
|
||
.vote-option.voted { border-color: #6366f1; }
|
||
.vote-fill { position: absolute; left: 0; top: 0; bottom: 0; opacity: 0.12; transition: width 0.7s ease-out; pointer-events: none; }
|
||
.vote-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; position: relative; z-index: 1; }
|
||
.vote-name { flex: 1; color: #f1f5f9; font-weight: 600; font-size: 1rem; position: relative; z-index: 1; }
|
||
.vote-count { color: #94a3b8; font-weight: 400; font-size: 0.8rem; min-width: 24px; text-align: right; position: relative; z-index: 1; font-variant-numeric: tabular-nums; }
|
||
.vote-pct { font-weight: 600; font-size: 0.8rem; min-width: 40px; text-align: right; position: relative; z-index: 1; font-variant-numeric: tabular-nums; }
|
||
.vote-badge { font-size: 0.625rem; padding: 2px 6px; border-radius: 999px; background: rgba(255,255,255,0.05); color: #94a3b8; margin-left: 6px; position: relative; z-index: 1; font-weight: 400; }
|
||
.vote-actions { display: flex; justify-content: center; margin-top: 1rem; }
|
||
.vote-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
|
||
.vote-reset:hover { border-color: #ef4444; color: #fca5a5; }
|
||
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: #64748b; }
|
||
</style>
|
||
|
||
<div class="rapp-nav">
|
||
<span class="rapp-nav__title">Choices</span>
|
||
<span class="demo-badge">DEMO</span>
|
||
</div>
|
||
|
||
<div class="demo-tabs">
|
||
${tabs.map((t) => `<button class="demo-tab${this.demoTab === t.key ? " active" : ""}" data-tab="${t.key}"><span class="demo-tab-icon">${t.icon}</span>${this.esc(t.label)}</button>`).join("")}
|
||
</div>
|
||
|
||
<div class="demo-content">
|
||
${content}
|
||
</div>
|
||
`;
|
||
|
||
this.bindDemoEvents();
|
||
}
|
||
|
||
/* -- Spider Chart -- */
|
||
|
||
private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } {
|
||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||
}
|
||
|
||
private renderSpider(): string {
|
||
const cx = 200, cy = 200, maxR = 150;
|
||
const axes = ["Taste", "Price", "Speed", "Healthy", "Distance"];
|
||
const people: { name: string; color: string; values: number[] }[] = [
|
||
{ name: "Alice", color: "#7c5bf5", values: [0.9, 0.6, 0.8, 0.4, 0.7] },
|
||
{ name: "Bob", color: "#f59e0b", values: [0.5, 0.9, 0.6, 0.7, 0.8] },
|
||
{ name: "Carol", color: "#10b981", values: [0.7, 0.4, 0.9, 0.8, 0.3] },
|
||
];
|
||
const angleStep = 360 / axes.length;
|
||
|
||
// Grid rings
|
||
let gridLines = "";
|
||
for (let ring = 1; ring <= 5; ring++) {
|
||
const r = (ring / 5) * maxR;
|
||
const pts = axes.map((_, i) => {
|
||
const p = this.polarToXY(cx, cy, r, i * angleStep);
|
||
return `${p.x},${p.y}`;
|
||
}).join(" ");
|
||
gridLines += `<polygon points="${pts}" fill="none" stroke="#334155" stroke-width="1"/>`;
|
||
}
|
||
|
||
// Axis lines + labels
|
||
let axisLines = "";
|
||
const labelOffset = 18;
|
||
axes.forEach((label, i) => {
|
||
const angle = i * angleStep;
|
||
const tip = this.polarToXY(cx, cy, maxR, angle);
|
||
axisLines += `<line x1="${cx}" y1="${cy}" x2="${tip.x}" y2="${tip.y}" stroke="#334155" stroke-width="1"/>`;
|
||
const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle);
|
||
axisLines += `<text x="${lp.x}" y="${lp.y}" text-anchor="middle" dominant-baseline="central" class="spider-axis-label">${this.esc(label)}</text>`;
|
||
});
|
||
|
||
// Data polygons
|
||
let polygons = "";
|
||
people.forEach((person) => {
|
||
const dimmed = this.hoveredPerson !== null && this.hoveredPerson !== person.name;
|
||
const opacity = dimmed ? 0.12 : 0.25;
|
||
const strokeOpacity = dimmed ? 0.2 : 1;
|
||
const strokeWidth = dimmed ? 1 : 2;
|
||
const pts = person.values.map((v, i) => {
|
||
const p = this.polarToXY(cx, cy, v * maxR, i * angleStep);
|
||
return `${p.x},${p.y}`;
|
||
}).join(" ");
|
||
polygons += `<polygon points="${pts}" fill="${person.color}" fill-opacity="${opacity}" stroke="${person.color}" stroke-opacity="${strokeOpacity}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
|
||
|
||
// Dots at each vertex
|
||
person.values.forEach((v, i) => {
|
||
const p = this.polarToXY(cx, cy, v * maxR, i * angleStep);
|
||
const dotOpacity = dimmed ? 0.2 : 1;
|
||
polygons += `<circle cx="${p.x}" cy="${p.y}" r="4" fill="${person.color}" opacity="${dotOpacity}"/>`;
|
||
});
|
||
});
|
||
|
||
const legend = people.map((p) =>
|
||
`<div class="spider-legend-item" data-person="${this.esc(p.name)}"><span class="spider-legend-dot" style="background:${p.color}"></span>${this.esc(p.name)}</div>`
|
||
).join("");
|
||
|
||
return `<div class="spider-wrap">
|
||
<svg class="spider-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||
${gridLines}
|
||
${axisLines}
|
||
${polygons}
|
||
</svg>
|
||
<div class="spider-legend">${legend}</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* -- Ranking -- */
|
||
|
||
private renderRanking(): string {
|
||
const medalClass = (i: number) => i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : "plain";
|
||
const items = this.rankItems.map((item, i) =>
|
||
`<li class="rank-item" draggable="true" data-rank-id="${item.id}">
|
||
<span class="rank-pos ${medalClass(i)}">${i + 1}</span>
|
||
<span class="rank-emoji">${item.emoji}</span>
|
||
<span class="rank-name">${this.esc(item.name)}</span>
|
||
<span class="rank-grip">⠿</span>
|
||
</li>`
|
||
).join("");
|
||
return `<ul class="rank-list">${items}</ul>`;
|
||
}
|
||
|
||
/* -- Live Voting -- */
|
||
|
||
private renderVoting(): string {
|
||
const sorted = [...this.voteOptions].sort((a, b) => b.votes - a.votes);
|
||
const total = sorted.reduce((s, o) => s + o.votes, 0);
|
||
const maxVotes = Math.max(...sorted.map((o) => o.votes), 1);
|
||
|
||
const items = sorted.map((opt) => {
|
||
const pct = total > 0 ? (opt.votes / total) * 100 : 0;
|
||
const isLeader = opt.votes === maxVotes && total > 4;
|
||
return `<div class="vote-option${this.voted ? " voted" : ""}" data-vote-id="${opt.id}" style="border-color:${this.voted === true ? (this.votedId === opt.id ? opt.color : '#334155') : '#334155'}">
|
||
<div class="vote-fill" style="width:${pct}%;background:${opt.color}"></div>
|
||
<span class="vote-dot" style="background:${opt.color}"></span>
|
||
<span class="vote-name">${this.esc(opt.name)}${isLeader ? `<span class="vote-badge">leading</span>` : ""}</span>
|
||
<span class="vote-count">${opt.votes}</span>
|
||
<span class="vote-pct" style="color:${opt.color}">${pct.toFixed(0)}%</span>
|
||
</div>`;
|
||
}).join("");
|
||
|
||
const status = this.voted
|
||
? "Results are in!"
|
||
: "Pick a movie \u2014 votes update live";
|
||
|
||
return `<div class="vote-wrap">
|
||
<div class="vote-status">${status}</div>
|
||
${items}
|
||
${this.voted ? `<div class="vote-actions"><button class="vote-reset">Reset demo</button></div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/* -- Demo event binding -- */
|
||
|
||
private bindDemoEvents() {
|
||
// Tab switching
|
||
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const tab = btn.dataset.tab as "spider" | "ranking" | "voting";
|
||
if (tab && tab !== this.demoTab) {
|
||
this.demoTab = tab;
|
||
this.renderDemo();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Spider legend hover
|
||
this.shadow.querySelectorAll<HTMLElement>(".spider-legend-item").forEach((el) => {
|
||
el.addEventListener("mouseenter", () => {
|
||
this.hoveredPerson = el.dataset.person || null;
|
||
this.renderDemo();
|
||
});
|
||
el.addEventListener("mouseleave", () => {
|
||
this.hoveredPerson = null;
|
||
this.renderDemo();
|
||
});
|
||
});
|
||
|
||
// Ranking drag-and-drop
|
||
const rankList = this.shadow.querySelector(".rank-list");
|
||
if (rankList) {
|
||
const items = rankList.querySelectorAll<HTMLLIElement>(".rank-item");
|
||
items.forEach((li) => {
|
||
li.addEventListener("dragstart", (e: DragEvent) => {
|
||
const id = parseInt(li.dataset.rankId || "0", 10);
|
||
this.rankDragging = id;
|
||
li.classList.add("dragging");
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", String(id));
|
||
}
|
||
});
|
||
|
||
li.addEventListener("dragover", (e: DragEvent) => {
|
||
e.preventDefault();
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||
li.classList.add("drag-over");
|
||
});
|
||
|
||
li.addEventListener("dragleave", () => {
|
||
li.classList.remove("drag-over");
|
||
});
|
||
|
||
li.addEventListener("drop", (e: DragEvent) => {
|
||
e.preventDefault();
|
||
li.classList.remove("drag-over");
|
||
const targetId = parseInt(li.dataset.rankId || "0", 10);
|
||
if (this.rankDragging !== null && this.rankDragging !== targetId) {
|
||
const fromIdx = this.rankItems.findIndex((r) => r.id === this.rankDragging);
|
||
const toIdx = this.rankItems.findIndex((r) => r.id === targetId);
|
||
if (fromIdx !== -1 && toIdx !== -1) {
|
||
const [moved] = this.rankItems.splice(fromIdx, 1);
|
||
this.rankItems.splice(toIdx, 0, moved);
|
||
this.renderDemo();
|
||
}
|
||
}
|
||
});
|
||
|
||
li.addEventListener("dragend", () => {
|
||
this.rankDragging = null;
|
||
li.classList.remove("dragging");
|
||
});
|
||
});
|
||
}
|
||
|
||
// Voting click
|
||
this.shadow.querySelectorAll<HTMLElement>(".vote-option").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
if (this.voted) return;
|
||
const id = el.dataset.voteId || "";
|
||
const opt = this.voteOptions.find((o) => o.id === id);
|
||
if (opt) {
|
||
opt.votes += 1;
|
||
this.voted = true;
|
||
this.votedId = id;
|
||
this.renderDemo();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Vote reset
|
||
const resetBtn = this.shadow.querySelector<HTMLButtonElement>(".vote-reset");
|
||
if (resetBtn) {
|
||
resetBtn.addEventListener("click", () => {
|
||
this.voteOptions = [
|
||
{ id: "action", name: "Action Movie", color: "#ef4444", votes: 2 },
|
||
{ id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 },
|
||
{ id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 },
|
||
{ id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 },
|
||
];
|
||
this.voted = false;
|
||
this.votedId = null;
|
||
this.startVoteSim();
|
||
this.renderDemo();
|
||
});
|
||
}
|
||
}
|
||
|
||
private esc(s: string): string {
|
||
const d = document.createElement("div");
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
}
|
||
|
||
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);
|