diff --git a/modules/crowdsurf/components/crowdsurf.css b/modules/crowdsurf/components/crowdsurf.css new file mode 100644 index 0000000..c2f073a --- /dev/null +++ b/modules/crowdsurf/components/crowdsurf.css @@ -0,0 +1,5 @@ +/* CrowdSurf module layout */ +main { + min-height: calc(100vh - 56px); + padding: 0; +} diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts new file mode 100644 index 0000000..aaef262 --- /dev/null +++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts @@ -0,0 +1,1015 @@ +/** + * — Tinder-style swipe UI for community activity proposals. + * + * Three views: Discover (swipe cards), Create (new prompt form), Profile (stats). + * Multiplayer: uses CrowdSurfLocalFirstClient for real-time sync via Automerge. + */ + +import { CrowdSurfLocalFirstClient } from '../local-first-client'; +import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; +import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } 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; +} + +type ViewTab = 'discover' | 'create' | 'rank' | 'profile'; + +class FolkCrowdSurfDashboard extends HTMLElement { + private shadow: ShadowRoot; + private space: string; + + // State + private activeTab: ViewTab = 'discover'; + private loading = true; + private prompts: CrowdSurfPrompt[] = []; + private currentPromptIndex = 0; + + // Swipe state + private isDragging = false; + private startX = 0; + private currentX = 0; + private isAnimating = false; + + // Create form state + private contributionSuggestions: string[] = []; + + // Profile stats + private stats = { joined: 0, created: 0, triggered: 0 }; + + // Rank (pairwise Elo) state + private rankPairA: CrowdSurfPrompt | null = null; + private rankPairB: CrowdSurfPrompt | null = null; + private rankLoading = false; + private rankSessionCount = 0; + private rankLastResult: { winnerId: string; delta: number } | null = null; + + // Multiplayer + private lfClient: CrowdSurfLocalFirstClient | null = null; + private _lfcUnsub: (() => void) | null = null; + + // Expiry timer + private _expiryTimer: number | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + this.space = this.getAttribute('space') || 'demo'; + } + + connectedCallback() { + if (this.space === 'demo') { + this.loadDemoData(); + } else { + this.initMultiplayer(); + } + } + + disconnectedCallback() { + this._lfcUnsub?.(); + this._lfcUnsub = null; + this.lfClient?.disconnect(); + if (this._expiryTimer !== null) clearInterval(this._expiryTimer); + } + + // ── Multiplayer init ── + + private async initMultiplayer() { + this.loading = true; + this.render(); + + try { + this.lfClient = new CrowdSurfLocalFirstClient(this.space); + await this.lfClient.init(); + await this.lfClient.subscribe(); + + this._lfcUnsub = this.lfClient.onChange((doc) => { + this.extractPrompts(doc); + this.render(); + this.bindEvents(); + }); + + const doc = this.lfClient.getDoc(); + if (doc) this.extractPrompts(doc); + } catch (err) { + console.warn('[CrowdSurf] Local-first init failed:', err); + } + + this.loading = false; + this.render(); + this.bindEvents(); + + // Check expiry every 30s + this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000); + } + + private extractPrompts(doc: CrowdSurfDoc) { + const myDid = getMyDid(); + const all = doc.prompts ? Object.values(doc.prompts) : []; + + // Sort: active first (by creation, newest first), then triggered, then expired + this.prompts = all.sort((a, b) => { + if (a.expired !== b.expired) return a.expired ? 1 : -1; + if (a.triggered !== b.triggered) return a.triggered ? 1 : -1; + return b.createdAt - a.createdAt; + }); + + // Compute profile stats + if (myDid) { + this.stats.created = all.filter(p => p.createdBy === myDid).length; + this.stats.joined = all.filter(p => p.swipes[myDid]?.direction === 'right').length; + this.stats.triggered = all.filter(p => p.triggered).length; + } + } + + private checkExpiry() { + if (!this.lfClient) return; + const doc = this.lfClient.getDoc(); + if (!doc?.prompts) return; + for (const prompt of Object.values(doc.prompts)) { + if (!prompt.expired && getDecayProgress(prompt) >= 1) { + this.lfClient.markExpired(prompt.id); + } + } + } + + // ── Demo data ── + + private loadDemoData() { + const now = Date.now(); + this.prompts = [ + { + id: 'demo-1', text: 'Community Garden Planting Day', location: 'Tempelhof Field, Berlin', + threshold: 5, duration: 4, activityDuration: '3 hours', createdAt: now - 3600000, + createdBy: null, triggered: false, expired: false, + swipes: { 'alice': { direction: 'right', timestamp: now - 2000000, contribution: { bringing: ['seedlings', 'gardening gloves'], needed: ['watering cans'], tags: ['food'], value: 10 } }, 'bob': { direction: 'right', timestamp: now - 1800000 }, 'carol': { direction: 'right', timestamp: now - 1500000 } }, + elo: 1532, comparisons: 6, wins: 4, + }, + { + id: 'demo-2', text: 'Open Mic & Jam Session', location: 'Klunkerkranich rooftop', + threshold: 8, duration: 6, activityDuration: '2 hours', createdAt: now - 7200000, + createdBy: null, triggered: false, expired: false, + swipes: { 'dave': { direction: 'right', timestamp: now - 5000000, contribution: { bringing: ['guitar', 'amp'], needed: ['microphone'], tags: ['music'], value: 15 } }, 'eve': { direction: 'right', timestamp: now - 4000000 }, 'frank': { direction: 'right', timestamp: now - 3500000 }, 'grace': { direction: 'right', timestamp: now - 3000000 }, 'hank': { direction: 'right', timestamp: now - 2500000 }, 'iris': { direction: 'right', timestamp: now - 2000000 } }, + elo: 1568, comparisons: 8, wins: 6, + }, + { + id: 'demo-3', text: 'Repair Cafe — Fix Your Stuff!', location: 'Maker Space, Kreuzberg', + threshold: 3, duration: 2, activityDuration: '4 hours', createdAt: now - 600000, + createdBy: null, triggered: false, expired: false, + swipes: { 'jack': { direction: 'right', timestamp: now - 400000, contribution: { bringing: ['soldering iron', 'electronics skills'], needed: ['broken gadgets'], tags: ['tech'], value: 15 } } }, + elo: 1485, comparisons: 4, wins: 1, + }, + { + id: 'demo-4', text: 'Sunrise Yoga by the Canal', location: 'Landwehr Canal', + threshold: 4, duration: 8, activityDuration: '1 hour', createdAt: now - 1000000, + createdBy: null, triggered: false, expired: false, + swipes: {}, + elo: 1415, comparisons: 6, wins: 1, + }, + ]; + this.loading = false; + this.render(); + this.bindEvents(); + } + + // ── Swipe mechanics ── + + private getActivePrompts(): CrowdSurfPrompt[] { + const myDid = getMyDid(); + return this.prompts.filter(p => + !p.expired && !p.triggered && (this.space === 'demo' || !myDid || !p.swipes[myDid]) + ); + } + + private getCurrentPrompt(): CrowdSurfPrompt | null { + const active = this.getActivePrompts(); + if (this.currentPromptIndex >= active.length) this.currentPromptIndex = 0; + return active[this.currentPromptIndex] ?? null; + } + + private handleSwipeEnd(diffX: number) { + if (Math.abs(diffX) < 100) return; // Below threshold + const prompt = this.getCurrentPrompt(); + if (!prompt) return; + + if (diffX > 0) { + this.performSwipe(prompt, 'right'); + } else { + this.performSwipe(prompt, 'left'); + } + } + + private performSwipe(prompt: CrowdSurfPrompt, direction: 'right' | 'left') { + const myDid = getMyDid(); + + if (this.space === 'demo') { + // Demo mode: just advance + if (direction === 'right') { + prompt.swipes['demo-user'] = { direction: 'right', timestamp: Date.now() }; + this.stats.joined++; + if (getRightSwipeCount(prompt) >= prompt.threshold) { + prompt.triggered = true; + this.stats.triggered++; + } + } + this.currentPromptIndex++; + this.isAnimating = true; + setTimeout(() => { + this.isAnimating = false; + this.render(); + this.bindEvents(); + }, 300); + return; + } + + if (!myDid || !this.lfClient) return; + + // TODO: show contribution modal on right-swipe before committing + this.lfClient.swipe(prompt.id, myDid, direction); + this.currentPromptIndex++; + this.isAnimating = true; + setTimeout(() => { + this.isAnimating = false; + this.render(); + this.bindEvents(); + }, 300); + } + + // ── Create prompt ── + + private handleCreate() { + const getText = (id: string) => (this.shadow.getElementById(id) as HTMLInputElement | HTMLTextAreaElement)?.value?.trim() ?? ''; + const getNum = (id: string) => parseInt((this.shadow.getElementById(id) as HTMLInputElement)?.value ?? '0', 10); + + const text = getText('cs-text'); + const location = getText('cs-location'); + const threshold = getNum('cs-threshold') || 3; + const duration = getNum('cs-duration') || 4; + const activityDuration = getText('cs-activity-duration') || '1 hour'; + const bringing = getText('cs-bringing'); + const needed = getText('cs-needed'); + + if (!text || !location) return; + + const prompt: CrowdSurfPrompt = { + id: crypto.randomUUID(), + text, + location, + threshold, + duration, + activityDuration, + createdAt: Date.now(), + createdBy: getMyDid(), + triggered: false, + expired: false, + swipes: {}, + elo: 1500, + comparisons: 0, + wins: 0, + }; + + // Add creator's contribution as a right-swipe + const myDid = getMyDid(); + if (myDid && (bringing || needed)) { + const contribution = parseContributions(bringing, needed); + prompt.swipes[myDid] = { + direction: 'right', + timestamp: Date.now(), + contribution, + }; + } + + if (this.space === 'demo') { + this.prompts.unshift(prompt); + this.stats.created++; + } else if (this.lfClient) { + this.lfClient.createPrompt(prompt); + } + + this.activeTab = 'discover'; + this.render(); + this.bindEvents(); + } + + // ── Render ── + + private render() { + const isLive = this.lfClient?.isConnected ?? false; + + this.shadow.innerHTML = ` + + +
+ ${this.loading ? '
Loading...
' : this.renderActiveView(isLive)} + + + +
`; + } + + private renderActiveView(isLive: boolean): string { + switch (this.activeTab) { + case 'discover': return this.renderDiscover(isLive); + case 'create': return this.renderCreateForm(); + case 'rank': return this.renderRank(); + case 'profile': return this.renderProfile(); + } + } + + // ── Discover view (swipe cards) ── + + private renderDiscover(isLive: boolean): string { + const prompt = this.getCurrentPrompt(); + const activeCount = this.getActivePrompts().length; + const triggeredPrompts = this.prompts.filter(p => p.triggered); + + return ` +
+ CrowdSurf + ${isLive ? 'LIVE' : ''} + ${this.space === 'demo' ? 'DEMO' : ''} +
+ +
+ ${prompt ? this.renderCard(prompt, activeCount) : this.renderNoCards()} + + ${triggeredPrompts.length > 0 ? ` + +
+ ${triggeredPrompts.map(p => ` +
+ 🚀 +
+
${this.esc(p.text)}
+
📍 ${this.esc(p.location)} · ${getRightSwipeCount(p)} people
+
+
+ `).join('')} +
+ ` : ''} +
`; + } + + private renderCard(prompt: CrowdSurfPrompt, totalActive: number): string { + const rightSwipes = getRightSwipeCount(prompt); + const progressPct = Math.min((rightSwipes / prompt.threshold) * 100, 100); + const urgency = getUrgency(prompt); + const timeLeft = getTimeRemaining(prompt); + + // Collect contributions + const allContribs = Object.values(prompt.swipes) + .filter(s => s.direction === 'right' && s.contribution) + .map(s => s.contribution!); + + const bringingAll = allContribs.flatMap(c => c.bringing); + const neededAll = allContribs.flatMap(c => c.needed); + + return ` +
+
+
✗ Pass
+
✓ Join
+ +
+
${this.esc(prompt.text)}
+ ${(prompt.elo ?? 0) > 0 ? `⚡ ${prompt.elo ?? 1500}` : ''} + +
📍 ${this.esc(prompt.location)}
+ ${prompt.activityDuration ? `
⏱️ ${this.esc(prompt.activityDuration)}
` : ''} + + ${bringingAll.length > 0 ? ` +
+
🎒 People are bringing:
+
${bringingAll.map(b => `${this.esc(b)}`).join('')}
+
+ ` : ''} + ${neededAll.length > 0 ? ` +
+
✨ Still needed:
+
${neededAll.map(n => `${this.esc(n)}`).join('')}
+
+ ` : ''} + + + + ${isReadyToTrigger(prompt) ? '
🚀 Group ready! Activity happening!
' : ''} +
+
+
${this.currentPromptIndex + 1} / ${totalActive}
+
+ +
+ + +
`; + } + + private renderNoCards(): string { + return ` +
+
🏄
+

No activities to discover right now.

+

Create one and get the wave started!

+ +
`; + } + + // ── Create form ── + + private renderCreateForm(): string { + return ` +
+ New Activity +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + + + +
+ + + + + + + + +
`; + } + + // ── Profile view ── + + private renderProfile(): string { + const allTriggered = this.prompts.filter(p => p.triggered); + const totalParticipants = this.prompts.reduce((sum, p) => sum + getRightSwipeCount(p), 0); + + return ` +
+ Profile +
+
+
+
+
${this.stats.joined}
+
Joined
+
+
+
${this.stats.created}
+
Created
+
+
+
${this.stats.triggered}
+
Triggered
+
+
+ + +
+
Active prompts${this.prompts.filter(p => !p.expired && !p.triggered).length}
+
Total triggered${allTriggered.length}
+
Total participants${totalParticipants}
+
+
`; + } + + // ── Rank (pairwise Elo) view ── + + private renderRank(): string { + const activePrompts = this.prompts.filter(p => !p.expired && !p.triggered); + const leaderboard = [...activePrompts].sort((a, b) => (b.elo ?? 1500) - (a.elo ?? 1500)); + + const pairHtml = this.rankLoading + ? '
Loading pair...
' + : (this.rankPairA && this.rankPairB) + ? `
+
+
${this.esc(this.rankPairA.text)}
+
📍 ${this.esc(this.rankPairA.location)}
+
⚡ ${this.rankPairA.elo ?? 1500}
+
+
VS
+
+
${this.esc(this.rankPairB.text)}
+
📍 ${this.esc(this.rankPairB.location)}
+
⚡ ${this.rankPairB.elo ?? 1500}
+
+
+
+ + ${this.rankSessionCount} comparisons +
` + : (activePrompts.length < 2 + ? '
Need at least 2 active prompts to rank.
' + : '
'); + + return ` +
+ Rank Activities +
+
+

Tap the activity you think should be higher priority.

+ ${pairHtml} + + ${leaderboard.length > 0 ? ` + +
+ ${leaderboard.map((p, i) => ` +
+ #${i + 1} + ${this.esc(p.text)} + ⚡ ${p.elo ?? 1500} + ${p.comparisons ?? 0} cmp +
+ `).join('')} +
+ ` : ''} +
`; + } + + private loadRankPair() { + const active = this.prompts.filter(p => !p.expired && !p.triggered); + if (active.length < 2) { this.rankPairA = null; this.rankPairB = null; return; } + + if (this.space !== 'demo') { + // Use API for live mode + this.rankLoading = true; + this.render(); + this.bindEvents(); + fetch(`/${this.space}/crowdsurf/api/crowdsurf/pair?space=${this.space}`) + .then(r => r.json()) + .then(data => { + if (data.a && data.b) { + this.rankPairA = data.a; + this.rankPairB = data.b; + } + }) + .catch(() => {}) + .finally(() => { + this.rankLoading = false; + this.render(); + this.bindEvents(); + }); + return; + } + + // Demo mode: sortition-weighted random pair + const maxComp = Math.max(...active.map(p => p.comparisons ?? 0), 1); + const weights = active.map(p => maxComp - (p.comparisons ?? 0) + 1); + const totalWeight = weights.reduce((a, b) => a + b, 0); + + const pickWeighted = (excludeId?: string): CrowdSurfPrompt => { + const effectiveWeight = excludeId ? totalWeight - (weights[active.findIndex(p => p.id === excludeId)] || 0) : totalWeight; + let r = Math.random() * effectiveWeight; + for (let i = 0; i < active.length; i++) { + if (active[i].id === excludeId) continue; + r -= weights[i]; + if (r <= 0) return active[i]; + } + return active[active.length - 1]; + }; + + this.rankPairA = pickWeighted(); + this.rankPairB = pickWeighted(this.rankPairA.id); + this.render(); + this.bindEvents(); + } + + private submitRankComparison(winnerId: string) { + const loserId = winnerId === this.rankPairA?.id ? this.rankPairB?.id : this.rankPairA?.id; + if (!loserId) return; + + if (this.space === 'demo') { + // Local Elo update + const winner = this.prompts.find(p => p.id === winnerId); + const loser = this.prompts.find(p => p.id === loserId); + if (!winner || !loser) return; + + const wElo = winner.elo ?? 1500; + const lElo = loser.elo ?? 1500; + const expected = 1 / (1 + Math.pow(10, (lElo - wElo) / 400)); + const delta = Math.round(32 * (1 - expected)); + + winner.elo = wElo + delta; + winner.comparisons = (winner.comparisons ?? 0) + 1; + winner.wins = (winner.wins ?? 0) + 1; + loser.elo = lElo - delta; + loser.comparisons = (loser.comparisons ?? 0) + 1; + + this.rankLastResult = { winnerId, delta }; + this.rankSessionCount++; + // Flash result then load next pair + this.render(); + this.bindEvents(); + setTimeout(() => { + this.rankLastResult = null; + this.loadRankPair(); + }, 600); + return; + } + + // Live mode: POST to API + fetch(`/${this.space}/crowdsurf/api/crowdsurf/compare?space=${this.space}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId, loserId }), + }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.rankLastResult = { winnerId, delta: data.winner.delta }; + this.rankSessionCount++; + this.render(); + this.bindEvents(); + setTimeout(() => { + this.rankLastResult = null; + this.loadRankPair(); + }, 600); + } + }) + .catch(console.error); + } + + // ── Event binding ── + + private bindEvents() { + // Tab navigation + this.shadow.querySelectorAll('.cs-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab as ViewTab; + if (tab && tab !== this.activeTab) { + this.activeTab = tab; + this.render(); + this.bindEvents(); + } + }); + }); + + // Swipe gesture on card + const card = this.shadow.getElementById('cs-current-card'); + if (card) this.setupSwipeGestures(card); + + // Swipe buttons + this.shadow.querySelector('[data-action="swipe-left"]')?.addEventListener('click', () => { + const prompt = this.getCurrentPrompt(); + if (prompt) this.performSwipe(prompt, 'left'); + }); + this.shadow.querySelector('[data-action="swipe-right"]')?.addEventListener('click', () => { + const prompt = this.getCurrentPrompt(); + if (prompt) this.performSwipe(prompt, 'right'); + }); + + // Go to create tab + this.shadow.querySelector('[data-action="go-create"]')?.addEventListener('click', () => { + this.activeTab = 'create'; + this.render(); + this.bindEvents(); + }); + + // Create form submit + this.shadow.querySelector('[data-action="create-prompt"]')?.addEventListener('click', () => this.handleCreate()); + + // Rank: pick a winner + this.shadow.querySelectorAll('[data-rank-pick]').forEach(card => { + card.addEventListener('click', () => { + const winnerId = card.dataset.rankPick; + if (winnerId) this.submitRankComparison(winnerId); + }); + }); + + // Rank: skip + this.shadow.querySelector('[data-action="rank-skip"]')?.addEventListener('click', () => this.loadRankPair()); + + // Rank: start + this.shadow.querySelector('[data-action="rank-start"]')?.addEventListener('click', () => this.loadRankPair()); + } + + private setupSwipeGestures(card: HTMLElement) { + const handleStart = (clientX: number) => { + if (this.isAnimating) return; + this.startX = clientX; + this.currentX = clientX; + this.isDragging = true; + }; + + const handleMove = (clientX: number) => { + if (!this.isDragging || this.isAnimating) return; + this.currentX = clientX; + const diffX = this.currentX - this.startX; + 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.isDragging || this.isAnimating) return; + this.isDragging = false; + const diffX = this.currentX - this.startX; + + card.querySelector('.cs-swipe-left')?.classList.remove('show'); + card.querySelector('.cs-swipe-right')?.classList.remove('show'); + + if (Math.abs(diffX) > 100) { + // Animate out + const direction = diffX > 0 ? 1 : -1; + 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.handleSwipeEnd(diffX); + } else { + card.style.transition = 'transform 0.2s ease-out'; + card.style.transform = ''; + setTimeout(() => { card.style.transition = ''; }, 200); + } + }; + + // Pointer events (unified touch + mouse) + 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.isDragging = false; + card.style.transform = ''; + card.style.touchAction = ''; + }); + } + + // ── Styles ── + + private getStyles(): string { + return ` + :host { display: block; height: 100%; -webkit-tap-highlight-color: transparent; } + button, a, input, select, textarea, [role="button"] { touch-action: manipulation; } + * { box-sizing: border-box; } + + .cs-app { + display: flex; flex-direction: column; height: 100%; + min-height: calc(100vh - 56px); + background: var(--rs-bg-page); + color: var(--rs-text-primary); + } + + .cs-loading { text-align: center; padding: 4rem; color: var(--rs-text-muted); flex: 1; display: flex; align-items: center; justify-content: center; } + + /* Header */ + .cs-header { display: flex; align-items: center; gap: 8px; padding: 1rem 1.25rem 0.5rem; } + .cs-title { font-size: 1.1rem; font-weight: 700; flex: 1; } + .cs-live { 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: 3px; } + .cs-live-dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; animation: cs-pulse 2s infinite; } + @keyframes cs-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } + .cs-demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: var(--rs-primary); color: #fff; font-weight: 500; } + + /* Discover */ + .cs-discover { flex: 1; display: flex; flex-direction: column; padding: 0.5rem 1.25rem 1rem; overflow-y: auto; } + + /* Card stack */ + .cs-card-stack { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 320px; position: relative; } + .cs-card { + width: 100%; max-width: 380px; + background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.15)); + border: 1px solid var(--rs-border); + border-radius: 16px; + padding: 1.5rem; + position: relative; + cursor: grab; + user-select: none; + transition: box-shadow 0.2s; + } + .cs-card:active { cursor: grabbing; } + .cs-card:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.2); } + .cs-card-count { font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.75rem; text-align: center; } + + /* Swipe indicators */ + .cs-swipe-indicator { + position: absolute; top: 1.5rem; + font-size: 1.25rem; font-weight: 800; + padding: 6px 16px; border-radius: 8px; + opacity: 0; transition: opacity 0.15s; + pointer-events: none; z-index: 2; + } + .cs-swipe-left { left: 1rem; color: #ef4444; border: 2px solid #ef4444; } + .cs-swipe-right { right: 1rem; color: #22c55e; border: 2px solid #22c55e; } + .cs-swipe-indicator.show { opacity: 1; } + + /* Card body */ + .cs-card-body { position: relative; z-index: 1; } + .cs-card-text { font-size: 1.2rem; font-weight: 700; margin-bottom: 1rem; line-height: 1.4; } + .cs-card-location { font-size: 0.9rem; margin-bottom: 0.5rem; opacity: 0.85; } + .cs-card-duration { font-size: 0.85rem; margin-bottom: 1rem; opacity: 0.7; background: rgba(255,255,255,0.08); display: inline-block; padding: 4px 10px; border-radius: 8px; } + + /* Contributions */ + .cs-contrib-section { margin-bottom: 0.75rem; } + .cs-contrib-label { font-size: 0.78rem; color: var(--rs-text-secondary); margin-bottom: 4px; } + .cs-contrib-tags { display: flex; flex-wrap: wrap; gap: 4px; } + .cs-tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 999px; background: rgba(94,234,212,0.12); color: #5eead4; } + .cs-tag.needed { background: rgba(251,191,36,0.12); color: #fbbf24; } + + /* Card footer */ + .cs-card-footer { margin-top: 0.75rem; } + .cs-pool { font-size: 0.85rem; font-weight: 600; margin-bottom: 6px; text-align: center; } + .cs-progress-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; position: relative; } + .cs-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #667eea, #764ba2); transition: width 0.5s ease-out; } + .cs-urgency-pulse { position: absolute; inset: 0; border-radius: 999px; background: rgba(239,68,68,0.3); animation: cs-urgency 1s infinite; } + @keyframes cs-urgency { 0%,100% { opacity: 0; } 50% { opacity: 1; } } + .cs-time { font-size: 0.78rem; text-align: center; margin-top: 6px; color: var(--rs-text-secondary); } + .cs-time.urgent { color: #ef4444; animation: cs-blink 1s infinite; } + @keyframes cs-blink { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } + .cs-trigger-msg { text-align: center; margin-top: 0.75rem; padding: 8px; border-radius: 8px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 600; font-size: 0.9rem; } + + /* Swipe buttons */ + .cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; padding: 1rem 0; } + .cs-btn-skip, .cs-btn-join { + width: 56px; height: 56px; border-radius: 50%; border: 2px solid; + font-size: 1.5rem; cursor: pointer; display: flex; align-items: center; justify-content: center; + background: var(--rs-bg-surface); transition: all 0.15s; font-family: inherit; + } + .cs-btn-skip { border-color: #ef4444; color: #ef4444; } + .cs-btn-skip:hover { background: rgba(239,68,68,0.15); } + .cs-btn-join { border-color: #22c55e; color: #22c55e; } + .cs-btn-join:hover { background: rgba(34,197,94,0.15); } + + /* Triggered list */ + .cs-section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-muted); margin: 1.25rem 0 0.5rem; font-weight: 600; } + .cs-triggered-list { display: flex; flex-direction: column; gap: 6px; } + .cs-triggered-card { display: flex; align-items: center; gap: 10px; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid rgba(34,197,94,0.2); border-radius: 10px; } + .cs-triggered-icon { font-size: 1.25rem; } + .cs-triggered-info { flex: 1; min-width: 0; } + .cs-triggered-title { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .cs-triggered-meta { font-size: 0.78rem; color: var(--rs-text-secondary); } + + /* Empty state */ + .cs-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: var(--rs-text-muted); padding: 2rem; } + .cs-empty-icon { font-size: 3rem; margin-bottom: 1rem; } + .cs-empty p { margin: 0.25rem 0; font-size: 0.9rem; } + .cs-btn-create { margin-top: 1rem; padding: 0.6rem 1.5rem; border-radius: 10px; border: 1px solid var(--rs-primary); background: var(--rs-primary); color: #fff; font-size: 0.9rem; cursor: pointer; font-family: inherit; } + + /* Create form */ + .cs-form { flex: 1; padding: 0.5rem 1.25rem 2rem; overflow-y: auto; } + .cs-label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--rs-text-secondary); margin: 1rem 0 0.35rem; } + .cs-label:first-child { margin-top: 0; } + .cs-input { width: 100%; padding: 0.6rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); font-size: 0.9rem; font-family: inherit; } + .cs-input:focus { outline: none; border-color: var(--rs-primary); } + .cs-textarea { resize: vertical; } + .cs-form-row { display: flex; gap: 0.75rem; } + .cs-form-col { flex: 1; } + .cs-divider { border: none; border-top: 1px solid var(--rs-border); margin: 1.25rem 0; height: 0; } + .cs-btn-submit { width: 100%; padding: 0.75rem; border-radius: 10px; border: none; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-top: 1.5rem; font-family: inherit; } + .cs-btn-submit:hover { opacity: 0.9; } + + /* Profile */ + .cs-profile { flex: 1; padding: 0.5rem 1.25rem 2rem; overflow-y: auto; } + .cs-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; } + .cs-stat-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; text-align: center; } + .cs-stat-num { font-size: 1.5rem; font-weight: 800; background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } + .cs-stat-label { font-size: 0.75rem; color: var(--rs-text-secondary); margin-top: 2px; } + .cs-community-stats { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; } + .cs-community-row { display: flex; justify-content: space-between; padding: 0.6rem 1rem; border-bottom: 1px solid var(--rs-border); font-size: 0.85rem; } + .cs-community-row:last-child { border-bottom: none; } + .cs-community-row span:last-child { font-weight: 600; } + + /* Elo badge on discover cards */ + .cs-elo-badge { display: inline-block; font-size: 0.75rem; padding: 2px 8px; border-radius: 999px; background: rgba(251,191,36,0.15); color: #fbbf24; font-weight: 600; margin-bottom: 0.5rem; } + + /* Rank view */ + .cs-rank-view { flex: 1; padding: 0.5rem 1.25rem 1rem; overflow-y: auto; } + .cs-rank-hint { font-size: 0.82rem; color: var(--rs-text-secondary); margin: 0 0 1rem; text-align: center; } + .cs-rank-loading { text-align: center; padding: 2rem; color: var(--rs-text-muted); } + .cs-rank-empty { text-align: center; padding: 2rem; color: var(--rs-text-muted); } + + .cs-rank-pair { display: flex; gap: 12px; align-items: stretch; margin-bottom: 1rem; } + .cs-rank-card { + flex: 1; padding: 1.25rem; border-radius: 14px; + background: var(--rs-bg-surface); border: 2px solid var(--rs-border); + cursor: pointer; transition: all 0.15s; min-height: 140px; + display: flex; flex-direction: column; justify-content: center; + } + .cs-rank-card:hover { border-color: #667eea; box-shadow: 0 4px 20px rgba(102,126,234,0.2); transform: translateY(-2px); } + .cs-rank-card:active { transform: scale(0.97); } + .cs-rank-card-text { font-size: 1rem; font-weight: 700; margin-bottom: 0.5rem; line-height: 1.3; } + .cs-rank-card-loc { font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.5rem; } + .cs-rank-card-elo { font-size: 0.78rem; color: #fbbf24; font-weight: 600; } + .cs-rank-vs { display: flex; align-items: center; font-size: 0.85rem; font-weight: 800; color: var(--rs-text-muted); padding: 0 4px; flex-shrink: 0; } + + .cs-rank-actions { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 1.25rem; } + .cs-rank-skip { padding: 6px 16px; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.82rem; font-family: inherit; } + .cs-rank-skip:hover { border-color: var(--rs-text-muted); } + .cs-rank-count { font-size: 0.78rem; color: var(--rs-text-muted); } + + /* Leaderboard */ + .cs-leaderboard { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; } + .cs-lb-row { display: flex; align-items: center; gap: 8px; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--rs-border); font-size: 0.85rem; transition: background 0.3s; } + .cs-lb-row:last-child { border-bottom: none; } + .cs-lb-row.cs-lb-winner { background: rgba(34,197,94,0.1); } + .cs-lb-rank { font-weight: 700; color: var(--rs-text-muted); width: 28px; flex-shrink: 0; } + .cs-lb-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; } + .cs-lb-elo { font-weight: 600; color: #fbbf24; font-size: 0.8rem; flex-shrink: 0; } + .cs-lb-comp { font-size: 0.72rem; color: var(--rs-text-muted); flex-shrink: 0; width: 48px; text-align: right; } + + @media (max-width: 480px) { + .cs-rank-pair { flex-direction: column; } + .cs-rank-vs { justify-content: center; padding: 4px 0; } + } + + /* Bottom nav */ + .cs-nav { display: flex; border-top: 1px solid var(--rs-border); background: var(--rs-bg-surface); padding: 0.35rem 0 env(safe-area-inset-bottom, 0.35rem); flex-shrink: 0; } + .cs-nav-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.4rem 0; border: none; background: none; color: var(--rs-text-muted); cursor: pointer; font-family: inherit; transition: color 0.15s; } + .cs-nav-btn:hover, .cs-nav-btn.active { color: var(--rs-text-primary); } + .cs-nav-btn.active .cs-nav-icon { transform: scale(1.15); } + .cs-nav-icon { font-size: 1.25rem; transition: transform 0.15s; } + .cs-nav-label { font-size: 0.65rem; font-weight: 500; } + + @media (max-width: 480px) { + .cs-card { padding: 1.25rem; } + .cs-card-text { font-size: 1.05rem; } + .cs-form { padding: 0.5rem 1rem 2rem; } + } + `; + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +customElements.define('folk-crowdsurf-dashboard', FolkCrowdSurfDashboard); diff --git a/modules/crowdsurf/landing.ts b/modules/crowdsurf/landing.ts new file mode 100644 index 0000000..df5f9ba --- /dev/null +++ b/modules/crowdsurf/landing.ts @@ -0,0 +1,123 @@ +/** + * CrowdSurf landing page — swipe to coordinate local activities. + */ +export function renderLanding(): string { + return ` + +
+ Coordinate spontaneous activities +

What should your community
do today?

+

+ Swipe to discover. Commit to join. When enough people are in, it happens. + No planning committees. No group chat chaos. Just action. +

+ +
+ + +
+
+

How Crowdsurfing works

+
+
+
1
+

Propose

+

Someone has an idea — community garden day, open mic, repair cafe. They post it with a threshold: “happens when 5 people are in.”

+
+
+
2
+

Swipe

+

Community members discover activities by swiping. Right to join, left to skip. Declare what you’re bringing — skills, gear, food.

+
+
+
3
+

Trigger

+

When enough people commit, the activity triggers. The group forms, contributions are matched, and it just… happens.

+
+
+
+
+ + +
+
+

Built for real communities

+
+
+

Threshold triggers

+

Activities only happen when enough people commit. No more “who’s coming?” anxiety. The threshold is the RSVP.

+
+
+

Contribution matching

+

See what people are bringing and what’s still needed. Skills, equipment, food, space — the puzzle assembles itself.

+
+
+

Time urgency

+

Proposals expire. The countdown creates momentum. As the window closes, urgency rises and commitment accelerates.

+
+
+

Real-time sync

+

Powered by rSpace CRDT infrastructure. Every swipe syncs instantly across all participants. Offline-first, multiplayer by default.

+
+
+
+
+ + +
+
+

What will your community crowdsurf?

+
+
+
🌱
+

Garden days

+

5 people + seedlings + shovels = community garden magic

+
+
+
🎸
+

Jam sessions

+

Musicians find each other. Instruments match up. Music emerges.

+
+
+
🔧
+

Repair cafes

+

Bring broken stuff, find fixers. Circular economy through coordination.

+
+
+
🍳
+

Community meals

+

Someone cooks, others bring ingredients. Potluck, self-organized.

+
+
+
🧘
+

Wellness

+

Yoga by the canal. Group meditation. Movement in the park.

+
+
+
💻
+

Hackathons

+

Coders + designers + a space + caffeine = build something together.

+
+
+
+
+ + +
+
+

Ready to ride the wave?

+

Create a space for your community and start crowdsurfing.

+ +
+
+ +`; +} diff --git a/modules/crowdsurf/local-first-client.ts b/modules/crowdsurf/local-first-client.ts new file mode 100644 index 0000000..4d612e0 --- /dev/null +++ b/modules/crowdsurf/local-first-client.ts @@ -0,0 +1,134 @@ +/** + * rCrowdSurf Local-First Client + * + * Wraps the shared local-first stack for collaborative activity proposals + * with swipe-based commitment, contributions, and threshold triggers. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { crowdsurfSchema, crowdsurfDocId } from './schemas'; +import type { CrowdSurfDoc, CrowdSurfPrompt, PromptSwipe, Contribution } from './schemas'; + +export class CrowdSurfLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(crowdsurfSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('crowdsurf', 'prompts'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, crowdsurfSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[CrowdSurf] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = crowdsurfDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, crowdsurfSchema, binary) + : this.#documents.open(docId, crowdsurfSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): CrowdSurfDoc | undefined { + return this.#documents.get(crowdsurfDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: CrowdSurfDoc) => void): () => void { + return this.#sync.onChange(crowdsurfDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + + // ── Prompt CRUD ── + + createPrompt(prompt: CrowdSurfPrompt): void { + const docId = crowdsurfDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Create prompt: ${prompt.text}`, (d) => { + d.prompts[prompt.id] = prompt; + }); + } + + deletePrompt(promptId: string): void { + const docId = crowdsurfDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete prompt`, (d) => { + delete d.prompts[promptId]; + }); + } + + // ── Swiping ── + + swipe(promptId: string, participantDid: string, direction: 'right' | 'left', contribution?: Contribution): void { + const docId = crowdsurfDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Swipe ${direction} on prompt`, (d) => { + const prompt = d.prompts[promptId]; + if (!prompt) return; + + const swipeData: PromptSwipe = { + direction, + timestamp: Date.now(), + }; + if (contribution) { + swipeData.contribution = contribution; + } + + prompt.swipes[participantDid] = swipeData; + + // Check trigger threshold + const rightSwipes = Object.values(prompt.swipes).filter(s => s.direction === 'right').length; + if (rightSwipes >= prompt.threshold && !prompt.triggered) { + prompt.triggered = true; + } + }); + } + + getMySwipe(promptId: string, myDid: string): PromptSwipe | null { + const doc = this.getDoc(); + return doc?.prompts?.[promptId]?.swipes?.[myDid] ?? null; + } + + // ── Expiry ── + + markExpired(promptId: string): void { + const docId = crowdsurfDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Mark prompt expired`, (d) => { + if (d.prompts[promptId]) d.prompts[promptId].expired = true; + }); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/crowdsurf/mod.ts b/modules/crowdsurf/mod.ts new file mode 100644 index 0000000..b13f653 --- /dev/null +++ b/modules/crowdsurf/mod.ts @@ -0,0 +1,220 @@ +/** + * CrowdSurf module — swipe-based community activity coordination. + * + * Inspired by gospelofchange/Crowdsurfing. Users propose activities with + * commitment thresholds; others swipe to join and declare contributions. + * When enough people commit, the activity triggers. + * + * The folk-crowdsurf-dashboard web component lives in components/. + * This module provides: + * - A dashboard listing active/triggered prompts in the current space + * - API to query crowdsurf prompts from the Automerge store + * - Canvas shape integration for inline prompt cards + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import type { RSpaceModule } from "../../shared/module"; +import { renderLanding } from "./landing"; +import { getModuleInfoList } from "../../shared/module"; +import { getDocumentData, addShapes } from "../../server/community-store"; +import { ELO_DEFAULT, computeElo } from "./schemas"; + +const routes = new Hono(); + +// GET /api/crowdsurf — list crowdsurf prompt shapes in the current space +routes.get("/api/crowdsurf", async (c) => { + const space = c.req.param("space") || c.req.query("space") || "demo"; + const docData = getDocumentData(space); + if (!docData?.shapes) { + return c.json({ prompts: [], total: 0 }); + } + + const promptTypes = ["folk-crowdsurf-prompt"]; + const prompts: any[] = []; + + for (const [id, shape] of Object.entries(docData.shapes as Record)) { + if (shape.forgotten) continue; + if (promptTypes.includes(shape.type)) { + prompts.push({ + id, + type: shape.type, + text: shape.text || "Untitled", + location: shape.location || "", + threshold: shape.threshold || 3, + swipeCount: Object.keys(shape.swipes || {}).length, + triggered: shape.triggered || false, + createdAt: shape.createdAt, + }); + } + } + + return c.json({ prompts, total: prompts.length }); +}); + +// GET /api/crowdsurf/pair — get a random pair for pairwise comparison (sortition-weighted) +routes.get("/api/crowdsurf/pair", async (c) => { + const space = c.req.param("space") || c.req.query("space") || "demo"; + const docData = getDocumentData(space); + if (!docData?.shapes) return c.json({ error: "No prompts" }, 404); + + const prompts: any[] = []; + for (const [id, shape] of Object.entries(docData.shapes as Record)) { + if (shape.forgotten || shape.type !== "folk-crowdsurf-prompt") continue; + if (shape.expired || shape.triggered) continue; + prompts.push({ id, ...shape, elo: shape.elo ?? ELO_DEFAULT, comparisons: shape.comparisons ?? 0, wins: shape.wins ?? 0 }); + } + + if (prompts.length < 2) return c.json({ error: "Need at least 2 active prompts" }, 404); + + // Sortition: weight by inverse comparisons (fewer comparisons = more likely to be picked) + const maxComp = Math.max(...prompts.map((p: any) => p.comparisons), 1); + const weights = prompts.map((p: any) => maxComp - p.comparisons + 1); + const totalWeight = weights.reduce((a: number, b: number) => a + b, 0); + + const pickWeighted = (exclude?: string): any => { + let r = Math.random() * (exclude ? totalWeight - (weights[prompts.findIndex((p: any) => p.id === exclude)] || 0) : totalWeight); + for (let i = 0; i < prompts.length; i++) { + if (prompts[i].id === exclude) continue; + r -= weights[i]; + if (r <= 0) return prompts[i]; + } + return prompts[prompts.length - 1]; + }; + + const a = pickWeighted(); + const b = pickWeighted(a.id); + return c.json({ a, b }); +}); + +// POST /api/crowdsurf/compare — record a pairwise comparison result +routes.post("/api/crowdsurf/compare", async (c) => { + const space = c.req.param("space") || c.req.query("space") || "demo"; + const body = await c.req.json(); + const { winnerId, loserId } = body; + if (!winnerId || !loserId) return c.json({ error: "winnerId and loserId required" }, 400); + + const docData = getDocumentData(space); + if (!docData?.shapes) return c.json({ error: "No data" }, 404); + + const winner = (docData.shapes as Record)[winnerId]; + const loser = (docData.shapes as Record)[loserId]; + if (!winner || !loser) return c.json({ error: "Prompt not found" }, 404); + + const winnerElo = winner.elo ?? ELO_DEFAULT; + const loserElo = loser.elo ?? ELO_DEFAULT; + const result = computeElo(winnerElo, loserElo); + + // Update shapes via addShapes (merge semantics) + addShapes(space, [ + { id: winnerId, elo: result.winner, comparisons: (winner.comparisons ?? 0) + 1, wins: (winner.wins ?? 0) + 1 }, + { id: loserId, elo: result.loser, comparisons: (loser.comparisons ?? 0) + 1 }, + ]); + + return c.json({ + ok: true, + winner: { id: winnerId, elo: result.winner, delta: result.winner - winnerElo }, + loser: { id: loserId, elo: result.loser, delta: result.loser - loserElo }, + }); +}); + +// GET / — crowdsurf dashboard page +routes.get("/", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} — CrowdSurf | rSpace`, + moduleId: "crowdsurf", + spaceSlug, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + })); +}); + +// ── Seed template data ── + +function seedTemplateCrowdSurf(space: string) { + const docData = getDocumentData(space); + const promptTypes = ["folk-crowdsurf-prompt"]; + if (docData?.shapes) { + const existing = Object.values(docData.shapes as Record) + .filter((s: any) => !s.forgotten && promptTypes.includes(s.type)); + if (existing.length > 0) return; + } + + const now = Date.now(); + const shapes: Record[] = [ + { + id: `tmpl-crowdsurf-1-${now}`, type: 'folk-crowdsurf-prompt', + x: 50, y: 2200, width: 420, height: 300, rotation: 0, + text: 'Community Garden Planting Day', + location: 'Community Center Garden', + threshold: 5, + duration: 4, + activityDuration: '3 hours', + swipes: {}, + triggered: false, + expired: false, + createdAt: now, + }, + { + id: `tmpl-crowdsurf-2-${now}`, type: 'folk-crowdsurf-prompt', + x: 520, y: 2200, width: 420, height: 300, rotation: 0, + text: 'Open Mic & Jam Session', + location: 'Local Park Bandstand', + threshold: 8, + duration: 6, + activityDuration: '2 hours', + swipes: {}, + triggered: false, + expired: false, + createdAt: now, + }, + { + id: `tmpl-crowdsurf-3-${now}`, type: 'folk-crowdsurf-prompt', + x: 990, y: 2200, width: 420, height: 300, rotation: 0, + text: 'Repair Cafe — Bring Your Broken Stuff', + location: 'Maker Space', + threshold: 3, + duration: 8, + activityDuration: '4 hours', + swipes: {}, + triggered: false, + expired: false, + createdAt: now, + }, + ]; + + addShapes(space, shapes); + console.log(`[CrowdSurf] Template seeded for "${space}": 3 prompt shapes`); +} + +export const crowdsurfModule: RSpaceModule = { + id: "crowdsurf", + name: "CrowdSurf", + icon: "🏄", + description: "Swipe-based community activity coordination", + scoping: { defaultScope: 'space', userConfigurable: false }, + routes, + hidden: true, // CrowdSurf is now a sub-tab of rChoices + standaloneDomain: "crowdsurf.online", + landingPage: renderLanding, + seedTemplate: seedTemplateCrowdSurf, + feeds: [ + { + id: "activity-triggers", + name: "Activity Triggers", + kind: "governance", + description: "Activity proposals and triggered events", + emits: ["folk-crowdsurf-prompt"], + }, + ], + acceptsFeeds: ["data", "governance"], + outputPaths: [ + { path: "prompts", name: "Prompts", icon: "🏄", description: "Active activity proposals" }, + { path: "triggered", name: "Triggered", icon: "🚀", description: "Activities that reached their threshold" }, + { path: "rank", name: "Rank", icon: "🎲", description: "Pairwise Elo ranking of activities" }, + ], +}; diff --git a/modules/crowdsurf/schemas.ts b/modules/crowdsurf/schemas.ts new file mode 100644 index 0000000..fbe31b6 --- /dev/null +++ b/modules/crowdsurf/schemas.ts @@ -0,0 +1,189 @@ +/** + * rCrowdSurf Automerge document schemas. + * + * Stores collaborative activity proposals ("prompts") with swipe-based + * commitment tracking, contribution tagging, and threshold triggers. + * + * DocId format: {space}:crowdsurf:prompts + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Contribution types ── + +export type ContributionCategory = 'skill' | 'space' | 'equipment' | 'food' | 'other'; + +export interface Contribution { + bringing: string[]; + needed: string[]; + tags: string[]; + value: number; +} + +// ── Swipe record ── + +export interface PromptSwipe { + direction: 'right' | 'left'; + timestamp: number; + contribution?: Contribution; +} + +// ── Activity prompt ── + +export interface CrowdSurfPrompt { + id: string; + text: string; + location: string; + /** Number of right-swipes needed to trigger */ + threshold: number; + /** Hours until prompt expires */ + duration: number; + /** Human-readable activity duration (e.g. "1 hour", "all day") */ + activityDuration: string; + createdAt: number; + createdBy: string | null; + triggered: boolean; + expired: boolean; + /** Keyed by participant DID */ + swipes: Record; + /** Elo rating for pairwise ranking (sortition) */ + elo: number; + /** Number of pairwise comparisons this prompt appeared in */ + comparisons: number; + /** Number of pairwise wins */ + wins: number; +} + +// ── Document root ── + +export interface CrowdSurfDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + prompts: Record; +} + +// ── Schema registration ── + +export const crowdsurfSchema: DocSchema = { + module: 'crowdsurf', + collection: 'prompts', + version: 1, + init: (): CrowdSurfDoc => ({ + meta: { + module: 'crowdsurf', + collection: 'prompts', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + prompts: {}, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.prompts) doc.prompts = {}; + // Backfill Elo defaults on existing prompts + for (const p of Object.values(doc.prompts) as any[]) { + if (p.elo === undefined) { p.elo = ELO_DEFAULT; p.comparisons = 0; p.wins = 0; } + } + doc.meta.version = 1; + return doc; + }, +}; + +// ── Elo constants ── + +export const ELO_K = 32; +export const ELO_DEFAULT = 1500; + +export function computeElo(winnerElo: number, loserElo: number): { winner: number; loser: number } { + const expected = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400)); + const delta = Math.round(ELO_K * (1 - expected)); + return { winner: winnerElo + delta, loser: loserElo - delta }; +} + +// ── Helpers ── + +export function crowdsurfDocId(space: string) { + return `${space}:crowdsurf:prompts` as const; +} + +/** Calculate decay progress (0-1) based on creation time and duration */ +export function getDecayProgress(prompt: CrowdSurfPrompt): number { + const age = Date.now() - prompt.createdAt; + const durationMs = prompt.duration * 60 * 60 * 1000; + return Math.min(age / durationMs, 1); +} + +/** Get human-readable time remaining */ +export function getTimeRemaining(prompt: CrowdSurfPrompt): string { + const remaining = prompt.duration * 60 * 60 * 1000 - (Date.now() - prompt.createdAt); + if (remaining <= 0) return 'Expired'; + const hours = Math.floor(remaining / (60 * 60 * 1000)); + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); + if (hours > 0) return `${hours}h ${minutes}m left`; + return `${minutes}m left`; +} + +/** Count right-swipes */ +export function getRightSwipeCount(prompt: CrowdSurfPrompt): number { + return Object.values(prompt.swipes).filter(s => s.direction === 'right').length; +} + +/** Check if prompt has met its threshold */ +export function isReadyToTrigger(prompt: CrowdSurfPrompt): boolean { + return getRightSwipeCount(prompt) >= prompt.threshold; +} + +/** Get urgency level based on time decay */ +export function getUrgency(prompt: CrowdSurfPrompt): 'low' | 'medium' | 'high' { + const decay = getDecayProgress(prompt); + if (decay > 0.7) return 'high'; + if (decay > 0.4) return 'medium'; + return 'low'; +} + +/** Parse free-text contribution input into tags and categories */ +export function parseContributions(bringing: string, needed: string): Contribution { + const parseItems = (text: string): string[] => + text.split(/[,\n]/).map(s => s.trim()).filter(s => s.length > 0); + + const bringingItems = parseItems(bringing); + const neededItems = parseItems(needed); + const allItems = [...bringingItems, ...neededItems]; + const tags = new Set(); + + const categoryKeywords: Record = { + food: ['cook', 'food', 'eat', 'meal', 'kitchen', 'bake', 'grill', 'ingredients'], + music: ['music', 'guitar', 'drum', 'sing', 'band', 'dj', 'speaker', 'mic'], + learning: ['teach', 'learn', 'skill', 'knowledge', 'workshop', 'lecture'], + tech: ['code', 'laptop', 'hack', 'build', 'dev', 'tech', 'wifi'], + art: ['art', 'paint', 'draw', 'craft', 'design', 'photo', 'camera'], + }; + + for (const item of allItems) { + const lower = item.toLowerCase(); + for (const [category, keywords] of Object.entries(categoryKeywords)) { + if (keywords.some(kw => lower.includes(kw))) { + tags.add(category); + } + } + } + + // Base value: 5 per item brought, 2 per item needed, +5 bonus for skill keywords + const skillWords = ['skill', 'experience', 'professional', 'advanced', 'expert']; + const value = bringingItems.reduce((sum, item) => { + const hasSkill = skillWords.some(sw => item.toLowerCase().includes(sw)); + return sum + (hasSkill ? 10 : 5); + }, 0) + neededItems.length * 2; + + return { + bringing: bringingItems, + needed: neededItems, + tags: Array.from(tags), + value, + }; +} diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index 3a980c9..a14fa98 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -9,6 +9,16 @@ 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 { @@ -31,7 +41,7 @@ class FolkChoicesDashboard extends HTMLElement { private space: string; /* Demo state */ - private demoTab: "spider" | "ranking" | "voting" = "spider"; + 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; @@ -47,18 +57,28 @@ class FolkChoicesDashboard extends HTMLElement { private activeSessionId: string | null = null; private sessionVotes: Map = new Map(); + /* CrowdSurf inline state */ + private csOptions: CrowdSurfOption[] = []; + private csCurrentIndex = 0; + private csSwipedMap: Map = new Map(); + private csIsDragging = false; + private csStartX = 0; + private csCurrentX = 0; + private csIsAnimating = false; + private _csTransitionTimer: number | null = null; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ - { target: '.demo-content', title: "rChoices", message: "Explore spider charts, rankings, and live voting. Use the sub-nav above to switch between modes.", advanceOnClick: true }, + { 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" | null; - if (tabAttr && ["spider", "ranking", "voting"].includes(tabAttr)) { + 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( @@ -85,6 +105,10 @@ class FolkChoicesDashboard extends HTMLElement { clearInterval(this.simTimer); this.simTimer = null; } + if (this._csTransitionTimer !== null) { + clearTimeout(this._csTransitionTimer); + this._csTransitionTimer = null; + } this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); @@ -508,6 +532,7 @@ class FolkChoicesDashboard extends HTMLElement { 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 = ` @@ -558,6 +583,30 @@ class FolkChoicesDashboard extends HTMLElement { .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; } } @@ -569,6 +618,9 @@ class FolkChoicesDashboard extends HTMLElement { .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; } } @@ -708,6 +760,324 @@ class FolkChoicesDashboard extends HTMLElement { `; } + /* -- CrowdSurf helpers -- */ + + private static mulberry32(seed: number): () => number { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + private static hashString(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return hash >>> 0; + } + + private buildCrowdSurfOptions() { + const myDid = getMyDid() || 'anon'; + const lsKey = `cs_swiped:${this.space}:${myDid}`; + + // Restore persisted swipes + try { + const saved = localStorage.getItem(lsKey); + if (saved) { + const entries: [string, 'right' | 'left'][] = JSON.parse(saved); + this.csSwipedMap = new Map(entries); + } + } catch { /* ignore */ } + + // Build pool from live sessions or demo data + let pool: CrowdSurfOption[] = []; + + // Try live sessions first + const openSessions = this.sessions.filter(s => !s.closed); + for (const session of openSessions) { + const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote'; + for (const opt of session.options) { + pool.push({ + optionId: opt.id, + label: opt.label, + color: opt.color, + sessionId: session.id, + sessionTitle: session.title, + sessionType: sType, + }); + } + } + + // Demo mode fallback + if (this.space === 'demo' && pool.length === 0) { + for (const opt of this.voteOptions) { + pool.push({ + optionId: opt.id, + label: opt.name, + color: opt.color, + sessionId: 'demo-vote', + sessionTitle: 'Movie Night', + sessionType: 'vote', + }); + } + for (const item of this.rankItems) { + pool.push({ + optionId: String(item.id), + label: item.name, + color: '#f59e0b', + sessionId: 'demo-rank', + sessionTitle: 'Lunch Spot', + sessionType: 'rank', + }); + } + } + + // Filter already swiped + pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`)); + + // Seeded shuffle (Fisher-Yates) + const today = new Date().toISOString().slice(0, 10); + const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`); + const rng = FolkChoicesDashboard.mulberry32(seed); + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + + this.csOptions = pool.slice(0, Math.min(10, pool.length)); + this.csCurrentIndex = 0; + } + + private renderCrowdSurf(): string { + // Build options if empty or all consumed + if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) { + this.buildCrowdSurfOptions(); + } + + // No options at all + if (this.csOptions.length === 0) { + return `
+
+
🏄
+

No open polls to surf yet.

+

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

+ ${this.csSwipedMap.size > 0 ? `` : ''} +
+
`; + } + + // All swiped — show summary + if (this.csCurrentIndex >= this.csOptions.length) { + return this.renderCrowdSurfSummary(); + } + + // Active card + const opt = this.csOptions[this.csCurrentIndex]; + const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length; + const typeBadgeColors: Record = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' }; + const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6'; + + return `
+
+ ${this.csCurrentIndex + 1} of ${this.csOptions.length} + ${approved} approved +
+
+
+
✗ Skip
+
✓ Approve
+
+
${opt.sessionType}
+
${this.esc(opt.sessionTitle)}
+
+ + ${this.esc(opt.label)} +
+
+
+
+
+ + +
+
`; + } + + private renderCrowdSurfSummary(): string { + const approved: CrowdSurfOption[] = []; + this.csSwipedMap.forEach((dir, key) => { + if (dir !== 'right') return; + const [sessionId, optionId] = key.split(':'); + const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId); + if (opt) approved.push(opt); + }); + + // Group by session + const grouped = new Map(); + for (const opt of approved) { + const list = grouped.get(opt.sessionId) || []; + list.push(opt); + grouped.set(opt.sessionId, list); + } + + let groupHtml = ''; + grouped.forEach((opts) => { + const title = opts[0].sessionTitle; + const items = opts.map(o => + `
+ + ${this.esc(o.label)} +
` + ).join(''); + groupHtml += `
+
${this.esc(title)}
+ ${items} +
`; + }); + + return `
+
+
+

All done!

+

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

+ ${groupHtml || `

No approvals this round.

`} + +
+
`; + } + + private setupCrowdSurfSwipe() { + const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; + if (!card) return; + + // Clear any pending transition timer from previous card + if (this._csTransitionTimer !== null) { + clearTimeout(this._csTransitionTimer); + this._csTransitionTimer = null; + } + + const handleStart = (clientX: number) => { + if (this.csIsAnimating) return; + // Clear any lingering transition + if (this._csTransitionTimer !== null) { + clearTimeout(this._csTransitionTimer); + this._csTransitionTimer = null; + } + card.style.transition = ''; + this.csStartX = clientX; + this.csCurrentX = clientX; + this.csIsDragging = true; + }; + + const handleMove = (clientX: number) => { + if (!this.csIsDragging || this.csIsAnimating) return; + this.csCurrentX = clientX; + const diffX = this.csCurrentX - this.csStartX; + const rotation = diffX * 0.1; + card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`; + + const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement; + const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement; + + if (diffX < -50) { + leftInd?.classList.add('show'); + rightInd?.classList.remove('show'); + } else if (diffX > 50) { + rightInd?.classList.add('show'); + leftInd?.classList.remove('show'); + } else { + leftInd?.classList.remove('show'); + rightInd?.classList.remove('show'); + } + }; + + const handleEnd = () => { + if (!this.csIsDragging || this.csIsAnimating) return; + this.csIsDragging = false; + const diffX = this.csCurrentX - this.csStartX; + + card.querySelector('.cs-swipe-left')?.classList.remove('show'); + card.querySelector('.cs-swipe-right')?.classList.remove('show'); + + if (Math.abs(diffX) > 100) { + const direction = diffX > 0 ? 1 : -1; + this.csIsAnimating = true; + card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; + card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`; + card.style.opacity = '0'; + this._csTransitionTimer = window.setTimeout(() => { + card.style.transform = ''; + card.style.opacity = ''; + card.style.transition = ''; + this._csTransitionTimer = null; + this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left'); + }, 300); + } else { + card.style.transition = 'transform 0.2s ease-out'; + card.style.transform = ''; + this._csTransitionTimer = window.setTimeout(() => { + card.style.transition = ''; + this._csTransitionTimer = null; + }, 200); + } + }; + + card.addEventListener('pointerdown', (e: PointerEvent) => { + if (e.button !== 0) return; + card.setPointerCapture(e.pointerId); + card.style.touchAction = 'none'; + handleStart(e.clientX); + }); + card.addEventListener('pointermove', (e: PointerEvent) => { + e.preventDefault(); + handleMove(e.clientX); + }); + card.addEventListener('pointerup', () => handleEnd()); + card.addEventListener('pointercancel', () => { + this.csIsDragging = false; + card.style.transform = ''; + card.style.transition = ''; + card.style.opacity = ''; + card.style.touchAction = ''; + }); + } + + private handleCrowdSurfSwipe(direction: 'right' | 'left') { + this.csIsAnimating = false; + if (this.csCurrentIndex >= this.csOptions.length) return; + + const opt = this.csOptions[this.csCurrentIndex]; + const swipeKey = `${opt.sessionId}:${opt.optionId}`; + this.csSwipedMap.set(swipeKey, direction); + + // Persist to localStorage + const myDid = getMyDid() || 'anon'; + const lsKey = `cs_swiped:${this.space}:${myDid}`; + try { + localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries()))); + } catch { /* quota */ } + + // Cast vote on right swipe (live mode) + if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') { + const did = getMyDid(); + if (did) { + const existing = this.lfClient.getMyVote(opt.sessionId, did); + const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 }; + this.lfClient.castVote(opt.sessionId, did, newChoices); + } + } + + this.csCurrentIndex++; + this.renderDemo(); + this.bindDemoEvents(); + } + /* -- Demo event binding -- */ private bindDemoEvents() { @@ -818,6 +1188,44 @@ class FolkChoicesDashboard extends HTMLElement { }); } + // 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 { diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index b992192..5022206 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -65,9 +65,9 @@ routes.get("/", (c) => { routes.get("/:tab", (c) => { const spaceSlug = c.req.param("space") || "demo"; const tab = c.req.param("tab"); - const validTabs = ["spider", "ranking", "voting"]; + const validTabs = ["spider", "ranking", "voting", "crowdsurf"]; if (!validTabs.includes(tab)) return c.notFound(); - const tabLabel = tab.charAt(0).toUpperCase() + tab.slice(1); + const tabLabel = tab === "crowdsurf" ? "CrowdSurf" : tab.charAt(0).toUpperCase() + tab.slice(1); return c.html(renderShell({ title: `${spaceSlug} — ${tabLabel} | rChoices`, moduleId: "rchoices", @@ -161,5 +161,6 @@ export const choicesModule: RSpaceModule = { { path: "spider", name: "Spider Chart", icon: "🕸", description: "Multi-criteria radar charts" }, { path: "ranking", name: "Ranking", icon: "📊", description: "Drag-and-drop rankings" }, { path: "voting", name: "Voting", icon: "☑", description: "Live polls and voting" }, + { path: "crowdsurf", name: "CrowdSurf", icon: "🏄", description: "Swipe-based option surfacing" }, ], }; diff --git a/server/index.ts b/server/index.ts index f041ea4..ab9bd88 100644 --- a/server/index.ts +++ b/server/index.ts @@ -75,6 +75,7 @@ import { chatsModule } from "../modules/rchats/mod"; import { scheduleModule } from "../modules/rschedule/mod"; import { bnbModule } from "../modules/rbnb/mod"; import { vnbModule } from "../modules/rvnb/mod"; +import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell"; @@ -118,6 +119,7 @@ registerModule(meetsModule); registerModule(chatsModule); registerModule(bnbModule); registerModule(vnbModule); +registerModule(crowdsurfModule); // De-emphasized modules (bottom of menu) registerModule(forumModule); registerModule(tubeModule); diff --git a/server/shell.ts b/server/shell.ts index 06e2358..13abe2d 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -45,6 +45,7 @@ const FAVICON_BADGE_MAP: Record = { rvnb: { badge: "r🚐", color: "#a5f3fc" }, rtasks: { badge: "r📋", color: "#cbd5e1" }, rschedule: { badge: "r⏱", color: "#a5b4fc" }, + crowdsurf: { badge: "r🏄", color: "#fde68a" }, rids: { badge: "r🪪", color: "#6ee7b7" }, rstack: { badge: "r✨", color: "#c4b5fd" }, }; diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 5eb78db..dc344cc 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -59,6 +59,7 @@ const MODULE_BADGES: Record = { // Coordinate rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300 rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200 + crowdsurf: { badge: "r🏄", color: "#fde68a" }, // amber-200 // Identity & Infrastructure rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300 rstack: { badge: "r✨", color: "" }, // gradient (handled separately) @@ -84,6 +85,7 @@ const MODULE_CATEGORIES: Record = { rtasks: "Coordinate", rchoices: "Coordinate", rvote: "Coordinate", + crowdsurf: "Coordinate", // Connect rnetwork: "Connect", rsocials: "Connect", diff --git a/vite.config.ts b/vite.config.ts index 1fe9549..70d8fdf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -357,7 +357,40 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rchoices/choices.css"), ); - + // Build crowdsurf module component (with Automerge WASM for local-first client) + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/crowdsurf/components"), + plugins: [wasm()], + resolve: { + alias: { + '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), + }, + }, + build: { + target: "esnext", + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/crowdsurf"), + lib: { + entry: resolve(__dirname, "modules/crowdsurf/components/folk-crowdsurf-dashboard.ts"), + formats: ["es"], + fileName: () => "folk-crowdsurf-dashboard.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-crowdsurf-dashboard.js", + }, + }, + }, + }); + + // Copy crowdsurf CSS + mkdirSync(resolve(__dirname, "dist/modules/crowdsurf"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/crowdsurf/components/crowdsurf.css"), + resolve(__dirname, "dist/modules/crowdsurf/crowdsurf.css"), + ); + // Build flows module components const flowsAlias = { "../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),