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"> <Badge variant="secondary" className="text-sm">
Interactive Demo Interactive Demo
</Badge> </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"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Experience how rVote works without creating an account. Click the vote These polls are synced in real-time across the entire r* ecosystem via rSpace.
arrows to rank proposalswatch how quadratic costs scale in real-time. Vote on options and watch tallies update live for everyone.
</p> </p>
</div> </div>

View File

@ -124,11 +124,11 @@ export default function HomePage() {
<section className="py-8"> <section className="py-8">
<div className="text-center mb-6"> <div className="text-center mb-6">
<Badge variant="secondary" className="mb-3"> <Badge variant="secondary" className="mb-3">
Interactive Demo Live Demo
</Badge> </Badge>
<h2 className="text-2xl font-bold">Try It Yourself</h2> <h2 className="text-2xl font-bold">Try It Yourself</h2>
<p className="text-muted-foreground max-w-2xl mx-auto"> <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> </p>
</div> </div>
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">

View File

@ -1,372 +1,239 @@
"use client"; "use client";
import { useState } from "react"; import { useDemoSync, DemoShape } from "@/lib/demo-sync";
import { Button } from "@/components/ui/button"; 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 { Badge } from "@/components/ui/badge";
import { import {
ChevronUp, Plus,
ChevronDown,
Check,
X,
Minus, Minus,
RotateCcw, RotateCcw,
Coins, Vote,
TrendingUp, Users,
Clock, Clock,
Wifi,
WifiOff,
Loader2,
} from "lucide-react"; } from "lucide-react";
interface DemoProposal { // ── Types ────────────────────────────────────────────────────────────
id: number;
title: string; interface PollOption {
description: string; label: string;
score: number; votes: number;
userVote: number;
pendingVote: number;
stage: "ranking" | "voting";
yesVotes: number;
noVotes: number;
} }
const initialProposals: DemoProposal[] = [ interface DemoPoll extends DemoShape {
{ question: string;
id: 1, options: PollOption[];
title: "Add dark mode toggle to the dashboard", totalVoters: number;
description: "Implement a system-aware dark/light theme switch so users can choose their preferred viewing mode", status: "active" | "closed";
score: 72, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, endsAt: string;
}, }
{
id: 2, function isPoll(shape: DemoShape): shape is DemoPoll {
title: "Build mobile-responsive voting interface", return shape.type === "demo-poll" && Array.isArray((shape as DemoPoll).options);
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,
}, // ── Component ────────────────────────────────────────────────────────
{
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,
},
];
export function InteractiveDemo() { export function InteractiveDemo() {
const [credits, setCredits] = useState(100); const { shapes, updateShape, connected, resetDemo } = useDemoSync({
const [proposals, setProposals] = useState<DemoProposal[]>(initialProposals); 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) { function handleVote(poll: DemoPoll, optionIndex: number, delta: number) {
setProposals((prev) => const updatedOptions = poll.options.map((opt, i) => {
prev.map((p) => { if (i !== optionIndex) return opt;
if (p.id !== proposalId) return p; return { ...opt, votes: Math.max(0, opt.votes + delta) };
if (p.userVote > 0) { });
const refund = p.userVote * p.userVote; updateShape(poll.id, { options: updatedOptions });
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 handleDownvote(proposalId: number) { async function handleReset() {
setProposals((prev) => try {
prev.map((p) => { await resetDemo();
if (p.id !== proposalId) return p; } catch (err) {
if (p.userVote < 0) { console.error("Reset failed:", err);
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;
})
);
} }
function cancelPending(proposalId: number) { // Total votes across all options in a poll
setProposals((prev) => function totalVotes(poll: DemoPoll): number {
prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p)) return poll.options.reduce((sum, opt) => sum + opt.votes, 0);
);
} }
function confirmVote(proposalId: number) { // Format the deadline
setProposals((prev) => function formatDeadline(dateStr: string): string {
prev.map((p) => { const date = new Date(dateStr);
if (p.id !== proposalId || p.pendingVote === 0) return p; const now = new Date();
const cost = p.pendingVote * p.pendingVote; const diff = date.getTime() - now.getTime();
const newScore = p.score + p.pendingVote; if (diff <= 0) return "Voting closed";
const promoted = newScore >= 100 && p.stage === "ranking"; const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
setCredits((c) => c - cost); return `${days} day${days !== 1 ? "s" : ""} left`;
return {
...p, score: newScore, userVote: p.pendingVote, pendingVote: 0,
stage: promoted ? "voting" : p.stage,
yesVotes: promoted ? 8 : p.yesVotes, noVotes: promoted ? 3 : p.noVotes,
};
})
);
} }
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Credits display */} {/* Connection status + Reset */}
<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 justify-between">
<div className="flex items-center gap-2 sm:gap-4"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <Badge
<Coins className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500" /> variant="outline"
<span className="font-bold text-xl sm:text-2xl text-orange-600">{credits}</span> className={
<span className="text-muted-foreground text-sm sm:text-base">credits</span> connected
</div> ? "border-green-500/50 text-green-600 bg-green-500/10"
<Badge variant="outline" className="border-orange-500/30 text-orange-600"> : "border-red-500/50 text-red-600 bg-red-500/10"
Max vote: &plusmn;{maxWeight} }
>
{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> </Badge>
</div> </div>
<Button variant="outline" size="sm" onClick={resetDemo} className="border-orange-500/30 hover:bg-orange-500/10"> <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" /> <RotateCcw className="h-4 w-4 mr-2" />
Reset Reset Demo
</Button> </Button>
</div> </div>
{/* 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> </CardContent>
</Card> </Card>
)}
{/* Quadratic cost explainer */} {/* No polls found */}
<Card className="border-muted"> {!loading && polls.length === 0 && (
<CardHeader className="pb-2"> <Card className="border-dashed border-2 border-muted">
<CardTitle className="text-lg flex items-center gap-2"> <CardContent className="py-12 text-center">
<TrendingUp className="h-5 w-5 text-orange-500" /> <Vote className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
Quadratic Voting Cost <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> </CardTitle>
<CardDescription>Each additional vote costs exponentially more credits</CardDescription> </div>
</CardHeader> <div className="flex items-center gap-2 shrink-0">
<CardContent> <Badge
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 text-center text-sm"> variant={poll.status === "active" ? "default" : "secondary"}
{[1, 2, 3, 4, 5].map((w) => ( className={
<div poll.status === "active"
key={w} ? "bg-orange-500 hover:bg-orange-600"
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> {poll.status === "active" ? "Active" : "Closed"}
<div className="text-xs opacity-70">vote{w > 1 ? "s" : ""}</div> </Badge>
<div className="font-mono text-sm mt-1 font-semibold">{w * w}&cent;</div>
</div> </div>
))}
</div> </div>
</CardContent> <div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
</Card> <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>
{/* Ranking stage */} <CardContent className="space-y-3">
<section className="space-y-3"> {poll.options.map((option, idx) => {
<div className="flex items-center gap-3"> const pct = total > 0 ? (option.votes / total) * 100 : 0;
<Badge className="bg-orange-500 hover:bg-orange-600">Stage 1</Badge> const barWidth = maxVotes > 0 ? (option.votes / maxVotes) * 100 : 0;
<h2 className="text-xl font-semibold">Quadratic Ranking</h2>
<span className="text-muted-foreground text-sm">Score +100 to advance &rarr;</span>
</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;
return ( return (
<div <div
key={proposal.id} key={idx}
className={`flex rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 ${ className="flex items-center gap-3 group"
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]"> {/* Vote buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button <Button
variant="ghost" size="sm" variant="ghost"
className={`h-10 w-10 p-0 rounded-md transition-all ${ size="sm"
isUpvoted ? "text-orange-500 bg-orange-500/10 hover:bg-orange-500/20" className="h-8 w-8 p-0 rounded-md text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10 transition-all"
: "text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10" onClick={() => handleVote(poll, idx, -1)}
} ${hasVoted && proposal.userVote < 0 ? "opacity-30 pointer-events-none" : ""}`} disabled={!connected || option.votes <= 0}
onClick={() => handleUpvote(proposal.id)}
> >
<ChevronUp className="h-7 w-7" strokeWidth={2.5} /> <Minus className="h-4 w-4" />
</Button> </Button>
<span className={`font-bold text-xl tabular-nums py-1 ${ <span className="w-8 text-center font-bold tabular-nums text-sm">
isUpvoted ? "text-orange-500" : isDownvoted ? "text-blue-500" : "text-foreground" {option.votes}
}`}>{displayScore}</span> </span>
<Button <Button
variant="ghost" size="sm" variant="ghost"
className={`h-10 w-10 p-0 rounded-md transition-all ${ size="sm"
isDownvoted ? "text-blue-500 bg-blue-500/10 hover:bg-blue-500/20" className="h-8 w-8 p-0 rounded-md text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10 transition-all"
: "text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10" onClick={() => handleVote(poll, idx, 1)}
} ${hasVoted && proposal.userVote > 0 ? "opacity-30 pointer-events-none" : ""}`} disabled={!connected}
onClick={() => handleDownvote(proposal.id)}
> >
<ChevronDown className="h-7 w-7" strokeWidth={2.5} /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="flex-1 p-4 min-w-0"> {/* Option label + progress bar */}
<h3 className="font-semibold text-base leading-tight">{proposal.title}</h3> <div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{proposal.description}</p> <div className="flex items-center justify-between mb-1">
<div className="mt-3"> <span className="text-sm font-medium truncate pr-2">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1"> {option.label}
<span>Progress to voting stage</span> </span>
<span className={isUpvoted ? "text-orange-500 font-medium" : isDownvoted ? "text-blue-500 font-medium" : ""}> <span className="text-xs text-muted-foreground tabular-nums shrink-0">
{displayScore}/100 {Math.round(pct)}%
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-muted overflow-hidden"> <div className="h-2.5 rounded-full bg-muted overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-300 ${ className="h-full rounded-full bg-gradient-to-r from-orange-400 to-orange-500 transition-all duration-300 ease-out"
isUpvoted ? "bg-orange-500" : isDownvoted ? "bg-blue-500" : "bg-primary" style={{ width: `${barWidth}%` }}
}`}
style={{ width: `${progressPercent}%` }}
/> />
</div> </div>
</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>
</div> </div>
); );
})} })}
</div>
</section>
{/* 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>
</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> </CardContent>
</Card> </Card>
); );
})} })}
</section>
)}
</div> </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 };
}