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">
|
<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 proposals—watch how quadratic costs scale in real-time.
|
Vote on options and watch tallies update live for everyone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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: ±{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}¢</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 →</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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