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

1239 lines
51 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.
*
* Multiplayer: uses ChoicesLocalFirstClient for real-time voting session sync.
*/
import { TourEngine } from "../../../shared/tour-engine";
import { ChoicesLocalFirstClient } from "../local-first-client";
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
// ── CrowdSurf types ──
interface CrowdSurfOption {
optionId: string;
label: string;
color: string;
sessionId: string;
sessionTitle: string;
sessionType: 'vote' | 'rank' | 'score';
}
// ── Auth helpers ──
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
try {
const raw = localStorage.getItem("encryptid_session");
if (!raw) return null;
const s = JSON.parse(raw);
return s?.accessToken ? s : null;
} catch { return null; }
}
function getMyDid(): string | null {
const s = getSession();
if (!s) return null;
return (s.claims as any).did || s.claims.sub;
}
class FolkChoicesDashboard extends HTMLElement {
private shadow: ShadowRoot;
private choices: any[] = [];
private loading = true;
private space: string;
/* Demo state */
private demoTab: "spider" | "ranking" | "voting" | "crowdsurf" = "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;
/* Multiplayer state */
private lfClient: ChoicesLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null;
private sessions: ChoiceSession[] = [];
private activeSessionId: string | null = null;
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
/* CrowdSurf inline state */
private csOptions: CrowdSurfOption[] = [];
private csCurrentIndex = 0;
private csSwipedMap: Map<string, 'right' | 'left'> = new Map();
private csIsDragging = false;
private csStartX = 0;
private csCurrentX = 0;
private csIsAnimating = false;
private _csTransitionTimer: number | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.demo-content', title: "rChoices", message: "Explore spider charts, rankings, live voting, and CrowdSurf swipe cards. Use the sub-nav above to switch between modes.", advanceOnClick: true },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this.space = this.getAttribute("space") || "demo";
const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | "crowdsurf" | null;
if (tabAttr && ["spider", "ranking", "voting", "crowdsurf"].includes(tabAttr)) {
this.demoTab = tabAttr;
}
this._tour = new TourEngine(
this.shadow,
FolkChoicesDashboard.TOUR_STEPS,
"rchoices_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
if (this.space === "demo") {
this.loadDemoData();
} else {
this.initMultiplayer();
}
if (!localStorage.getItem("rchoices_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
if (this.simTimer !== null) {
clearInterval(this.simTimer);
this.simTimer = null;
}
if (this._csTransitionTimer !== null) {
clearTimeout(this._csTransitionTimer);
this._csTransitionTimer = null;
}
this._lfcUnsub?.();
this._lfcUnsub = null;
this.lfClient?.disconnect();
}
private async initMultiplayer() {
this.loading = true;
this.render();
try {
this.lfClient = new ChoicesLocalFirstClient(this.space);
await this.lfClient.init();
await this.lfClient.subscribe();
this._lfcUnsub = this.lfClient.onChange((doc) => {
this.extractSessions(doc);
this.render();
this.bindLiveEvents();
});
const doc = this.lfClient.getDoc();
if (doc) this.extractSessions(doc);
} catch (err) {
console.warn('[rChoices] Local-first init failed, falling back to API:', err);
}
// Also load canvas-based choices
await this.loadChoices();
this.loading = false;
this.render();
this.bindLiveEvents();
}
private extractSessions(doc: ChoicesDoc) {
this.sessions = doc.sessions ? Object.values(doc.sessions).sort((a, b) => b.createdAt - a.createdAt) : [];
// Pre-compute votes per session
this.sessionVotes.clear();
if (doc.votes) {
for (const [key, vote] of Object.entries(doc.votes)) {
const sessionId = key.split(':')[0];
if (!this.sessionVotes.has(sessionId)) this.sessionVotes.set(sessionId, []);
this.sessionVotes.get(sessionId)!.push(vote);
}
}
}
private createSession() {
const title = prompt('Poll title:');
if (!title || !this.lfClient) return;
const optionsRaw = prompt('Options (comma-separated):');
if (!optionsRaw) return;
const colors = ['#3b82f6', '#ef4444', '#f59e0b', '#10b981', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316'];
const options = optionsRaw.split(',').map((label, i) => ({
id: crypto.randomUUID(),
label: label.trim(),
color: colors[i % colors.length],
})).filter(o => o.label);
const session: ChoiceSession = {
id: crypto.randomUUID(),
title,
type: 'vote',
mode: 'single',
options,
createdBy: getMyDid(),
createdAt: Date.now(),
closed: false,
};
this.lfClient.createSession(session);
this.activeSessionId = session.id;
}
private castVoteOnSession(sessionId: string, optionId: string) {
const myDid = getMyDid();
if (!myDid || !this.lfClient) return;
this.lfClient.castVote(sessionId, myDid, { [optionId]: 1 });
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rchoices/);
return match ? match[0] : "/rchoices";
}
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",
};
const isLive = this.lfClient?.isConnected ?? false;
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; -webkit-tap-highlight-color: transparent; }
button, a, input, select, textarea, [role="button"] { touch-action: manipulation; }
.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: var(--rs-text-primary); }
.create-btns { display: flex; gap: 0.5rem; }
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; text-decoration: none; font-family: inherit; }
.create-btn:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; margin-right: 4px; animation: pulse-dot 2s infinite; }
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.live-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 500; display: flex; align-items: center; gap: 2px; }
/* Sessions */
.section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-muted); margin-bottom: 0.75rem; font-weight: 600; }
.sessions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
.session-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 1rem; cursor: pointer; transition: border-color 0.15s; }
.session-card:hover { border-color: var(--rs-primary); }
.session-card.closed { opacity: 0.6; }
.session-title { font-weight: 600; font-size: 0.95rem; color: var(--rs-text-primary); margin-bottom: 4px; }
.session-meta { font-size: 0.8rem; color: var(--rs-text-secondary); display: flex; gap: 0.75rem; }
.session-status { font-size: 0.65rem; padding: 2px 6px; border-radius: 999px; font-weight: 500; }
.session-status.open { background: rgba(34,197,94,0.15); color: #22c55e; }
.session-status.closed { background: rgba(239,68,68,0.15); color: #ef4444; }
/* Active session */
.active-session { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
.active-header { display: flex; align-items: center; gap: 8px; margin-bottom: 1rem; }
.active-title { font-size: 1.1rem; font-weight: 700; color: var(--rs-text-primary); flex: 1; }
.back-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.8rem; font-family: inherit; }
.back-btn:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.session-actions { display: flex; gap: 6px; }
.session-action-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.75rem; font-family: inherit; }
.session-action-btn:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.session-action-btn.danger:hover { border-color: var(--rs-error, #ef4444); color: #fca5a5; }
/* Vote bars */
.vote-bar-row { display: flex; align-items: center; gap: 10px; padding: 0.6rem 0.75rem; margin-bottom: 6px; background: var(--rs-bg-page); border: 1px solid var(--rs-border); border-radius: 8px; cursor: pointer; position: relative; overflow: hidden; transition: border-color 0.15s; }
.vote-bar-row:hover { border-color: var(--rs-primary); }
.vote-bar-row.my-vote { border-color: var(--rs-primary); }
.vote-bar-fill { position: absolute; left: 0; top: 0; bottom: 0; opacity: 0.12; transition: width 0.5s ease-out; pointer-events: none; }
.vote-bar-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; position: relative; z-index: 1; }
.vote-bar-label { flex: 1; font-weight: 600; font-size: 0.9rem; color: var(--rs-text-primary); position: relative; z-index: 1; }
.vote-bar-count { font-size: 0.8rem; color: var(--rs-text-secondary); position: relative; z-index: 1; min-width: 20px; text-align: right; font-variant-numeric: tabular-nums; }
.vote-bar-pct { font-size: 0.8rem; font-weight: 600; position: relative; z-index: 1; min-width: 40px; text-align: right; font-variant-numeric: tabular-nums; }
.vote-bar-check { position: relative; z-index: 1; font-size: 0.75rem; }
.vote-summary { text-align: center; font-size: 0.8rem; color: var(--rs-text-muted); margin-top: 0.75rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
.card:hover { border-color: var(--rs-primary); }
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
.card-title { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
.card-type { color: var(--rs-primary-hover); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.card-meta { color: var(--rs-text-secondary); font-size: 0.8125rem; }
.stat { display: inline-block; margin-right: 1rem; }
.empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); }
.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: var(--rs-text-secondary); }
.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; }
.divider { border: none; border-top: 1px solid var(--rs-border); margin: 1.5rem 0; }
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Choices</span>
${isLive ? `<span class="live-badge"><span class="live-dot"></span>LIVE</span>` : ''}
<div class="create-btns">
${this.lfClient ? `<button class="create-btn" data-action="new-session">+ New Poll</button>` : ''}
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">+ Canvas</a>
</div>
</div>
${this.loading ? `<div class="loading">Loading...</div>` : this.renderLiveContent(typeIcons, typeLabels)}
`;
this.bindLiveEvents();
}
private renderLiveContent(typeIcons: Record<string, string>, typeLabels: Record<string, string>): string {
let html = '';
// Active session view
if (this.activeSessionId) {
const session = this.sessions.find(s => s.id === this.activeSessionId);
if (session) {
html += this.renderActiveSession(session);
return html;
}
this.activeSessionId = null;
}
// Session cards
if (this.sessions.length > 0) {
html += `<div class="section-label">Live Polls</div>`;
html += this.renderSessionsList();
}
// Canvas choices
if (this.choices.length > 0) {
if (this.sessions.length > 0) html += `<hr class="divider">`;
html += `<div class="section-label">Canvas Choices</div>`;
html += this.renderGrid(typeIcons, typeLabels);
}
if (this.sessions.length === 0 && this.choices.length === 0) {
html += this.renderEmpty();
}
return html;
}
private renderSessionsList(): string {
const cards = this.sessions.map(session => {
const votes = this.sessionVotes.get(session.id) || [];
const status = session.closed ? 'closed' : 'open';
const timeAgo = this.timeAgo(session.createdAt);
return `<div class="session-card${session.closed ? ' closed' : ''}" data-session-id="${session.id}">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span class="session-status ${status}">${status}</span>
<span class="session-title">${this.esc(session.title)}</span>
</div>
<div class="session-meta">
<span>${session.options.length} options</span>
<span>${votes.length} vote${votes.length !== 1 ? 's' : ''}</span>
<span>${timeAgo}</span>
</div>
</div>`;
}).join('');
return `<div class="sessions-grid">${cards}</div>`;
}
private renderActiveSession(session: ChoiceSession): string {
const myDid = getMyDid();
const votes = this.sessionVotes.get(session.id) || [];
const myVote = myDid ? this.lfClient?.getMyVote(session.id, myDid) ?? null : null;
const totalVotes = votes.length;
// Tally votes per option
const tally: Record<string, number> = {};
for (const opt of session.options) tally[opt.id] = 0;
for (const vote of votes) {
for (const [optId, val] of Object.entries(vote.choices)) {
if (val > 0) tally[optId] = (tally[optId] || 0) + 1;
}
}
const bars = session.options.map(opt => {
const count = tally[opt.id] || 0;
const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
const isMyVote = myVote?.choices?.[opt.id] && myVote.choices[opt.id] > 0;
return `<div class="vote-bar-row${isMyVote ? ' my-vote' : ''}" data-vote-option="${opt.id}" data-vote-session="${session.id}">
<div class="vote-bar-fill" style="width:${pct}%;background:${opt.color}"></div>
<span class="vote-bar-dot" style="background:${opt.color}"></span>
<span class="vote-bar-label">${this.esc(opt.label)}</span>
${isMyVote ? `<span class="vote-bar-check">✓</span>` : ''}
<span class="vote-bar-count">${count}</span>
<span class="vote-bar-pct" style="color:${opt.color}">${pct.toFixed(0)}%</span>
</div>`;
}).join('');
const isOwner = myDid && session.createdBy === myDid;
return `<div class="active-session">
<div class="active-header">
<button class="back-btn" data-action="back-to-list">&larr; Back</button>
<span class="active-title">${this.esc(session.title)}</span>
${session.closed ? `<span class="session-status closed">closed</span>` : `<span class="session-status open">open</span>`}
</div>
${!session.closed && !myVote ? `<div class="vote-summary">Tap an option to vote</div>` : ''}
${bars}
<div class="vote-summary">${totalVotes} vote${totalVotes !== 1 ? 's' : ''} total</div>
${isOwner ? `<div class="session-actions" style="margin-top:0.75rem;justify-content:flex-end">
${!session.closed ? `<button class="session-action-btn" data-action="close-session" data-session-id="${session.id}">Close Poll</button>` : ''}
<button class="session-action-btn danger" data-action="delete-session" data-session-id="${session.id}">Delete</button>
</div>` : ''}
</div>`;
}
private bindLiveEvents() {
// New session button
this.shadow.querySelector('[data-action="new-session"]')?.addEventListener('click', () => this.createSession());
// Session card clicks
this.shadow.querySelectorAll<HTMLElement>('.session-card').forEach(el => {
el.addEventListener('click', () => {
const sid = el.dataset.sessionId;
if (sid) { this.activeSessionId = sid; this.render(); }
});
});
// Back to list
this.shadow.querySelector('[data-action="back-to-list"]')?.addEventListener('click', () => {
this.activeSessionId = null;
this.render();
});
// Vote option clicks
this.shadow.querySelectorAll<HTMLElement>('.vote-bar-row').forEach(el => {
el.addEventListener('click', () => {
const sessionId = el.dataset.voteSession;
const optionId = el.dataset.voteOption;
if (!sessionId || !optionId) return;
const session = this.sessions.find(s => s.id === sessionId);
if (session?.closed) return;
const myDid = getMyDid();
if (!myDid) return;
const existing = this.lfClient?.getMyVote(sessionId, myDid);
if (existing) return; // Already voted
this.castVoteOnSession(sessionId, optionId);
});
});
// Close session
this.shadow.querySelector('[data-action="close-session"]')?.addEventListener('click', (e) => {
const sid = (e.currentTarget as HTMLElement).dataset.sessionId;
if (sid && this.lfClient) this.lfClient.closeSession(sid);
});
// Delete session
this.shadow.querySelector('[data-action="delete-session"]')?.addEventListener('click', (e) => {
const sid = (e.currentTarget as HTMLElement).dataset.sessionId;
if (sid && this.lfClient && confirm('Delete this poll?')) {
this.lfClient.deleteSession(sid);
this.activeSessionId = null;
}
});
}
private timeAgo(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
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" data-collab-id="choice:${ch.id}" 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() {
let content = "";
if (this.demoTab === "spider") content = this.renderSpider();
else if (this.demoTab === "ranking") content = this.renderRanking();
else if (this.demoTab === "crowdsurf") content = this.renderCrowdSurf();
else content = this.renderVoting();
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; -webkit-tap-highlight-color: transparent; }
button, a, input, select, textarea, [role="button"] { touch-action: manipulation; }
.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: var(--rs-text-primary); }
.demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: var(--rs-primary); color: #fff; font-weight: 500; }
/* 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: var(--rs-text-primary); }
.spider-legend-item:hover { background: var(--rs-bg-hover); }
.spider-legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.spider-axis-label { fill: var(--rs-text-secondary); 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: var(--rs-bg-surface); border: 1px solid var(--rs-border); 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: var(--rs-primary); 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: var(--rs-bg-page); flex-shrink: 0; }
.rank-pos.gold { background: #f59e0b; }
.rank-pos.silver { background: #94a3b8; }
.rank-pos.bronze { background: #cd7f32; }
.rank-pos.plain { background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); }
.rank-emoji { font-size: 1.5rem; flex-shrink: 0; }
.rank-name { flex: 1; color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; }
.rank-grip { color: var(--rs-text-secondary); 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: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; cursor: pointer; position: relative; overflow: hidden; transition: border-color 0.15s; }
.vote-option:hover { border-color: var(--rs-primary); }
.vote-option.voted { border-color: var(--rs-primary); }
.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: var(--rs-text-primary); font-weight: 600; font-size: 1rem; position: relative; z-index: 1; }
.vote-count { color: var(--rs-text-secondary); 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: var(--rs-text-secondary); 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 var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
.vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; }
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); }
/* CrowdSurf inline */
.cs-inline { max-width: 420px; margin-inline: auto; }
.cs-progress-header { display: flex; justify-content: space-between; margin-bottom: 0.75rem; }
.cs-card-stack { display: flex; flex-direction: column; align-items: center; min-height: 240px; justify-content: center; }
.cs-card { position: relative; width: 100%; background: linear-gradient(135deg, var(--rs-bg-surface) 0%, var(--rs-bg-surface-raised, var(--rs-bg-surface)) 100%); border: 1px solid var(--rs-border); border-radius: 16px; padding: 1.5rem; cursor: grab; user-select: none; touch-action: pan-y; }
.cs-card:active { cursor: grabbing; }
.cs-card-body { display: flex; flex-direction: column; gap: 0.75rem; }
.cs-type-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; width: fit-content; }
.cs-card-session { font-size: 0.85rem; color: var(--rs-text-secondary); }
.cs-card-option { font-size: 1.3rem; font-weight: 700; color: var(--rs-text-primary); display: flex; align-items: center; gap: 10px; }
.cs-color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.cs-swipe-indicator { position: absolute; top: 50%; transform: translateY(-50%); font-size: 1.1rem; font-weight: 700; padding: 6px 14px; border-radius: 8px; opacity: 0; transition: opacity 0.15s; pointer-events: none; z-index: 2; }
.cs-swipe-left { left: 12px; color: #ef4444; background: rgba(239,68,68,0.15); }
.cs-swipe-right { right: 12px; color: #22c55e; background: rgba(34,197,94,0.15); }
.cs-swipe-indicator.show { opacity: 1; }
.cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; margin-top: 1.25rem; }
.cs-btn-skip, .cs-btn-approve { width: 52px; height: 52px; border-radius: 50%; border: 2px solid; font-size: 1.3rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: var(--rs-bg-surface); font-family: inherit; }
.cs-btn-skip { border-color: #ef4444; color: #ef4444; }
.cs-btn-skip:hover { background: rgba(239,68,68,0.15); }
.cs-btn-approve { border-color: #22c55e; color: #22c55e; }
.cs-btn-approve:hover { background: rgba(34,197,94,0.15); }
.cs-btn-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; font-family: inherit; transition: all 0.15s; margin-top: 0.75rem; }
.cs-btn-reset:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
:host { padding: 1rem; }
.rapp-nav { gap: 4px; }
.create-btn { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
.rank-item { padding: 0.625rem 0.75rem; gap: 8px; }
.rank-name { font-size: 0.875rem; }
.vote-option { padding: 0.625rem 0.75rem; }
.spider-svg { max-width: 300px; }
.cs-card { padding: 1.25rem; border-radius: 12px; }
.cs-card-option { font-size: 1.1rem; }
.cs-btn-skip, .cs-btn-approve { width: 46px; height: 46px; font-size: 1.1rem; }
}
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Choices</span>
<span class="demo-badge">DEMO</span>
<button style="margin-left:auto;padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;font-family:inherit;" id="btn-tour">Tour</button>
</div>
<div class="demo-content">
${content}
</div>
`;
this.bindDemoEvents();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
/* -- 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>`;
}
/* -- CrowdSurf helpers -- */
private static mulberry32(seed: number): () => number {
let s = seed | 0;
return () => {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
private static hashString(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return hash >>> 0;
}
private buildCrowdSurfOptions() {
const myDid = getMyDid() || 'anon';
const lsKey = `cs_swiped:${this.space}:${myDid}`;
// Restore persisted swipes
try {
const saved = localStorage.getItem(lsKey);
if (saved) {
const entries: [string, 'right' | 'left'][] = JSON.parse(saved);
this.csSwipedMap = new Map(entries);
}
} catch { /* ignore */ }
// Build pool from live sessions or demo data
let pool: CrowdSurfOption[] = [];
// Try live sessions first
const openSessions = this.sessions.filter(s => !s.closed);
for (const session of openSessions) {
const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote';
for (const opt of session.options) {
pool.push({
optionId: opt.id,
label: opt.label,
color: opt.color,
sessionId: session.id,
sessionTitle: session.title,
sessionType: sType,
});
}
}
// Demo mode fallback
if (this.space === 'demo' && pool.length === 0) {
for (const opt of this.voteOptions) {
pool.push({
optionId: opt.id,
label: opt.name,
color: opt.color,
sessionId: 'demo-vote',
sessionTitle: 'Movie Night',
sessionType: 'vote',
});
}
for (const item of this.rankItems) {
pool.push({
optionId: String(item.id),
label: item.name,
color: '#f59e0b',
sessionId: 'demo-rank',
sessionTitle: 'Lunch Spot',
sessionType: 'rank',
});
}
}
// Filter already swiped
pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`));
// Seeded shuffle (Fisher-Yates)
const today = new Date().toISOString().slice(0, 10);
const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`);
const rng = FolkChoicesDashboard.mulberry32(seed);
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
this.csOptions = pool.slice(0, Math.min(10, pool.length));
this.csCurrentIndex = 0;
}
private renderCrowdSurf(): string {
// Build options if empty or all consumed
if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) {
this.buildCrowdSurfOptions();
}
// No options at all
if (this.csOptions.length === 0) {
return `<div class="cs-inline">
<div style="text-align:center;padding:2rem 0;">
<div style="font-size:2.5rem;margin-bottom:0.75rem;">🏄</div>
<p style="color:var(--rs-text-secondary);margin:0 0 1rem;">No open polls to surf yet.</p>
<p style="color:var(--rs-text-muted);font-size:0.8rem;margin:0 0 1rem;">Create a poll in the Voting tab, then come back to swipe!</p>
${this.csSwipedMap.size > 0 ? `<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>` : ''}
</div>
</div>`;
}
// All swiped — show summary
if (this.csCurrentIndex >= this.csOptions.length) {
return this.renderCrowdSurfSummary();
}
// Active card
const opt = this.csOptions[this.csCurrentIndex];
const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length;
const typeBadgeColors: Record<string, string> = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' };
const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6';
return `<div class="cs-inline">
<div class="cs-progress-header">
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${this.csCurrentIndex + 1} of ${this.csOptions.length}</span>
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${approved} approved</span>
</div>
<div class="cs-card-stack">
<div class="cs-card" id="cs-inline-card">
<div class="cs-swipe-indicator cs-swipe-left">&#10007; Skip</div>
<div class="cs-swipe-indicator cs-swipe-right">&#10003; Approve</div>
<div class="cs-card-body">
<div class="cs-type-badge" style="background:${badgeColor}20;color:${badgeColor}">${opt.sessionType}</div>
<div class="cs-card-session">${this.esc(opt.sessionTitle)}</div>
<div class="cs-card-option">
<span class="cs-color-dot" style="background:${opt.color}"></span>
${this.esc(opt.label)}
</div>
</div>
</div>
</div>
<div class="cs-swipe-buttons">
<button class="cs-btn-skip" data-cs-action="skip" title="Skip">&#10007;</button>
<button class="cs-btn-approve" data-cs-action="approve" title="Approve">&#10003;</button>
</div>
</div>`;
}
private renderCrowdSurfSummary(): string {
const approved: CrowdSurfOption[] = [];
this.csSwipedMap.forEach((dir, key) => {
if (dir !== 'right') return;
const [sessionId, optionId] = key.split(':');
const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId);
if (opt) approved.push(opt);
});
// Group by session
const grouped = new Map<string, CrowdSurfOption[]>();
for (const opt of approved) {
const list = grouped.get(opt.sessionId) || [];
list.push(opt);
grouped.set(opt.sessionId, list);
}
let groupHtml = '';
grouped.forEach((opts) => {
const title = opts[0].sessionTitle;
const items = opts.map(o =>
`<div style="display:flex;align-items:center;gap:6px;padding:4px 0;">
<span class="cs-color-dot" style="background:${o.color}"></span>
<span style="color:var(--rs-text-primary);font-size:0.9rem;">${this.esc(o.label)}</span>
</div>`
).join('');
groupHtml += `<div style="margin-bottom:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rs-text-muted);margin-bottom:4px;">${this.esc(title)}</div>
${items}
</div>`;
});
return `<div class="cs-inline">
<div style="text-align:center;padding:1.5rem 0;">
<div style="font-size:2rem;margin-bottom:0.5rem;">&#10003;</div>
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;font-size:1.1rem;">All done!</h3>
<p style="color:var(--rs-text-secondary);margin:0 0 1.25rem;font-size:0.85rem;">
You approved ${approved.length} of ${this.csSwipedMap.size} options
</p>
${groupHtml || `<p style="color:var(--rs-text-muted);font-size:0.85rem;">No approvals this round.</p>`}
<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>
</div>
</div>`;
}
private setupCrowdSurfSwipe() {
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
if (!card) return;
// Clear any pending transition timer from previous card
if (this._csTransitionTimer !== null) {
clearTimeout(this._csTransitionTimer);
this._csTransitionTimer = null;
}
const handleStart = (clientX: number) => {
if (this.csIsAnimating) return;
// Clear any lingering transition
if (this._csTransitionTimer !== null) {
clearTimeout(this._csTransitionTimer);
this._csTransitionTimer = null;
}
card.style.transition = '';
this.csStartX = clientX;
this.csCurrentX = clientX;
this.csIsDragging = true;
};
const handleMove = (clientX: number) => {
if (!this.csIsDragging || this.csIsAnimating) return;
this.csCurrentX = clientX;
const diffX = this.csCurrentX - this.csStartX;
const rotation = diffX * 0.1;
card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`;
const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement;
const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement;
if (diffX < -50) {
leftInd?.classList.add('show');
rightInd?.classList.remove('show');
} else if (diffX > 50) {
rightInd?.classList.add('show');
leftInd?.classList.remove('show');
} else {
leftInd?.classList.remove('show');
rightInd?.classList.remove('show');
}
};
const handleEnd = () => {
if (!this.csIsDragging || this.csIsAnimating) return;
this.csIsDragging = false;
const diffX = this.csCurrentX - this.csStartX;
card.querySelector('.cs-swipe-left')?.classList.remove('show');
card.querySelector('.cs-swipe-right')?.classList.remove('show');
if (Math.abs(diffX) > 100) {
const direction = diffX > 0 ? 1 : -1;
this.csIsAnimating = true;
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`;
card.style.opacity = '0';
this._csTransitionTimer = window.setTimeout(() => {
card.style.transform = '';
card.style.opacity = '';
card.style.transition = '';
this._csTransitionTimer = null;
this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left');
}, 300);
} else {
card.style.transition = 'transform 0.2s ease-out';
card.style.transform = '';
this._csTransitionTimer = window.setTimeout(() => {
card.style.transition = '';
this._csTransitionTimer = null;
}, 200);
}
};
card.addEventListener('pointerdown', (e: PointerEvent) => {
if (e.button !== 0) return;
card.setPointerCapture(e.pointerId);
card.style.touchAction = 'none';
handleStart(e.clientX);
});
card.addEventListener('pointermove', (e: PointerEvent) => {
e.preventDefault();
handleMove(e.clientX);
});
card.addEventListener('pointerup', () => handleEnd());
card.addEventListener('pointercancel', () => {
this.csIsDragging = false;
card.style.transform = '';
card.style.transition = '';
card.style.opacity = '';
card.style.touchAction = '';
});
}
private handleCrowdSurfSwipe(direction: 'right' | 'left') {
this.csIsAnimating = false;
if (this.csCurrentIndex >= this.csOptions.length) return;
const opt = this.csOptions[this.csCurrentIndex];
const swipeKey = `${opt.sessionId}:${opt.optionId}`;
this.csSwipedMap.set(swipeKey, direction);
// Persist to localStorage
const myDid = getMyDid() || 'anon';
const lsKey = `cs_swiped:${this.space}:${myDid}`;
try {
localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries())));
} catch { /* quota */ }
// Cast vote on right swipe (live mode)
if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') {
const did = getMyDid();
if (did) {
const existing = this.lfClient.getMyVote(opt.sessionId, did);
const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 };
this.lfClient.castVote(opt.sessionId, did, newChoices);
}
}
this.csCurrentIndex++;
this.renderDemo();
this.bindDemoEvents();
}
/* -- Demo event binding -- */
private bindDemoEvents() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Spider legend hover
this.shadow.querySelectorAll<HTMLElement>(".spider-legend-item").forEach((el) => {
el.addEventListener("pointerenter", () => {
this.hoveredPerson = el.dataset.person || null;
this.renderDemo();
});
el.addEventListener("pointerleave", () => {
this.hoveredPerson = null;
this.renderDemo();
});
el.addEventListener("click", () => {
this.hoveredPerson = this.hoveredPerson === (el.dataset.person || null) ? null : (el.dataset.person || null);
this.renderDemo();
});
});
// Ranking drag-and-drop (pointer events — works with touch, pen, mouse)
const rankList = this.shadow.querySelector(".rank-list");
if (rankList) {
const items = rankList.querySelectorAll<HTMLLIElement>(".rank-item");
items.forEach((li) => {
li.removeAttribute("draggable");
li.addEventListener("pointerdown", (e: PointerEvent) => {
if (e.button !== 0) return;
const id = parseInt(li.dataset.rankId || "0", 10);
this.rankDragging = id;
li.classList.add("dragging");
li.setPointerCapture(e.pointerId);
li.style.touchAction = "none";
});
li.addEventListener("pointermove", (e: PointerEvent) => {
if (this.rankDragging === null) return;
e.preventDefault();
// Find target item under pointer
const allItems = rankList.querySelectorAll<HTMLLIElement>(".rank-item");
allItems.forEach(item => item.classList.remove("drag-over"));
const target = this.shadow.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
const targetLi = target?.closest?.(".rank-item") as HTMLLIElement | null;
if (targetLi && targetLi !== li) targetLi.classList.add("drag-over");
});
li.addEventListener("pointerup", (e: PointerEvent) => {
if (this.rankDragging === null) return;
const allItems = rankList.querySelectorAll<HTMLLIElement>(".rank-item");
allItems.forEach(item => { item.classList.remove("drag-over"); item.classList.remove("dragging"); });
li.style.touchAction = "";
const target = this.shadow.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
const targetLi = target?.closest?.(".rank-item") as HTMLLIElement | null;
if (targetLi) {
const targetId = parseInt(targetLi.dataset.rankId || "0", 10);
if (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.rankDragging = null;
this.renderDemo();
});
li.addEventListener("pointercancel", () => {
const allItems = rankList.querySelectorAll<HTMLLIElement>(".rank-item");
allItems.forEach(item => { item.classList.remove("drag-over"); item.classList.remove("dragging"); });
li.style.touchAction = "";
this.rankDragging = null;
});
});
}
// 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();
});
}
// CrowdSurf swipe + buttons
this.setupCrowdSurfSwipe();
this.shadow.querySelector('[data-cs-action="skip"]')?.addEventListener('click', () => {
if (this.csIsAnimating) return;
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
if (card) {
this.csIsAnimating = true;
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
card.style.transform = 'translateX(-500px) rotate(-30deg)';
card.style.opacity = '0';
setTimeout(() => this.handleCrowdSurfSwipe('left'), 300);
} else {
this.handleCrowdSurfSwipe('left');
}
});
this.shadow.querySelector('[data-cs-action="approve"]')?.addEventListener('click', () => {
if (this.csIsAnimating) return;
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
if (card) {
this.csIsAnimating = true;
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
card.style.transform = 'translateX(500px) rotate(30deg)';
card.style.opacity = '0';
setTimeout(() => this.handleCrowdSurfSwipe('right'), 300);
} else {
this.handleCrowdSurfSwipe('right');
}
});
this.shadow.querySelector('[data-cs-action="reset"]')?.addEventListener('click', () => {
const myDid = getMyDid() || 'anon';
const lsKey = `cs_swiped:${this.space}:${myDid}`;
localStorage.removeItem(lsKey);
this.csSwipedMap.clear();
this.csOptions = [];
this.csCurrentIndex = 0;
this.renderDemo();
this.bindDemoEvents();
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);