vibe coded v1

This commit is contained in:
khannurien
2026-03-16 07:34:49 +00:00
parent 6207a7549f
commit e88fed4e98
48 changed files with 4303 additions and 595 deletions

200
src/contexts/WSProvider.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { WSContext, type WSContextValue } from "./WSContext.ts";
import { WS_URL } from "../config/api.ts";
import type { Dump, OnlineUser } from "../model.ts";
interface WSProviderProps {
children: ReactNode;
token: string | null;
}
const MAX_BACKOFF = 30_000;
const ACK_TIMEOUT = 5_000;
export function WSProvider({ children, token }: WSProviderProps) {
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
const socketRef = useRef<WebSocket | null>(null);
// Tracks pending optimistic votes: dumpId → revert timeout ID
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
let closed = false;
let backoff = 500;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (closed) return;
const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const ws = new WebSocket(url);
socketRef.current = ws;
ws.onmessage = (event) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
switch (msg.type) {
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
case "welcome": {
backoff = 500; // reset backoff on successful connect
const users = msg.users as OnlineUser[];
const votes = msg.myVotes as string[];
setOnlineUsers(users);
setMyVotes(new Set(votes));
break;
}
case "presence_update":
setOnlineUsers(msg.users as OnlineUser[]);
break;
case "votes_update": {
const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number };
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
break;
}
case "dump_created": {
const dump = msg.dump as Dump;
setRecentDumps((prev) => [dump, ...prev]);
break;
}
case "dump_deleted": {
const dumpId = msg.dumpId as string;
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
break;
}
case "vote_ack": {
const { dumpId, action, voteCount } = msg as {
dumpId: string;
action: "cast" | "remove";
voteCount: number;
};
// Clear pending revert timeout
const timeout = pendingRef.current.get(dumpId);
if (timeout !== undefined) {
clearTimeout(timeout);
pendingRef.current.delete(dumpId);
}
// Reconcile with authoritative count
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
// Confirm vote state
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
else next.delete(dumpId);
return next;
});
break;
}
case "error":
// On error, revert any pending optimistic update for the affected dump
// (the revert timeout will handle it)
break;
}
};
ws.onclose = () => {
if (closed) return;
reconnectTimer = setTimeout(() => {
backoff = Math.min(backoff * 2, MAX_BACKOFF);
connect();
}, backoff);
};
ws.onerror = () => {
// onclose will fire after onerror
};
}
connect();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socketRef.current?.close();
socketRef.current = null;
// Clear all pending revert timeouts
for (const t of pendingRef.current.values()) clearTimeout(t);
pendingRef.current.clear();
};
}, [token]);
const castVote = useCallback((dumpId: string) => {
// Optimistic update
const prevCount = voteCountsRef.current[dumpId] ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (prevVoted) return; // already voted
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
// Schedule revert if no ack
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId }));
}, []);
const removeVote = useCallback((dumpId: string) => {
// Optimistic update
const prevCount = voteCountsRef.current[dumpId] ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return; // not voted
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: Math.max(0, prevCount - 1) }));
// Schedule revert if no ack
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
}, []);
const value: WSContextValue = {
onlineUsers,
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
castVote,
removeVote,
};
return (
<WSContext.Provider value={value}>
{children}
</WSContext.Provider>
);
}