/** * — 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 = new Map(); /* CrowdSurf inline state */ private csOptions: CrowdSurfOption[] = []; private csCurrentIndex = 0; private csSwipedMap: Map = 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 = { "folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-spider": "🕸", }; const typeLabels: Record = { "folk-choice-vote": "Poll", "folk-choice-rank": "Ranking", "folk-choice-spider": "Spider Chart", }; const isLive = this.lfClient?.isConnected ?? false; this.shadow.innerHTML = `
Choices ${isLive ? `LIVE` : ''}
${this.lfClient ? `` : ''} + Canvas
${this.loading ? `
Loading...
` : this.renderLiveContent(typeIcons, typeLabels)} `; this.bindLiveEvents(); } private renderLiveContent(typeIcons: Record, typeLabels: Record): 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 += ``; html += this.renderSessionsList(); } // Canvas choices if (this.choices.length > 0) { if (this.sessions.length > 0) html += `
`; html += ``; 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 `
${status} ${this.esc(session.title)}
${session.options.length} options ${votes.length} vote${votes.length !== 1 ? 's' : ''} ${timeAgo}
`; }).join(''); return `
${cards}
`; } 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 = {}; 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 `
${this.esc(opt.label)} ${isMyVote ? `` : ''} ${count} ${pct.toFixed(0)}%
`; }).join(''); const isOwner = myDid && session.createdBy === myDid; return `
${this.esc(session.title)} ${session.closed ? `closed` : `open`}
${!session.closed && !myVote ? `
Tap an option to vote
` : ''} ${bars}
${totalVotes} vote${totalVotes !== 1 ? 's' : ''} total
${isOwner ? `
${!session.closed ? `` : ''}
` : ''}
`; } private bindLiveEvents() { // New session button this.shadow.querySelector('[data-action="new-session"]')?.addEventListener('click', () => this.createSession()); // Session card clicks this.shadow.querySelectorAll('.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('.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 `

No choices in this space yet.

Open the canvas and use the Poll, Rank, or Spider buttons to create one.

`; } private renderGrid(icons: Record, labels: Record): string { return ``; } /* ===== 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 = `
Choices DEMO
${content}
`; 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 += ``; } // 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 += ``; const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle); axisLines += `${this.esc(label)}`; }); // 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 += ``; // 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 += ``; }); }); const legend = people.map((p) => `
${this.esc(p.name)}
` ).join(""); return `
${gridLines} ${axisLines} ${polygons}
${legend}
`; } /* -- 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) => `
  • ${i + 1} ${item.emoji} ${this.esc(item.name)}
  • ` ).join(""); return `
      ${items}
    `; } /* -- 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 `
    ${this.esc(opt.name)}${isLeader ? `leading` : ""} ${opt.votes} ${pct.toFixed(0)}%
    `; }).join(""); const status = this.voted ? "Results are in!" : "Pick a movie \u2014 votes update live"; return `
    ${status}
    ${items} ${this.voted ? `
    ` : ""}
    `; } /* -- 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 `
    🏄

    No open polls to surf yet.

    Create a poll in the Voting tab, then come back to swipe!

    ${this.csSwipedMap.size > 0 ? `` : ''}
    `; } // 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 = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' }; const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6'; return `
    ${this.csCurrentIndex + 1} of ${this.csOptions.length} ${approved} approved
    ✗ Skip
    ✓ Approve
    ${opt.sessionType}
    ${this.esc(opt.sessionTitle)}
    ${this.esc(opt.label)}
    `; } 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(); 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 => `
    ${this.esc(o.label)}
    ` ).join(''); groupHtml += `
    ${this.esc(title)}
    ${items}
    `; }); return `

    All done!

    You approved ${approved.length} of ${this.csSwipedMap.size} options

    ${groupHtml || `

    No approvals this round.

    `}
    `; } 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(".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(".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(".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(".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(".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(".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(".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);