874 lines
38 KiB
TypeScript
874 lines
38 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";
|
|
|
|
// ── 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();
|
|
|
|
// Guided tour
|
|
private _tour!: TourEngine;
|
|
private static readonly TOUR_STEPS = [
|
|
{ target: '[data-tab="spider"]', title: "Spider Charts", message: "Compare multiple criteria on a radar chart. Each participant's scores overlay in real time.", advanceOnClick: true },
|
|
{ target: '[data-tab="ranking"]', title: "Rankings", message: "Drag items to rank them. Rankings aggregate across all participants for a collective order.", advanceOnClick: true },
|
|
{ target: '[data-tab="voting"]', title: "Voting", message: "Cast your vote and watch results update live with animated bars and totals.", advanceOnClick: true },
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
this.space = this.getAttribute("space") || "demo";
|
|
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;
|
|
}
|
|
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() {
|
|
const tabs: { key: "spider" | "ranking" | "voting" | "crowdsurf"; label: string; icon: string }[] = [
|
|
{ key: "spider", label: "Spider Chart", icon: "🕸" },
|
|
{ key: "ranking", label: "Ranking", icon: "📊" },
|
|
{ key: "voting", label: "Live Voting", icon: "☑" },
|
|
{ key: "crowdsurf", label: "CrowdSurf", icon: "🏄" },
|
|
];
|
|
|
|
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; }
|
|
|
|
/* Tabs */
|
|
.demo-tabs { display: flex; gap: 4px; margin-bottom: 1.5rem; margin-top: 4px; background: var(--rs-bg-page); border-radius: 10px; padding: 4px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
.demo-tab { flex: 1 1 0; min-width: 0; text-align: center; padding: 0.5rem 0.5rem; border-radius: 8px; border: none; background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.8rem; font-family: inherit; transition: all 0.15s; white-space: nowrap; }
|
|
.demo-tab:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface); }
|
|
.demo-tab.active { background: var(--rs-bg-surface); color: var(--rs-text-primary); box-shadow: var(--rs-shadow-sm); }
|
|
.demo-tab-icon { margin-right: 4px; }
|
|
.demo-tab-label { }
|
|
@media (max-width: 480px) { .demo-tab-label { display: none; } .demo-tab-icon { margin-right: 0; } .demo-tab { flex: 0 0 auto; padding: 0.5rem 0.75rem; } }
|
|
|
|
/* 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); }
|
|
|
|
@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; }
|
|
.demo-tabs { gap: 2px; padding: 3px; }
|
|
.demo-tab { padding: 0.5rem; font-size: 0.8125rem; }
|
|
.demo-tab-label { display: none; }
|
|
.demo-tab-icon { margin-right: 0; font-size: 1.1rem; }
|
|
.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; }
|
|
}
|
|
</style>
|
|
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">Choices</span>
|
|
<span class="demo-badge">DEMO</span>
|
|
<button class="demo-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
|
|
</div>
|
|
|
|
<div class="demo-tabs">
|
|
${tabs.map((t) => `<button class="demo-tab${this.demoTab === t.key ? " active" : ""}" data-tab="${t.key}"><span class="demo-tab-icon">${t.icon}</span><span class="demo-tab-label">${this.esc(t.label)}</span></button>`).join("")}
|
|
</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>`;
|
|
}
|
|
|
|
private renderCrowdSurf(): string {
|
|
return `<div style="text-align:center;padding:2rem 1rem;">
|
|
<div style="font-size:3rem;margin-bottom:0.75rem;">🏄</div>
|
|
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;">CrowdSurf</h3>
|
|
<p style="color:var(--rs-text-secondary);margin:0 0 1.5rem;max-width:360px;margin-inline:auto;">
|
|
Swipe-based community coordination. Propose activities, set commitment thresholds, and watch them trigger when enough people join.
|
|
</p>
|
|
<a href="/${this.space}/crowdsurf" style="display:inline-block;padding:0.6rem 1.5rem;background:var(--rs-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.9rem;">Open CrowdSurf</a>
|
|
</div>`;
|
|
}
|
|
|
|
/* -- Demo event binding -- */
|
|
|
|
private bindDemoEvents() {
|
|
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
|
// Tab switching
|
|
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const tab = btn.dataset.tab as "spider" | "ranking" | "voting" | "crowdsurf";
|
|
if (tab && tab !== this.demoTab) {
|
|
this.demoTab = tab;
|
|
this.renderDemo();
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);
|