feat: rewrite demo with live rSpace data via useDemoSync

Replace local state InteractiveDemo with real-time WebSocket connection
to the shared demo community. Votes sync across the r* ecosystem in
real-time. Updated home page section header to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:39:11 -07:00
parent 8276dacc24
commit 160a556a48
4 changed files with 426 additions and 338 deletions

View File

@ -12,10 +12,10 @@ export default function DemoPage() {
<Badge variant="secondary" className="text-sm">
Interactive Demo
</Badge>
<h1 className="text-4xl font-bold">Try Quadratic Proposal Ranking</h1>
<h1 className="text-4xl font-bold">Live Community Polls</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Experience how rVote works without creating an account. Click the vote
arrows to rank proposalswatch how quadratic costs scale in real-time.
These polls are synced in real-time across the entire r* ecosystem via rSpace.
Vote on options and watch tallies update live for everyone.
</p>
</div>

View File

@ -124,11 +124,11 @@ export default function HomePage() {
<section className="py-8">
<div className="text-center mb-6">
<Badge variant="secondary" className="mb-3">
Interactive Demo
Live Demo
</Badge>
<h2 className="text-2xl font-bold">Try It Yourself</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Click the vote arrows to rank proposals. Watch how quadratic costs scale in real-time.
Vote on live polls synced across the r* ecosystem. Changes appear in real-time for everyone.
</p>
</div>
<div className="max-w-4xl mx-auto">

View File

@ -1,372 +1,239 @@
"use client";
import { useState } from "react";
import { useDemoSync, DemoShape } from "@/lib/demo-sync";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
ChevronUp,
ChevronDown,
Check,
X,
Plus,
Minus,
RotateCcw,
Coins,
TrendingUp,
Vote,
Users,
Clock,
Wifi,
WifiOff,
Loader2,
} from "lucide-react";
interface DemoProposal {
id: number;
title: string;
description: string;
score: number;
userVote: number;
pendingVote: number;
stage: "ranking" | "voting";
yesVotes: number;
noVotes: number;
// ── Types ────────────────────────────────────────────────────────────
interface PollOption {
label: string;
votes: number;
}
const initialProposals: DemoProposal[] = [
{
id: 1,
title: "Add dark mode toggle to the dashboard",
description: "Implement a system-aware dark/light theme switch so users can choose their preferred viewing mode",
score: 72, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 2,
title: "Build mobile-responsive voting interface",
description: "Redesign the voting UI to work seamlessly on phones and tablets so members can vote on the go",
score: 58, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 3,
title: "Add email notifications for promoted proposals",
description: "Send members an email when a proposal they voted on advances to the pass/fail voting stage",
score: 41, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 4,
title: "Create public API for proposal data",
description: "Expose a read-only REST API so external tools and dashboards can display proposal rankings",
score: 35, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 5,
title: "Add proposal tagging and filtering",
description: "Let authors tag proposals by category (feature, bug, process) and allow users to filter the list",
score: 23, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
];
interface DemoPoll extends DemoShape {
question: string;
options: PollOption[];
totalVoters: number;
status: "active" | "closed";
endsAt: string;
}
function isPoll(shape: DemoShape): shape is DemoPoll {
return shape.type === "demo-poll" && Array.isArray((shape as DemoPoll).options);
}
// ── Component ────────────────────────────────────────────────────────
export function InteractiveDemo() {
const [credits, setCredits] = useState(100);
const [proposals, setProposals] = useState<DemoProposal[]>(initialProposals);
const { shapes, updateShape, connected, resetDemo } = useDemoSync({
filter: ["demo-poll"],
});
const maxWeight = Math.floor(Math.sqrt(credits));
const polls = Object.values(shapes).filter(isPoll);
const loading = !connected && polls.length === 0;
function handleUpvote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId) return p;
if (p.userVote > 0) {
const refund = p.userVote * p.userVote;
setCredits((c) => c + refund);
return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 };
}
if (p.userVote < 0) return p;
const newPending = p.pendingVote + 1;
const newCost = newPending * newPending;
if (newCost <= credits && newPending <= maxWeight) {
return { ...p, pendingVote: newPending };
}
return p;
})
);
function handleVote(poll: DemoPoll, optionIndex: number, delta: number) {
const updatedOptions = poll.options.map((opt, i) => {
if (i !== optionIndex) return opt;
return { ...opt, votes: Math.max(0, opt.votes + delta) };
});
updateShape(poll.id, { options: updatedOptions });
}
function handleDownvote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId) return p;
if (p.userVote < 0) {
const refund = p.userVote * p.userVote;
setCredits((c) => c + refund);
return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 };
}
if (p.userVote > 0) return p;
const newPending = p.pendingVote - 1;
const newCost = newPending * newPending;
if (newCost <= credits && Math.abs(newPending) <= maxWeight) {
return { ...p, pendingVote: newPending };
}
return p;
})
);
async function handleReset() {
try {
await resetDemo();
} catch (err) {
console.error("Reset failed:", err);
}
}
function cancelPending(proposalId: number) {
setProposals((prev) =>
prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p))
);
// Total votes across all options in a poll
function totalVotes(poll: DemoPoll): number {
return poll.options.reduce((sum, opt) => sum + opt.votes, 0);
}
function confirmVote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId || p.pendingVote === 0) return p;
const cost = p.pendingVote * p.pendingVote;
const newScore = p.score + p.pendingVote;
const promoted = newScore >= 100 && p.stage === "ranking";
setCredits((c) => c - cost);
return {
...p, score: newScore, userVote: p.pendingVote, pendingVote: 0,
stage: promoted ? "voting" : p.stage,
yesVotes: promoted ? 8 : p.yesVotes, noVotes: promoted ? 3 : p.noVotes,
};
})
);
// Format the deadline
function formatDeadline(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff <= 0) return "Voting closed";
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
return `${days} day${days !== 1 ? "s" : ""} left`;
}
function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") {
setProposals((prev) =>
prev.map((p) => {
if (p.id === proposalId) {
return {
...p,
yesVotes: vote === "yes" ? p.yesVotes + 1 : p.yesVotes,
noVotes: vote === "no" ? p.noVotes + 1 : p.noVotes,
};
}
return p;
})
);
}
function resetDemo() {
setCredits(100);
setProposals(initialProposals);
}
const rankingProposals = proposals.filter((p) => p.stage === "ranking").sort((a, b) => b.score - a.score);
const votingProposals = proposals.filter((p) => p.stage === "voting");
return (
<div className="space-y-6">
{/* Credits display */}
<Card className="border-2 border-orange-500/30 bg-gradient-to-r from-orange-500/10 to-amber-500/10">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-4">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500" />
<span className="font-bold text-xl sm:text-2xl text-orange-600">{credits}</span>
<span className="text-muted-foreground text-sm sm:text-base">credits</span>
</div>
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
Max vote: &plusmn;{maxWeight}
</Badge>
</div>
<Button variant="outline" size="sm" onClick={resetDemo} className="border-orange-500/30 hover:bg-orange-500/10">
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
</div>
</CardContent>
</Card>
{/* Quadratic cost explainer */}
<Card className="border-muted">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-orange-500" />
Quadratic Voting Cost
</CardTitle>
<CardDescription>Each additional vote costs exponentially more credits</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 text-center text-sm">
{[1, 2, 3, 4, 5].map((w) => (
<div
key={w}
className={`p-2 sm:p-3 rounded-lg border-2 transition-all ${
w <= maxWeight
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
: "bg-muted/50 border-muted text-muted-foreground"
}`}
>
<div className="font-bold text-xl">+{w}</div>
<div className="text-xs opacity-70">vote{w > 1 ? "s" : ""}</div>
<div className="font-mono text-sm mt-1 font-semibold">{w * w}&cent;</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Ranking stage */}
<section className="space-y-3">
{/* Connection status + Reset */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge className="bg-orange-500 hover:bg-orange-600">Stage 1</Badge>
<h2 className="text-xl font-semibold">Quadratic Ranking</h2>
<span className="text-muted-foreground text-sm">Score +100 to advance &rarr;</span>
<Badge
variant="outline"
className={
connected
? "border-green-500/50 text-green-600 bg-green-500/10"
: "border-red-500/50 text-red-600 bg-red-500/10"
}
>
{connected ? (
<Wifi className="h-3 w-3 mr-1" />
) : (
<WifiOff className="h-3 w-3 mr-1" />
)}
{connected ? "Connected" : "Disconnected"}
</Badge>
<Badge variant="secondary" className="text-xs bg-orange-500/10 text-orange-600 border-orange-500/20">
Live synced across all r* demos
</Badge>
</div>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="border-orange-500/30 hover:bg-orange-500/10"
disabled={!connected}
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset Demo
</Button>
</div>
<div className="space-y-2">
{rankingProposals.map((proposal) => {
const hasPending = proposal.pendingVote !== 0;
const hasVoted = proposal.userVote !== 0;
const pendingCost = proposal.pendingVote * proposal.pendingVote;
const displayScore = hasPending ? proposal.score + proposal.pendingVote : proposal.score;
const progressPercent = Math.min((displayScore / 100) * 100, 100);
const isUpvoted = (hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0;
const isDownvoted = (hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0;
{/* Loading state */}
{loading && (
<Card className="border-dashed border-2 border-muted">
<CardContent className="py-12 text-center">
<Loader2 className="h-8 w-8 animate-spin text-orange-500 mx-auto mb-3" />
<p className="text-muted-foreground">Connecting to rSpace...</p>
</CardContent>
</Card>
)}
return (
<div
key={proposal.id}
className={`flex rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 ${
hasPending
? proposal.pendingVote > 0 ? "ring-2 ring-orange-500/50" : "ring-2 ring-blue-500/50"
: ""
}`}
>
<div className="flex flex-col items-center justify-center py-3 px-4 bg-muted/50 border-r min-w-[72px]">
<Button
variant="ghost" size="sm"
className={`h-10 w-10 p-0 rounded-md transition-all ${
isUpvoted ? "text-orange-500 bg-orange-500/10 hover:bg-orange-500/20"
: "text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10"
} ${hasVoted && proposal.userVote < 0 ? "opacity-30 pointer-events-none" : ""}`}
onClick={() => handleUpvote(proposal.id)}
>
<ChevronUp className="h-7 w-7" strokeWidth={2.5} />
</Button>
<span className={`font-bold text-xl tabular-nums py-1 ${
isUpvoted ? "text-orange-500" : isDownvoted ? "text-blue-500" : "text-foreground"
}`}>{displayScore}</span>
<Button
variant="ghost" size="sm"
className={`h-10 w-10 p-0 rounded-md transition-all ${
isDownvoted ? "text-blue-500 bg-blue-500/10 hover:bg-blue-500/20"
: "text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10"
} ${hasVoted && proposal.userVote > 0 ? "opacity-30 pointer-events-none" : ""}`}
onClick={() => handleDownvote(proposal.id)}
>
<ChevronDown className="h-7 w-7" strokeWidth={2.5} />
</Button>
{/* No polls found */}
{!loading && polls.length === 0 && (
<Card className="border-dashed border-2 border-muted">
<CardContent className="py-12 text-center">
<Vote className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">No polls found. Try resetting the demo.</p>
</CardContent>
</Card>
)}
{/* Poll cards */}
{polls.map((poll) => {
const total = totalVotes(poll);
const maxVotes = Math.max(...poll.options.map((o) => o.votes), 1);
return (
<Card key={poll.id} className="border-2 border-orange-500/20 overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg leading-tight flex items-center gap-2">
<Vote className="h-5 w-5 text-orange-500 shrink-0" />
{poll.question}
</CardTitle>
</div>
<div className="flex-1 p-4 min-w-0">
<h3 className="font-semibold text-base leading-tight">{proposal.title}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{proposal.description}</p>
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Progress to voting stage</span>
<span className={isUpvoted ? "text-orange-500 font-medium" : isDownvoted ? "text-blue-500 font-medium" : ""}>
{displayScore}/100
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
isUpvoted ? "bg-orange-500" : isDownvoted ? "bg-blue-500" : "bg-primary"
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{hasPending && (
<div className="mt-3 flex items-center gap-2">
<Badge variant="outline" className={proposal.pendingVote > 0
? "border-orange-500/50 text-orange-600 bg-orange-500/10"
: "border-blue-500/50 text-blue-600 bg-blue-500/10"
}>
{proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote = {pendingCost} credits
</Badge>
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => cancelPending(proposal.id)}>
<X className="h-4 w-4 mr-1" />Cancel
</Button>
<Button
size="sm"
className={`h-7 ${proposal.pendingVote > 0 ? "bg-orange-500 hover:bg-orange-600" : "bg-blue-500 hover:bg-blue-600"}`}
onClick={() => confirmVote(proposal.id)}
>
<Check className="h-4 w-4 mr-1" />Confirm
</Button>
</div>
)}
{hasVoted && !hasPending && (
<div className="mt-3">
<Badge variant="secondary" className={proposal.userVote > 0
? "bg-orange-500/20 text-orange-600" : "bg-blue-500/20 text-blue-600"
}>
You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote}
</Badge>
</div>
)}
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={poll.status === "active" ? "default" : "secondary"}
className={
poll.status === "active"
? "bg-orange-500 hover:bg-orange-600"
: ""
}
>
{poll.status === "active" ? "Active" : "Closed"}
</Badge>
</div>
</div>
);
})}
</div>
</section>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{poll.totalVoters} voters
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{formatDeadline(poll.endsAt)}
</span>
<span className="ml-auto tabular-nums font-medium text-foreground/70">
{total} total vote{total !== 1 ? "s" : ""}
</span>
</div>
</CardHeader>
{/* Voting stage */}
{votingProposals.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="border-green-500/50 text-green-600">Stage 2</Badge>
<h2 className="text-xl font-semibold">Pass/Fail Voting</h2>
<span className="text-muted-foreground text-sm">One member = one vote</span>
</div>
{votingProposals.map((proposal) => {
const total = proposal.yesVotes + proposal.noVotes;
const yesPercent = total > 0 ? (proposal.yesVotes / total) * 100 : 50;
return (
<Card key={proposal.id} className="border-green-500/30 bg-green-500/5">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{proposal.title}</CardTitle>
<div className="flex items-center gap-1 text-sm text-amber-600">
<Clock className="h-4 w-4" /><span>6 days left</span>
<CardContent className="space-y-3">
{poll.options.map((option, idx) => {
const pct = total > 0 ? (option.votes / total) * 100 : 0;
const barWidth = maxVotes > 0 ? (option.votes / maxVotes) * 100 : 0;
return (
<div
key={idx}
className="flex items-center gap-3 group"
>
{/* Vote buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 rounded-md text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10 transition-all"
onClick={() => handleVote(poll, idx, -1)}
disabled={!connected || option.votes <= 0}
>
<Minus className="h-4 w-4" />
</Button>
<span className="w-8 text-center font-bold tabular-nums text-sm">
{option.votes}
</span>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 rounded-md text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10 transition-all"
onClick={() => handleVote(poll, idx, 1)}
disabled={!connected}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Option label + progress bar */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium truncate pr-2">
{option.label}
</span>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{Math.round(pct)}%
</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-orange-400 to-orange-500 transition-all duration-300 ease-out"
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
</div>
<CardDescription>{proposal.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="h-4 rounded-full overflow-hidden bg-muted flex">
<div className="h-full bg-green-500 transition-all" style={{ width: `${yesPercent}%` }} />
<div className="h-full bg-red-500 transition-all" style={{ width: `${100 - yesPercent}%` }} />
</div>
<div className="flex justify-between text-sm font-medium">
<span className="text-green-600">{proposal.yesVotes} Yes ({Math.round(yesPercent)}%)</span>
<span className="text-red-600">{proposal.noVotes} No ({Math.round(100 - yesPercent)}%)</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<Button variant="outline" className="flex-col h-auto py-3 border-green-500/30 hover:bg-green-500/10 hover:border-green-500"
onClick={() => castFinalVote(proposal.id, "yes")}>
<Check className="h-5 w-5 text-green-500" /><span className="text-xs mt-1">Yes</span>
</Button>
<Button variant="outline" className="flex-col h-auto py-3 border-red-500/30 hover:bg-red-500/10 hover:border-red-500"
onClick={() => castFinalVote(proposal.id, "no")}>
<X className="h-5 w-5 text-red-500" /><span className="text-xs mt-1">No</span>
</Button>
<Button variant="outline" className="flex-col h-auto py-3"
onClick={() => castFinalVote(proposal.id, "abstain")}>
<Minus className="h-5 w-5" /><span className="text-xs mt-1">Abstain</span>
</Button>
</div>
</CardContent>
</Card>
);
})}
</section>
)}
);
})}
</CardContent>
</Card>
);
})}
</div>
);
}

221
src/lib/demo-sync.ts Normal file
View File

@ -0,0 +1,221 @@
/**
* useDemoSync lightweight React hook for real-time demo data via rSpace
*
* Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed).
* All demo pages share the "demo" community, so changes in one app
* propagate to every other app viewing the same shapes.
*
* Usage:
* const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({
* filter: ['folk-note', 'folk-notebook'], // optional: only these shape types
* });
*/
import { useEffect, useRef, useState, useCallback } from 'react';
export interface DemoShape {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
[key: string]: unknown;
}
interface UseDemoSyncOptions {
/** Community slug (default: 'demo') */
slug?: string;
/** Only subscribe to these shape types */
filter?: string[];
/** rSpace server URL (default: auto-detect based on environment) */
serverUrl?: string;
}
interface UseDemoSyncReturn {
/** Current shapes (filtered if filter option set) */
shapes: Record<string, DemoShape>;
/** Update a shape by ID (partial update merged with existing) */
updateShape: (id: string, data: Partial<DemoShape>) => void;
/** Delete a shape by ID */
deleteShape: (id: string) => void;
/** Whether WebSocket is connected */
connected: boolean;
/** Reset demo to seed state */
resetDemo: () => Promise<void>;
}
const DEFAULT_SLUG = 'demo';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const PING_INTERVAL_MS = 30000;
function getDefaultServerUrl(): string {
if (typeof window === 'undefined') return 'https://rspace.online';
// In development, use localhost
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return `http://${window.location.hostname}:3000`;
}
return 'https://rspace.online';
}
export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn {
const slug = options?.slug ?? DEFAULT_SLUG;
const filter = options?.filter;
const serverUrl = options?.serverUrl ?? getDefaultServerUrl();
const [shapes, setShapes] = useState<Record<string, DemoShape>>({});
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
// Stable filter reference for use in callbacks
const filterRef = useRef(filter);
filterRef.current = filter;
const applyFilter = useCallback((allShapes: Record<string, DemoShape>): Record<string, DemoShape> => {
const f = filterRef.current;
if (!f || f.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
for (const [id, shape] of Object.entries(allShapes)) {
if (f.includes(shape.type)) {
filtered[id] = shape;
}
}
return filtered;
}, []);
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Build WebSocket URL
const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws';
const host = serverUrl.replace(/^https?:\/\//, '');
const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) return;
setConnected(true);
reconnectAttemptRef.current = 0;
// Start ping keepalive
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
if (!mountedRef.current) return;
try {
const msg = JSON.parse(event.data);
if (msg.type === 'snapshot' && msg.shapes) {
setShapes(applyFilter(msg.shapes));
}
// pong and error messages are silently handled
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
if (!mountedRef.current) return;
setConnected(false);
cleanup();
scheduleReconnect();
};
ws.onerror = () => {
// onclose will fire after onerror, so reconnect is handled there
};
}, [slug, serverUrl, applyFilter]);
const cleanup = useCallback(() => {
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
}, []);
const scheduleReconnect = useCallback(() => {
if (!mountedRef.current) return;
const attempt = reconnectAttemptRef.current;
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
reconnectAttemptRef.current = attempt + 1;
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) connect();
}, delay);
}, [connect]);
// Connect on mount
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
cleanup();
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null; // prevent reconnect on unmount
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect, cleanup]);
const updateShape = useCallback((id: string, data: Partial<DemoShape>) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local update
setShapes((prev) => {
const existing = prev[id];
if (!existing) return prev;
const updated = { ...existing, ...data, id };
const f = filterRef.current;
if (f && f.length > 0 && !f.includes(updated.type)) return prev;
return { ...prev, [id]: updated };
});
// Send to server
ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } }));
}, []);
const deleteShape = useCallback((id: string) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local delete
setShapes((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
});
ws.send(JSON.stringify({ type: 'delete', id }));
}, []);
const resetDemo = useCallback(async () => {
const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' });
if (!res.ok) {
const body = await res.text();
throw new Error(`Reset failed: ${res.status} ${body}`);
}
// The server will broadcast new snapshot via WebSocket
}, [serverUrl]);
return { shapes, updateShape, deleteShape, connected, resetDemo };
}