Vote fixing
This commit is contained in:
37
frontend.css
37
frontend.css
@@ -335,6 +335,24 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── My Vote Highlight ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contestant--my-vote {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-vote-tag {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--text-muted);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Viewer Votes ────────────────────────────────────────────── */
|
/* ── Viewer Votes ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.viewer-vote-bar {
|
.viewer-vote-bar {
|
||||||
@@ -358,25 +376,6 @@ body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Vote CTA ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.vote-cta {
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 10px 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
animation: vote-cta-pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes vote-cta-pulse {
|
|
||||||
0%, 100% { opacity: 0.7; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-countdown {
|
.vote-countdown {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
33
frontend.tsx
33
frontend.tsx
@@ -53,7 +53,7 @@ type ViewerCountMessage = {
|
|||||||
type: "viewerCount";
|
type: "viewerCount";
|
||||||
viewerCount: number;
|
viewerCount: number;
|
||||||
};
|
};
|
||||||
type VotedAckMessage = { type: "votedAck" };
|
type VotedAckMessage = { type: "votedAck"; votedFor: "A" | "B" };
|
||||||
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
|
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
|
||||||
|
|
||||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||||
@@ -163,6 +163,7 @@ function ContestantCard({
|
|||||||
totalViewerVotes,
|
totalViewerVotes,
|
||||||
votable,
|
votable,
|
||||||
onVote,
|
onVote,
|
||||||
|
isMyVote,
|
||||||
}: {
|
}: {
|
||||||
task: TaskInfo;
|
task: TaskInfo;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
@@ -174,6 +175,7 @@ function ContestantCard({
|
|||||||
totalViewerVotes?: number;
|
totalViewerVotes?: number;
|
||||||
votable?: boolean;
|
votable?: boolean;
|
||||||
onVote?: () => void;
|
onVote?: () => void;
|
||||||
|
isMyVote?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const color = getColor(task.model.name);
|
const color = getColor(task.model.name);
|
||||||
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
||||||
@@ -184,7 +186,7 @@ function ContestantCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""}`}
|
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""} ${isMyVote ? "contestant--my-vote" : ""}`}
|
||||||
style={{ "--accent": color } as React.CSSProperties}
|
style={{ "--accent": color } as React.CSSProperties}
|
||||||
onClick={votable ? onVote : undefined}
|
onClick={votable ? onVote : undefined}
|
||||||
role={votable ? "button" : undefined}
|
role={votable ? "button" : undefined}
|
||||||
@@ -193,6 +195,7 @@ function ContestantCard({
|
|||||||
>
|
>
|
||||||
<div className="contestant__head">
|
<div className="contestant__head">
|
||||||
<ModelTag model={task.model} />
|
<ModelTag model={task.model} />
|
||||||
|
{isMyVote && <span className="my-vote-tag">YOUR PICK</span>}
|
||||||
{isWinner && <span className="win-tag">WIN</span>}
|
{isWinner && <span className="win-tag">WIN</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,13 +280,13 @@ function ContestantCard({
|
|||||||
function Arena({
|
function Arena({
|
||||||
round,
|
round,
|
||||||
total,
|
total,
|
||||||
hasVoted,
|
myVote,
|
||||||
onVote,
|
onVote,
|
||||||
viewerVotingSecondsLeft,
|
viewerVotingSecondsLeft,
|
||||||
}: {
|
}: {
|
||||||
round: RoundState;
|
round: RoundState;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
hasVoted: boolean;
|
myVote: "A" | "B" | null;
|
||||||
onVote: (side: "A" | "B") => void;
|
onVote: (side: "A" | "B") => void;
|
||||||
viewerVotingSecondsLeft: number;
|
viewerVotingSecondsLeft: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -304,7 +307,6 @@ function Arena({
|
|||||||
|
|
||||||
const canVote =
|
const canVote =
|
||||||
round.phase === "voting" &&
|
round.phase === "voting" &&
|
||||||
!hasVoted &&
|
|
||||||
viewerVotingSecondsLeft > 0 &&
|
viewerVotingSecondsLeft > 0 &&
|
||||||
round.answerTasks[0].finishedAt &&
|
round.answerTasks[0].finishedAt &&
|
||||||
round.answerTasks[1].finishedAt;
|
round.answerTasks[1].finishedAt;
|
||||||
@@ -337,12 +339,6 @@ function Arena({
|
|||||||
|
|
||||||
<PromptCard round={round} />
|
<PromptCard round={round} />
|
||||||
|
|
||||||
{canVote && (
|
|
||||||
<div className="vote-cta">
|
|
||||||
Pick the funnier answer!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{round.phase !== "prompting" && (
|
{round.phase !== "prompting" && (
|
||||||
<div className="showdown">
|
<div className="showdown">
|
||||||
<ContestantCard
|
<ContestantCard
|
||||||
@@ -356,6 +352,7 @@ function Arena({
|
|||||||
totalViewerVotes={totalViewerVotes}
|
totalViewerVotes={totalViewerVotes}
|
||||||
votable={!!canVote}
|
votable={!!canVote}
|
||||||
onVote={() => onVote("A")}
|
onVote={() => onVote("A")}
|
||||||
|
isMyVote={myVote === "A"}
|
||||||
/>
|
/>
|
||||||
<ContestantCard
|
<ContestantCard
|
||||||
task={round.answerTasks[1]}
|
task={round.answerTasks[1]}
|
||||||
@@ -368,6 +365,7 @@ function Arena({
|
|||||||
totalViewerVotes={totalViewerVotes}
|
totalViewerVotes={totalViewerVotes}
|
||||||
votable={!!canVote}
|
votable={!!canVote}
|
||||||
onVote={() => onVote("B")}
|
onVote={() => onVote("B")}
|
||||||
|
isMyVote={myVote === "B"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -492,16 +490,16 @@ function App() {
|
|||||||
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
||||||
const [viewerCount, setViewerCount] = useState(0);
|
const [viewerCount, setViewerCount] = useState(0);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [hasVoted, setHasVoted] = useState(false);
|
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
||||||
const [votedRound, setVotedRound] = useState<number | null>(null);
|
const [votedRound, setVotedRound] = useState<number | null>(null);
|
||||||
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
||||||
const wsRef = React.useRef<WebSocket | null>(null);
|
const wsRef = React.useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
// Reset hasVoted when round changes
|
// Reset vote when round changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentRound = state?.active?.num ?? null;
|
const currentRound = state?.active?.num ?? null;
|
||||||
if (currentRound !== null && currentRound !== votedRound) {
|
if (currentRound !== null && currentRound !== votedRound) {
|
||||||
setHasVoted(false);
|
setMyVote(null);
|
||||||
setVotedRound(null);
|
setVotedRound(null);
|
||||||
}
|
}
|
||||||
}, [state?.active?.num, votedRound]);
|
}, [state?.active?.num, votedRound]);
|
||||||
@@ -552,7 +550,7 @@ function App() {
|
|||||||
} else if (msg.type === "viewerCount") {
|
} else if (msg.type === "viewerCount") {
|
||||||
setViewerCount(msg.viewerCount);
|
setViewerCount(msg.viewerCount);
|
||||||
} else if (msg.type === "votedAck") {
|
} else if (msg.type === "votedAck") {
|
||||||
setHasVoted(true);
|
setMyVote(msg.votedFor);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -565,8 +563,9 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVote = (side: "A" | "B") => {
|
const handleVote = (side: "A" | "B") => {
|
||||||
if (hasVoted || !wsRef.current) return;
|
if (myVote === side || !wsRef.current) return;
|
||||||
wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side }));
|
wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side }));
|
||||||
|
setMyVote(side);
|
||||||
setVotedRound(state?.active?.num ?? null);
|
setVotedRound(state?.active?.num ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -607,7 +606,7 @@ function App() {
|
|||||||
<Arena
|
<Arena
|
||||||
round={displayRound}
|
round={displayRound}
|
||||||
total={totalRounds}
|
total={totalRounds}
|
||||||
hasVoted={hasVoted}
|
myVote={myVote}
|
||||||
onVote={handleVote}
|
onVote={handleVote}
|
||||||
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
||||||
/>
|
/>
|
||||||
|
|||||||
14
server.ts
14
server.ts
@@ -218,7 +218,7 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
|
|||||||
// ── WebSocket clients ───────────────────────────────────────────────────────
|
// ── WebSocket clients ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const clients = new Set<ServerWebSocket<WsData>>();
|
const clients = new Set<ServerWebSocket<WsData>>();
|
||||||
const viewerVoters = new Set<ServerWebSocket<WsData>>();
|
const viewerVoters = new Map<ServerWebSocket<WsData>, "A" | "B">();
|
||||||
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function scheduleViewerVoteBroadcast() {
|
function scheduleViewerVoteBroadcast() {
|
||||||
@@ -611,14 +611,20 @@ const server = Bun.serve<WsData>({
|
|||||||
const round = gameState.active;
|
const round = gameState.active;
|
||||||
if (!round || round.phase !== "voting") return;
|
if (!round || round.phase !== "voting") return;
|
||||||
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return;
|
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return;
|
||||||
if (viewerVoters.has(ws)) return;
|
|
||||||
if (msg.votedFor !== "A" && msg.votedFor !== "B") return;
|
if (msg.votedFor !== "A" && msg.votedFor !== "B") return;
|
||||||
|
|
||||||
viewerVoters.add(ws);
|
const previousVote = viewerVoters.get(ws);
|
||||||
|
if (previousVote === msg.votedFor) return; // same vote, ignore
|
||||||
|
|
||||||
|
// Undo previous vote if changing
|
||||||
|
if (previousVote === "A") round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1);
|
||||||
|
else if (previousVote === "B") round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1);
|
||||||
|
|
||||||
|
viewerVoters.set(ws, msg.votedFor);
|
||||||
if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
|
if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
|
||||||
else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
|
else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: "votedAck" }));
|
ws.send(JSON.stringify({ type: "votedAck", votedFor: msg.votedFor }));
|
||||||
scheduleViewerVoteBroadcast();
|
scheduleViewerVoteBroadcast();
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user