Merge branch 'dev'
This commit is contained in:
commit
4b8fd2fed0
|
|
@ -0,0 +1,5 @@
|
||||||
|
/* CrowdSurf module layout */
|
||||||
|
main {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,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<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 /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<string, any>)) {
|
||||||
|
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<string, any>)[winnerId];
|
||||||
|
const loser = (docData.shapes as Record<string, any>)[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: `<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,
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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<string, PromptSwipe>;
|
||||||
|
/** 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<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 = {};
|
||||||
|
// 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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,16 @@ import { TourEngine } from "../../../shared/tour-engine";
|
||||||
import { ChoicesLocalFirstClient } from "../local-first-client";
|
import { ChoicesLocalFirstClient } from "../local-first-client";
|
||||||
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
|
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 ──
|
// ── Auth helpers ──
|
||||||
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -31,7 +41,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private space: string;
|
private space: string;
|
||||||
|
|
||||||
/* Demo state */
|
/* Demo state */
|
||||||
private demoTab: "spider" | "ranking" | "voting" = "spider";
|
private demoTab: "spider" | "ranking" | "voting" | "crowdsurf" = "spider";
|
||||||
private hoveredPerson: string | null = null;
|
private hoveredPerson: string | null = null;
|
||||||
private rankItems: { id: number; name: string; emoji: string }[] = [];
|
private rankItems: { id: number; name: string; emoji: string }[] = [];
|
||||||
private rankDragging: number | null = null;
|
private rankDragging: number | null = null;
|
||||||
|
|
@ -47,18 +57,28 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private activeSessionId: string | null = null;
|
private activeSessionId: string | null = null;
|
||||||
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
|
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
|
||||||
|
|
||||||
|
/* CrowdSurf inline state */
|
||||||
|
private csOptions: CrowdSurfOption[] = [];
|
||||||
|
private csCurrentIndex = 0;
|
||||||
|
private csSwipedMap: Map<string, 'right' | 'left'> = new Map();
|
||||||
|
private csIsDragging = false;
|
||||||
|
private csStartX = 0;
|
||||||
|
private csCurrentX = 0;
|
||||||
|
private csIsAnimating = false;
|
||||||
|
private _csTransitionTimer: number | null = null;
|
||||||
|
|
||||||
// Guided tour
|
// Guided tour
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | null;
|
const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | "crowdsurf" | null;
|
||||||
if (tabAttr && ["spider", "ranking", "voting"].includes(tabAttr)) {
|
if (tabAttr && ["spider", "ranking", "voting", "crowdsurf"].includes(tabAttr)) {
|
||||||
this.demoTab = tabAttr;
|
this.demoTab = tabAttr;
|
||||||
}
|
}
|
||||||
this._tour = new TourEngine(
|
this._tour = new TourEngine(
|
||||||
|
|
@ -85,6 +105,10 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
clearInterval(this.simTimer);
|
clearInterval(this.simTimer);
|
||||||
this.simTimer = null;
|
this.simTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this._csTransitionTimer !== null) {
|
||||||
|
clearTimeout(this._csTransitionTimer);
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}
|
||||||
this._lfcUnsub?.();
|
this._lfcUnsub?.();
|
||||||
this._lfcUnsub = null;
|
this._lfcUnsub = null;
|
||||||
this.lfClient?.disconnect();
|
this.lfClient?.disconnect();
|
||||||
|
|
@ -508,6 +532,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
let content = "";
|
let content = "";
|
||||||
if (this.demoTab === "spider") content = this.renderSpider();
|
if (this.demoTab === "spider") content = this.renderSpider();
|
||||||
else if (this.demoTab === "ranking") content = this.renderRanking();
|
else if (this.demoTab === "ranking") content = this.renderRanking();
|
||||||
|
else if (this.demoTab === "crowdsurf") content = this.renderCrowdSurf();
|
||||||
else content = this.renderVoting();
|
else content = this.renderVoting();
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
|
|
@ -558,6 +583,30 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
.vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; }
|
.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); }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
@ -569,6 +618,9 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
.rank-name { font-size: 0.875rem; }
|
.rank-name { font-size: 0.875rem; }
|
||||||
.vote-option { padding: 0.625rem 0.75rem; }
|
.vote-option { padding: 0.625rem 0.75rem; }
|
||||||
.spider-svg { max-width: 300px; }
|
.spider-svg { max-width: 300px; }
|
||||||
|
.cs-card { padding: 1.25rem; border-radius: 12px; }
|
||||||
|
.cs-card-option { font-size: 1.1rem; }
|
||||||
|
.cs-btn-skip, .cs-btn-approve { width: 46px; height: 46px; font-size: 1.1rem; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -708,6 +760,324 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- CrowdSurf helpers -- */
|
||||||
|
|
||||||
|
private static mulberry32(seed: number): () => number {
|
||||||
|
let s = seed | 0;
|
||||||
|
return () => {
|
||||||
|
s = (s + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hashString(str: string): number {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCrowdSurfOptions() {
|
||||||
|
const myDid = getMyDid() || 'anon';
|
||||||
|
const lsKey = `cs_swiped:${this.space}:${myDid}`;
|
||||||
|
|
||||||
|
// Restore persisted swipes
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(lsKey);
|
||||||
|
if (saved) {
|
||||||
|
const entries: [string, 'right' | 'left'][] = JSON.parse(saved);
|
||||||
|
this.csSwipedMap = new Map(entries);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Build pool from live sessions or demo data
|
||||||
|
let pool: CrowdSurfOption[] = [];
|
||||||
|
|
||||||
|
// Try live sessions first
|
||||||
|
const openSessions = this.sessions.filter(s => !s.closed);
|
||||||
|
for (const session of openSessions) {
|
||||||
|
const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote';
|
||||||
|
for (const opt of session.options) {
|
||||||
|
pool.push({
|
||||||
|
optionId: opt.id,
|
||||||
|
label: opt.label,
|
||||||
|
color: opt.color,
|
||||||
|
sessionId: session.id,
|
||||||
|
sessionTitle: session.title,
|
||||||
|
sessionType: sType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo mode fallback
|
||||||
|
if (this.space === 'demo' && pool.length === 0) {
|
||||||
|
for (const opt of this.voteOptions) {
|
||||||
|
pool.push({
|
||||||
|
optionId: opt.id,
|
||||||
|
label: opt.name,
|
||||||
|
color: opt.color,
|
||||||
|
sessionId: 'demo-vote',
|
||||||
|
sessionTitle: 'Movie Night',
|
||||||
|
sessionType: 'vote',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of this.rankItems) {
|
||||||
|
pool.push({
|
||||||
|
optionId: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
color: '#f59e0b',
|
||||||
|
sessionId: 'demo-rank',
|
||||||
|
sessionTitle: 'Lunch Spot',
|
||||||
|
sessionType: 'rank',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter already swiped
|
||||||
|
pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`));
|
||||||
|
|
||||||
|
// Seeded shuffle (Fisher-Yates)
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`);
|
||||||
|
const rng = FolkChoicesDashboard.mulberry32(seed);
|
||||||
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.csOptions = pool.slice(0, Math.min(10, pool.length));
|
||||||
|
this.csCurrentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCrowdSurf(): string {
|
||||||
|
// Build options if empty or all consumed
|
||||||
|
if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) {
|
||||||
|
this.buildCrowdSurfOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No options at all
|
||||||
|
if (this.csOptions.length === 0) {
|
||||||
|
return `<div class="cs-inline">
|
||||||
|
<div style="text-align:center;padding:2rem 0;">
|
||||||
|
<div style="font-size:2.5rem;margin-bottom:0.75rem;">🏄</div>
|
||||||
|
<p style="color:var(--rs-text-secondary);margin:0 0 1rem;">No open polls to surf yet.</p>
|
||||||
|
<p style="color:var(--rs-text-muted);font-size:0.8rem;margin:0 0 1rem;">Create a poll in the Voting tab, then come back to swipe!</p>
|
||||||
|
${this.csSwipedMap.size > 0 ? `<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All swiped — show summary
|
||||||
|
if (this.csCurrentIndex >= this.csOptions.length) {
|
||||||
|
return this.renderCrowdSurfSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active card
|
||||||
|
const opt = this.csOptions[this.csCurrentIndex];
|
||||||
|
const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length;
|
||||||
|
const typeBadgeColors: Record<string, string> = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' };
|
||||||
|
const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6';
|
||||||
|
|
||||||
|
return `<div class="cs-inline">
|
||||||
|
<div class="cs-progress-header">
|
||||||
|
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${this.csCurrentIndex + 1} of ${this.csOptions.length}</span>
|
||||||
|
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${approved} approved</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-card-stack">
|
||||||
|
<div class="cs-card" id="cs-inline-card">
|
||||||
|
<div class="cs-swipe-indicator cs-swipe-left">✗ Skip</div>
|
||||||
|
<div class="cs-swipe-indicator cs-swipe-right">✓ Approve</div>
|
||||||
|
<div class="cs-card-body">
|
||||||
|
<div class="cs-type-badge" style="background:${badgeColor}20;color:${badgeColor}">${opt.sessionType}</div>
|
||||||
|
<div class="cs-card-session">${this.esc(opt.sessionTitle)}</div>
|
||||||
|
<div class="cs-card-option">
|
||||||
|
<span class="cs-color-dot" style="background:${opt.color}"></span>
|
||||||
|
${this.esc(opt.label)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-swipe-buttons">
|
||||||
|
<button class="cs-btn-skip" data-cs-action="skip" title="Skip">✗</button>
|
||||||
|
<button class="cs-btn-approve" data-cs-action="approve" title="Approve">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCrowdSurfSummary(): string {
|
||||||
|
const approved: CrowdSurfOption[] = [];
|
||||||
|
this.csSwipedMap.forEach((dir, key) => {
|
||||||
|
if (dir !== 'right') return;
|
||||||
|
const [sessionId, optionId] = key.split(':');
|
||||||
|
const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId);
|
||||||
|
if (opt) approved.push(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by session
|
||||||
|
const grouped = new Map<string, CrowdSurfOption[]>();
|
||||||
|
for (const opt of approved) {
|
||||||
|
const list = grouped.get(opt.sessionId) || [];
|
||||||
|
list.push(opt);
|
||||||
|
grouped.set(opt.sessionId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupHtml = '';
|
||||||
|
grouped.forEach((opts) => {
|
||||||
|
const title = opts[0].sessionTitle;
|
||||||
|
const items = opts.map(o =>
|
||||||
|
`<div style="display:flex;align-items:center;gap:6px;padding:4px 0;">
|
||||||
|
<span class="cs-color-dot" style="background:${o.color}"></span>
|
||||||
|
<span style="color:var(--rs-text-primary);font-size:0.9rem;">${this.esc(o.label)}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
groupHtml += `<div style="margin-bottom:1rem;">
|
||||||
|
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rs-text-muted);margin-bottom:4px;">${this.esc(title)}</div>
|
||||||
|
${items}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<div class="cs-inline">
|
||||||
|
<div style="text-align:center;padding:1.5rem 0;">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem;">✓</div>
|
||||||
|
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;font-size:1.1rem;">All done!</h3>
|
||||||
|
<p style="color:var(--rs-text-secondary);margin:0 0 1.25rem;font-size:0.85rem;">
|
||||||
|
You approved ${approved.length} of ${this.csSwipedMap.size} options
|
||||||
|
</p>
|
||||||
|
${groupHtml || `<p style="color:var(--rs-text-muted);font-size:0.85rem;">No approvals this round.</p>`}
|
||||||
|
<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupCrowdSurfSwipe() {
|
||||||
|
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
// Clear any pending transition timer from previous card
|
||||||
|
if (this._csTransitionTimer !== null) {
|
||||||
|
clearTimeout(this._csTransitionTimer);
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = (clientX: number) => {
|
||||||
|
if (this.csIsAnimating) return;
|
||||||
|
// Clear any lingering transition
|
||||||
|
if (this._csTransitionTimer !== null) {
|
||||||
|
clearTimeout(this._csTransitionTimer);
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}
|
||||||
|
card.style.transition = '';
|
||||||
|
this.csStartX = clientX;
|
||||||
|
this.csCurrentX = clientX;
|
||||||
|
this.csIsDragging = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (clientX: number) => {
|
||||||
|
if (!this.csIsDragging || this.csIsAnimating) return;
|
||||||
|
this.csCurrentX = clientX;
|
||||||
|
const diffX = this.csCurrentX - this.csStartX;
|
||||||
|
const rotation = diffX * 0.1;
|
||||||
|
card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`;
|
||||||
|
|
||||||
|
const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement;
|
||||||
|
const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement;
|
||||||
|
|
||||||
|
if (diffX < -50) {
|
||||||
|
leftInd?.classList.add('show');
|
||||||
|
rightInd?.classList.remove('show');
|
||||||
|
} else if (diffX > 50) {
|
||||||
|
rightInd?.classList.add('show');
|
||||||
|
leftInd?.classList.remove('show');
|
||||||
|
} else {
|
||||||
|
leftInd?.classList.remove('show');
|
||||||
|
rightInd?.classList.remove('show');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
if (!this.csIsDragging || this.csIsAnimating) return;
|
||||||
|
this.csIsDragging = false;
|
||||||
|
const diffX = this.csCurrentX - this.csStartX;
|
||||||
|
|
||||||
|
card.querySelector('.cs-swipe-left')?.classList.remove('show');
|
||||||
|
card.querySelector('.cs-swipe-right')?.classList.remove('show');
|
||||||
|
|
||||||
|
if (Math.abs(diffX) > 100) {
|
||||||
|
const direction = diffX > 0 ? 1 : -1;
|
||||||
|
this.csIsAnimating = true;
|
||||||
|
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
|
||||||
|
card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`;
|
||||||
|
card.style.opacity = '0';
|
||||||
|
this._csTransitionTimer = window.setTimeout(() => {
|
||||||
|
card.style.transform = '';
|
||||||
|
card.style.opacity = '';
|
||||||
|
card.style.transition = '';
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left');
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
card.style.transition = 'transform 0.2s ease-out';
|
||||||
|
card.style.transform = '';
|
||||||
|
this._csTransitionTimer = window.setTimeout(() => {
|
||||||
|
card.style.transition = '';
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
card.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
card.setPointerCapture(e.pointerId);
|
||||||
|
card.style.touchAction = 'none';
|
||||||
|
handleStart(e.clientX);
|
||||||
|
});
|
||||||
|
card.addEventListener('pointermove', (e: PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMove(e.clientX);
|
||||||
|
});
|
||||||
|
card.addEventListener('pointerup', () => handleEnd());
|
||||||
|
card.addEventListener('pointercancel', () => {
|
||||||
|
this.csIsDragging = false;
|
||||||
|
card.style.transform = '';
|
||||||
|
card.style.transition = '';
|
||||||
|
card.style.opacity = '';
|
||||||
|
card.style.touchAction = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCrowdSurfSwipe(direction: 'right' | 'left') {
|
||||||
|
this.csIsAnimating = false;
|
||||||
|
if (this.csCurrentIndex >= this.csOptions.length) return;
|
||||||
|
|
||||||
|
const opt = this.csOptions[this.csCurrentIndex];
|
||||||
|
const swipeKey = `${opt.sessionId}:${opt.optionId}`;
|
||||||
|
this.csSwipedMap.set(swipeKey, direction);
|
||||||
|
|
||||||
|
// Persist to localStorage
|
||||||
|
const myDid = getMyDid() || 'anon';
|
||||||
|
const lsKey = `cs_swiped:${this.space}:${myDid}`;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries())));
|
||||||
|
} catch { /* quota */ }
|
||||||
|
|
||||||
|
// Cast vote on right swipe (live mode)
|
||||||
|
if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') {
|
||||||
|
const did = getMyDid();
|
||||||
|
if (did) {
|
||||||
|
const existing = this.lfClient.getMyVote(opt.sessionId, did);
|
||||||
|
const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 };
|
||||||
|
this.lfClient.castVote(opt.sessionId, did, newChoices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.csCurrentIndex++;
|
||||||
|
this.renderDemo();
|
||||||
|
this.bindDemoEvents();
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Demo event binding -- */
|
/* -- Demo event binding -- */
|
||||||
|
|
||||||
private bindDemoEvents() {
|
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 {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,9 @@ routes.get("/", (c) => {
|
||||||
routes.get("/:tab", (c) => {
|
routes.get("/:tab", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
const tab = c.req.param("tab");
|
const tab = c.req.param("tab");
|
||||||
const validTabs = ["spider", "ranking", "voting"];
|
const validTabs = ["spider", "ranking", "voting", "crowdsurf"];
|
||||||
if (!validTabs.includes(tab)) return c.notFound();
|
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({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — ${tabLabel} | rChoices`,
|
title: `${spaceSlug} — ${tabLabel} | rChoices`,
|
||||||
moduleId: "rchoices",
|
moduleId: "rchoices",
|
||||||
|
|
@ -161,5 +161,6 @@ export const choicesModule: RSpaceModule = {
|
||||||
{ path: "spider", name: "Spider Chart", icon: "🕸", description: "Multi-criteria radar charts" },
|
{ path: "spider", name: "Spider Chart", icon: "🕸", description: "Multi-criteria radar charts" },
|
||||||
{ path: "ranking", name: "Ranking", icon: "📊", description: "Drag-and-drop rankings" },
|
{ path: "ranking", name: "Ranking", icon: "📊", description: "Drag-and-drop rankings" },
|
||||||
{ path: "voting", name: "Voting", icon: "☑", description: "Live polls and voting" },
|
{ path: "voting", name: "Voting", icon: "☑", description: "Live polls and voting" },
|
||||||
|
{ path: "crowdsurf", name: "CrowdSurf", icon: "🏄", description: "Swipe-based option surfacing" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ import { chatsModule } from "../modules/rchats/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 { vnbModule } from "../modules/rvnb/mod";
|
import { vnbModule } from "../modules/rvnb/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";
|
||||||
|
|
@ -118,6 +119,7 @@ registerModule(meetsModule);
|
||||||
registerModule(chatsModule);
|
registerModule(chatsModule);
|
||||||
registerModule(bnbModule);
|
registerModule(bnbModule);
|
||||||
registerModule(vnbModule);
|
registerModule(vnbModule);
|
||||||
|
registerModule(crowdsurfModule);
|
||||||
// De-emphasized modules (bottom of menu)
|
// De-emphasized modules (bottom of menu)
|
||||||
registerModule(forumModule);
|
registerModule(forumModule);
|
||||||
registerModule(tubeModule);
|
registerModule(tubeModule);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
|
||||||
rvnb: { badge: "r🚐", color: "#a5f3fc" },
|
rvnb: { badge: "r🚐", color: "#a5f3fc" },
|
||||||
rtasks: { badge: "r📋", color: "#cbd5e1" },
|
rtasks: { badge: "r📋", color: "#cbd5e1" },
|
||||||
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
||||||
|
crowdsurf: { badge: "r🏄", color: "#fde68a" },
|
||||||
rids: { badge: "r🪪", color: "#6ee7b7" },
|
rids: { badge: "r🪪", color: "#6ee7b7" },
|
||||||
rstack: { badge: "r✨", color: "#c4b5fd" },
|
rstack: { badge: "r✨", color: "#c4b5fd" },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
||||||
// Coordinate
|
// Coordinate
|
||||||
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
||||||
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
||||||
|
crowdsurf: { badge: "r🏄", color: "#fde68a" }, // amber-200
|
||||||
// Identity & Infrastructure
|
// Identity & Infrastructure
|
||||||
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
|
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
|
||||||
rstack: { badge: "r✨", color: "" }, // gradient (handled separately)
|
rstack: { badge: "r✨", color: "" }, // gradient (handled separately)
|
||||||
|
|
@ -84,6 +85,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
|
||||||
rtasks: "Coordinate",
|
rtasks: "Coordinate",
|
||||||
rchoices: "Coordinate",
|
rchoices: "Coordinate",
|
||||||
rvote: "Coordinate",
|
rvote: "Coordinate",
|
||||||
|
crowdsurf: "Coordinate",
|
||||||
// Connect
|
// Connect
|
||||||
rnetwork: "Connect",
|
rnetwork: "Connect",
|
||||||
rsocials: "Connect",
|
rsocials: "Connect",
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,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