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:
parent
8276dacc24
commit
160a556a48
|
|
@ -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 proposals—watch 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: ±{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}¢</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 →</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue