1239 lines
51 KiB
TypeScript
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">← 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">✗ Skip</div>
|
|
<div class="cs-swipe-indicator cs-swipe-right">✓ 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">✗</button>
|
|
<button class="cs-btn-approve" data-cs-action="approve" title="Approve">✓</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;">✓</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);
|