rspace-online/modules/choices/components/folk-choices-dashboard.ts

497 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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);