feat: add CrowdSurf module — swipe-based community activity coordination
Implements the Crowdsurfing protocol (gospelofchange/Crowdsurfing) as an rSpace module with full local-first Automerge CRDT sync. Users propose activities with commitment thresholds, others swipe to join and declare contributions, and activities trigger when enough people commit. Module includes schemas, local-first client, swipe UI dashboard with pointer gesture detection, landing page, seed template data, and Vite build integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
51da13ac46
commit
2ea6fee951
|
|
@ -0,0 +1,5 @@
|
||||||
|
/* CrowdSurf module layout */
|
||||||
|
main {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,781 @@
|
||||||
|
/**
|
||||||
|
* <folk-crowdsurf-dashboard> — 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' | '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 };
|
||||||
|
|
||||||
|
// 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 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<style>${this.getStyles()}</style>
|
||||||
|
|
||||||
|
<div class="cs-app">
|
||||||
|
${this.loading ? '<div class="cs-loading">Loading...</div>' : this.renderActiveView(isLive)}
|
||||||
|
|
||||||
|
<!-- Bottom nav -->
|
||||||
|
<nav class="cs-nav">
|
||||||
|
<button class="cs-nav-btn${this.activeTab === 'discover' ? ' active' : ''}" data-tab="discover">
|
||||||
|
<span class="cs-nav-icon">🏄</span>
|
||||||
|
<span class="cs-nav-label">Discover</span>
|
||||||
|
</button>
|
||||||
|
<button class="cs-nav-btn${this.activeTab === 'create' ? ' active' : ''}" data-tab="create">
|
||||||
|
<span class="cs-nav-icon">✨</span>
|
||||||
|
<span class="cs-nav-label">Create</span>
|
||||||
|
</button>
|
||||||
|
<button class="cs-nav-btn${this.activeTab === 'profile' ? ' active' : ''}" data-tab="profile">
|
||||||
|
<span class="cs-nav-icon">👤</span>
|
||||||
|
<span class="cs-nav-label">Profile</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderActiveView(isLive: boolean): string {
|
||||||
|
switch (this.activeTab) {
|
||||||
|
case 'discover': return this.renderDiscover(isLive);
|
||||||
|
case 'create': return this.renderCreateForm();
|
||||||
|
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 `
|
||||||
|
<div class="cs-header">
|
||||||
|
<span class="cs-title">CrowdSurf</span>
|
||||||
|
${isLive ? '<span class="cs-live"><span class="cs-live-dot"></span>LIVE</span>' : ''}
|
||||||
|
${this.space === 'demo' ? '<span class="cs-demo-badge">DEMO</span>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cs-discover">
|
||||||
|
${prompt ? this.renderCard(prompt, activeCount) : this.renderNoCards()}
|
||||||
|
|
||||||
|
${triggeredPrompts.length > 0 ? `
|
||||||
|
<div class="cs-section-label">Triggered Activities</div>
|
||||||
|
<div class="cs-triggered-list">
|
||||||
|
${triggeredPrompts.map(p => `
|
||||||
|
<div class="cs-triggered-card">
|
||||||
|
<span class="cs-triggered-icon">🚀</span>
|
||||||
|
<div class="cs-triggered-info">
|
||||||
|
<div class="cs-triggered-title">${this.esc(p.text)}</div>
|
||||||
|
<div class="cs-triggered-meta">📍 ${this.esc(p.location)} · ${getRightSwipeCount(p)} people</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="cs-card-stack">
|
||||||
|
<div class="cs-card" id="cs-current-card">
|
||||||
|
<div class="cs-swipe-indicator cs-swipe-left">✗ Pass</div>
|
||||||
|
<div class="cs-swipe-indicator cs-swipe-right">✓ Join</div>
|
||||||
|
|
||||||
|
<div class="cs-card-body">
|
||||||
|
<div class="cs-card-text">${this.esc(prompt.text)}</div>
|
||||||
|
|
||||||
|
<div class="cs-card-location">📍 ${this.esc(prompt.location)}</div>
|
||||||
|
${prompt.activityDuration ? `<div class="cs-card-duration">⏱️ ${this.esc(prompt.activityDuration)}</div>` : ''}
|
||||||
|
|
||||||
|
${bringingAll.length > 0 ? `
|
||||||
|
<div class="cs-contrib-section">
|
||||||
|
<div class="cs-contrib-label">🎒 People are bringing:</div>
|
||||||
|
<div class="cs-contrib-tags">${bringingAll.map(b => `<span class="cs-tag">${this.esc(b)}</span>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${neededAll.length > 0 ? `
|
||||||
|
<div class="cs-contrib-section">
|
||||||
|
<div class="cs-contrib-label">✨ Still needed:</div>
|
||||||
|
<div class="cs-contrib-tags">${neededAll.map(n => `<span class="cs-tag needed">${this.esc(n)}</span>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="cs-card-footer">
|
||||||
|
<div class="cs-pool">${rightSwipes}/${prompt.threshold} interested</div>
|
||||||
|
<div class="cs-progress-bar">
|
||||||
|
<div class="cs-progress-fill" style="width:${progressPct}%"></div>
|
||||||
|
${urgency === 'high' ? '<div class="cs-urgency-pulse"></div>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="cs-time ${urgency === 'high' ? 'urgent' : ''}">${timeLeft}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isReadyToTrigger(prompt) ? '<div class="cs-trigger-msg">🚀 Group ready! Activity happening!</div>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-card-count">${this.currentPromptIndex + 1} / ${totalActive}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cs-swipe-buttons">
|
||||||
|
<button class="cs-btn-skip" data-action="swipe-left">✗</button>
|
||||||
|
<button class="cs-btn-join" data-action="swipe-right">✓</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNoCards(): string {
|
||||||
|
return `
|
||||||
|
<div class="cs-empty">
|
||||||
|
<div class="cs-empty-icon">🏄</div>
|
||||||
|
<p>No activities to discover right now.</p>
|
||||||
|
<p>Create one and get the wave started!</p>
|
||||||
|
<button class="cs-btn-create" data-action="go-create">Create Activity</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create form ──
|
||||||
|
|
||||||
|
private renderCreateForm(): string {
|
||||||
|
return `
|
||||||
|
<div class="cs-header">
|
||||||
|
<span class="cs-title">New Activity</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-form">
|
||||||
|
<label class="cs-label">What's happening?</label>
|
||||||
|
<textarea id="cs-text" class="cs-input cs-textarea" rows="3" placeholder="Community garden planting, open mic night, repair cafe..."></textarea>
|
||||||
|
|
||||||
|
<label class="cs-label">Where?</label>
|
||||||
|
<input id="cs-location" type="text" class="cs-input" placeholder="Park, rooftop, community center...">
|
||||||
|
|
||||||
|
<div class="cs-form-row">
|
||||||
|
<div class="cs-form-col">
|
||||||
|
<label class="cs-label">People needed</label>
|
||||||
|
<input id="cs-threshold" type="number" class="cs-input" min="2" max="50" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="cs-form-col">
|
||||||
|
<label class="cs-label">Expires in (hours)</label>
|
||||||
|
<input id="cs-duration" type="number" class="cs-input" min="1" max="168" value="4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="cs-label">Activity duration</label>
|
||||||
|
<select id="cs-activity-duration" class="cs-input">
|
||||||
|
<option value="30 minutes">30 minutes</option>
|
||||||
|
<option value="1 hour" selected>1 hour</option>
|
||||||
|
<option value="2 hours">2 hours</option>
|
||||||
|
<option value="3 hours">3 hours</option>
|
||||||
|
<option value="half day">Half day</option>
|
||||||
|
<option value="all day">All day</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="cs-divider"></div>
|
||||||
|
|
||||||
|
<label class="cs-label">🎒 What are you bringing?</label>
|
||||||
|
<textarea id="cs-bringing" class="cs-input cs-textarea" rows="2" placeholder="Guitar, cooking skills, projector..."></textarea>
|
||||||
|
|
||||||
|
<label class="cs-label">✨ What would be great to have?</label>
|
||||||
|
<textarea id="cs-needed" class="cs-input cs-textarea" rows="2" placeholder="More musicians, ingredients, curious people..."></textarea>
|
||||||
|
|
||||||
|
<button class="cs-btn-submit" data-action="create-prompt">Launch Activity 🚀</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 `
|
||||||
|
<div class="cs-header">
|
||||||
|
<span class="cs-title">Profile</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-profile">
|
||||||
|
<div class="cs-stats-grid">
|
||||||
|
<div class="cs-stat-card">
|
||||||
|
<div class="cs-stat-num">${this.stats.joined}</div>
|
||||||
|
<div class="cs-stat-label">Joined</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-stat-card">
|
||||||
|
<div class="cs-stat-num">${this.stats.created}</div>
|
||||||
|
<div class="cs-stat-label">Created</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-stat-card">
|
||||||
|
<div class="cs-stat-num">${this.stats.triggered}</div>
|
||||||
|
<div class="cs-stat-label">Triggered</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cs-section-label">Community Stats</div>
|
||||||
|
<div class="cs-community-stats">
|
||||||
|
<div class="cs-community-row"><span>Active prompts</span><span>${this.prompts.filter(p => !p.expired && !p.triggered).length}</span></div>
|
||||||
|
<div class="cs-community-row"><span>Total triggered</span><span>${allTriggered.length}</span></div>
|
||||||
|
<div class="cs-community-row"><span>Total participants</span><span>${totalParticipants}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event binding ──
|
||||||
|
|
||||||
|
private bindEvents() {
|
||||||
|
// Tab navigation
|
||||||
|
this.shadow.querySelectorAll<HTMLElement>('.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* CrowdSurf landing page — swipe to coordinate local activities.
|
||||||
|
*/
|
||||||
|
export function renderLanding(): string {
|
||||||
|
return `
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="rl-hero">
|
||||||
|
<span class="rl-tagline">Coordinate spontaneous activities</span>
|
||||||
|
<h1 class="rl-heading">What should <span style="color:#5eead4">your community</span><br>do today?</h1>
|
||||||
|
<p class="rl-subtext">
|
||||||
|
Swipe to discover. Commit to join. When enough people are in, it happens.
|
||||||
|
No planning committees. No group chat chaos. Just action.
|
||||||
|
</p>
|
||||||
|
<div class="rl-cta-row">
|
||||||
|
<a href="https://demo.rspace.online/crowdsurf" class="rl-cta-primary">Start Swiping</a>
|
||||||
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How it works -->
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">How Crowdsurfing works</h2>
|
||||||
|
<div class="rl-grid-3">
|
||||||
|
<div class="rl-step">
|
||||||
|
<div class="rl-step__num">1</div>
|
||||||
|
<h3>Propose</h3>
|
||||||
|
<p>Someone has an idea — community garden day, open mic, repair cafe. They post it with a threshold: “happens when 5 people are in.”</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-step">
|
||||||
|
<div class="rl-step__num">2</div>
|
||||||
|
<h3>Swipe</h3>
|
||||||
|
<p>Community members discover activities by swiping. Right to join, left to skip. Declare what you’re bringing — skills, gear, food.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-step">
|
||||||
|
<div class="rl-step__num">3</div>
|
||||||
|
<h3>Trigger</h3>
|
||||||
|
<p>When enough people commit, the activity triggers. The group forms, contributions are matched, and it just… happens.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<section class="rl-section rl-section--alt">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">Built for real communities</h2>
|
||||||
|
<div class="rl-grid-2" style="margin-top:2rem">
|
||||||
|
<div class="rl-card">
|
||||||
|
<h3 style="margin-bottom:0.35rem">Threshold triggers</h3>
|
||||||
|
<p>Activities only happen when enough people commit. No more “who’s coming?” anxiety. The threshold <em>is</em> the RSVP.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card">
|
||||||
|
<h3 style="margin-bottom:0.35rem">Contribution matching</h3>
|
||||||
|
<p>See what people are bringing and what’s still needed. Skills, equipment, food, space — the puzzle assembles itself.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card">
|
||||||
|
<h3 style="margin-bottom:0.35rem">Time urgency</h3>
|
||||||
|
<p>Proposals expire. The countdown creates momentum. As the window closes, urgency rises and commitment accelerates.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card">
|
||||||
|
<h3 style="margin-bottom:0.35rem">Real-time sync</h3>
|
||||||
|
<p>Powered by rSpace CRDT infrastructure. Every swipe syncs instantly across all participants. Offline-first, multiplayer by default.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Use cases -->
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">What will your community crowdsurf?</h2>
|
||||||
|
<div class="rl-grid-3" style="margin-top:2rem">
|
||||||
|
<div class="rl-card" style="text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">🌱</div>
|
||||||
|
<h3 style="margin-bottom:0.25rem">Garden days</h3>
|
||||||
|
<p style="font-size:0.85rem">5 people + seedlings + shovels = community garden magic</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card" style="text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">🎸</div>
|
||||||
|
<h3 style="margin-bottom:0.25rem">Jam sessions</h3>
|
||||||
|
<p style="font-size:0.85rem">Musicians find each other. Instruments match up. Music emerges.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card" style="text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">🔧</div>
|
||||||
|
<h3 style="margin-bottom:0.25rem">Repair cafes</h3>
|
||||||
|
<p style="font-size:0.85rem">Bring broken stuff, find fixers. Circular economy through coordination.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card" style="text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">🍳</div>
|
||||||
|
<h3 style="margin-bottom:0.25rem">Community meals</h3>
|
||||||
|
<p style="font-size:0.85rem">Someone cooks, others bring ingredients. Potluck, self-organized.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card" style="text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">🧘</div>
|
||||||
|
<h3 style="margin-bottom:0.25rem">Wellness</h3>
|
||||||
|
<p style="font-size:0.85rem">Yoga by the canal. Group meditation. Movement in the park.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card" style="text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">💻</div>
|
||||||
|
<h3 style="margin-bottom:0.25rem">Hackathons</h3>
|
||||||
|
<p style="font-size:0.85rem">Coders + designers + a space + caffeine = build something together.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="rl-section rl-section--alt">
|
||||||
|
<div class="rl-container" style="text-align:center">
|
||||||
|
<h2 class="rl-heading">Ready to ride the wave?</h2>
|
||||||
|
<p class="rl-subtext">Create a space for your community and start crowdsurfing.</p>
|
||||||
|
<div class="rl-cta-row">
|
||||||
|
<a href="https://demo.rspace.online/crowdsurf" class="rl-cta-primary">Try the Demo</a>
|
||||||
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="rl-back">
|
||||||
|
<a href="/">← Back to rSpace</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
@ -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<void> {
|
||||||
|
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<CrowdSurfDoc>(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<CrowdSurfDoc | null> {
|
||||||
|
const docId = crowdsurfDocId(this.#space) as DocumentId;
|
||||||
|
let doc = this.#documents.get<CrowdSurfDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
const binary = await this.#store.load(docId);
|
||||||
|
doc = binary
|
||||||
|
? this.#documents.open<CrowdSurfDoc>(docId, crowdsurfSchema, binary)
|
||||||
|
: this.#documents.open<CrowdSurfDoc>(docId, crowdsurfSchema);
|
||||||
|
}
|
||||||
|
await this.#sync.subscribe([docId]);
|
||||||
|
return doc ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDoc(): CrowdSurfDoc | undefined {
|
||||||
|
return this.#documents.get<CrowdSurfDoc>(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<CrowdSurfDoc>(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<CrowdSurfDoc>(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<CrowdSurfDoc>(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<CrowdSurfDoc>(docId, `Mark prompt expired`, (d) => {
|
||||||
|
if (d.prompts[promptId]) d.prompts[promptId].expired = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.#sync.flush();
|
||||||
|
this.#sync.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
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<string, any>)) {
|
||||||
|
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 / — 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: `<folk-crowdsurf-dashboard space="${spaceSlug}"></folk-crowdsurf-dashboard>`,
|
||||||
|
scripts: `<script type="module" src="/modules/crowdsurf/folk-crowdsurf-dashboard.js?v=1"></script>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/crowdsurf/crowdsurf.css">`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<string, any>)
|
||||||
|
.filter((s: any) => !s.forgotten && promptTypes.includes(s.type));
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const shapes: Record<string, unknown>[] = [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* 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<string, PromptSwipe>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document root ──
|
||||||
|
|
||||||
|
export interface CrowdSurfDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
prompts: Record<string, CrowdSurfPrompt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema registration ──
|
||||||
|
|
||||||
|
export const crowdsurfSchema: DocSchema<CrowdSurfDoc> = {
|
||||||
|
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 = {};
|
||||||
|
doc.meta.version = 1;
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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<string>();
|
||||||
|
|
||||||
|
const categoryKeywords: Record<string, string[]> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,7 @@ import { meetsModule } from "../modules/rmeets/mod";
|
||||||
// import { designModule } from "../modules/rdesign/mod";
|
// import { designModule } from "../modules/rdesign/mod";
|
||||||
import { scheduleModule } from "../modules/rschedule/mod";
|
import { scheduleModule } from "../modules/rschedule/mod";
|
||||||
import { bnbModule } from "../modules/rbnb/mod";
|
import { bnbModule } from "../modules/rbnb/mod";
|
||||||
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell";
|
import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell";
|
||||||
|
|
@ -113,6 +114,7 @@ registerModule(socialsModule);
|
||||||
registerModule(scheduleModule);
|
registerModule(scheduleModule);
|
||||||
registerModule(meetsModule);
|
registerModule(meetsModule);
|
||||||
registerModule(bnbModule);
|
registerModule(bnbModule);
|
||||||
|
registerModule(crowdsurfModule);
|
||||||
// De-emphasized modules (bottom of menu)
|
// De-emphasized modules (bottom of menu)
|
||||||
registerModule(forumModule);
|
registerModule(forumModule);
|
||||||
registerModule(tubeModule);
|
registerModule(tubeModule);
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,40 @@ export default defineConfig({
|
||||||
resolve(__dirname, "dist/modules/rchoices/choices.css"),
|
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
|
// Build flows module components
|
||||||
const flowsAlias = {
|
const flowsAlias = {
|
||||||
"../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),
|
"../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue