fix host UX: revert act, inline open/close voting, larger bingo text, scoring explanation, simplify player list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 21:22:35 +01:00
parent 4cfff0eaa5
commit 38a0c9f55a
8 changed files with 103 additions and 53 deletions

View File

@@ -27,7 +27,7 @@ export function BingoCard({ card, onTap }: BingoCardProps) {
key={square.tropeId}
type="button"
onClick={() => onTap(square.tropeId)}
className={`flex aspect-square items-center justify-center rounded-md border p-1 text-center text-xs leading-tight transition-colors ${
className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
square.tapped
? "border-primary bg-primary/20 font-medium text-primary"
: "border-muted hover:bg-muted/50"

View File

@@ -1,4 +1,3 @@
import { useState } from "react"
import type { Entry, JuryRound, JuryResult } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -12,26 +11,8 @@ interface JuryHostProps {
}
export function JuryHost({ entries, currentRound, results, onOpenVote, onCloseVote }: JuryHostProps) {
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const votedCountries = new Set(results.map((r) => r.countryCode))
if (currentRound) {
return (
<Card>
<CardHeader>
<CardTitle>
Voting: {currentRound.countryFlag} {currentRound.countryName}
</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={onCloseVote} variant="destructive" className="w-full">
Close Voting
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
@@ -54,37 +35,41 @@ export function JuryHost({ entries, currentRound, results, onOpenVote, onCloseVo
<div className="flex flex-col gap-1">
{entries.map((entry) => {
const voted = votedCountries.has(entry.country.code)
const isSelected = selectedCountry === entry.country.code
const isVoting = currentRound?.countryCode === entry.country.code
return (
<button
<div
key={entry.country.code}
type="button"
disabled={voted}
onClick={() => setSelectedCountry(isSelected ? null : entry.country.code)}
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm ${
voted
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isSelected
: isVoting
? "border-primary bg-primary/5"
: "hover:bg-muted"
: ""
}`}
>
{entry.country.flag} {entry.artist} {entry.song}
</button>
<span>{entry.country.flag} {entry.artist} {entry.song}</span>
{!voted && !currentRound && (
<Button
size="sm"
variant="outline"
onClick={() => onOpenVote(entry.country.code)}
>
Open
</Button>
)}
{isVoting && (
<Button
size="sm"
variant="destructive"
onClick={onCloseVote}
>
Close
</Button>
)}
</div>
)
})}
</div>
{selectedCountry && (
<Button
onClick={() => {
onOpenVote(selectedCountry)
setSelectedCountry(null)
}}
className="w-full"
>
Open Voting
</Button>
)}
</CardContent>
</Card>
)

View File

@@ -13,7 +13,7 @@ export function Leaderboard({ entries }: LeaderboardProps) {
<CardHeader>
<CardTitle>Leaderboard</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{entries.map((entry, i) => (
<div key={entry.playerId} className="flex items-center justify-between border-b py-1.5 last:border-0">
@@ -31,6 +31,13 @@ export function Leaderboard({ entries }: LeaderboardProps) {
</div>
))}
</div>
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium">How scoring works</p>
<ul className="flex flex-col gap-0.5">
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
</ul>
</div>
</CardContent>
</Card>
)

View File

@@ -17,7 +17,7 @@ export function PlayerList({ players, mySessionId, predictionSubmitted }: Player
<span
className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
/>
<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
<span className={player.sessionId === mySessionId ? "font-bold underline" : ""}>
{player.displayName}
</span>
{player.isHost && (
@@ -25,9 +25,6 @@ export function PlayerList({ players, mySessionId, predictionSubmitted }: Player
Host
</Badge>
)}
{player.sessionId === mySessionId && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
{predictionSubmitted?.[player.id] && (
<span className="text-green-600" title="Prediction submitted"></span>
)}

View File

@@ -24,6 +24,13 @@ const nextActLabels: Partial<Record<Act, string>> = {
scoring: "End Party",
}
const prevActLabels: Partial<Record<Act, string>> = {
"pre-show": "Back to Lobby",
"live-event": "Back to Pre-Show",
scoring: "Back to Live Event",
ended: "Back to Scoring",
}
function HostView() {
const { roomCode } = Route.useParams()
const { send } = useWebSocket(roomCode)
@@ -117,9 +124,20 @@ function HostView() {
</CardHeader>
<CardContent className="flex flex-col gap-3">
{room.currentAct !== "ended" && (
<Button onClick={() => send({ type: "advance_act" })} className="w-full">
{nextActLabels[room.currentAct] ?? "Next"}
</Button>
<div className="flex gap-2">
{room.currentAct !== "lobby" && (
<Button
variant="outline"
onClick={() => send({ type: "revert_act" })}
className="flex-1"
>
{prevActLabels[room.currentAct] ?? "Back"}
</Button>
)}
<Button onClick={() => send({ type: "advance_act" })} className="flex-1">
{nextActLabels[room.currentAct] ?? "Next"}
</Button>
</div>
)}
{room.currentAct !== "ended" && (
<Button
@@ -131,9 +149,17 @@ function HostView() {
</Button>
)}
{room.currentAct === "ended" && (
<p className="text-center text-muted-foreground">
The party has ended. Thanks for playing!
</p>
<div className="flex flex-col gap-2">
<p className="text-center text-muted-foreground">
The party has ended. Thanks for playing!
</p>
<Button
variant="outline"
onClick={() => send({ type: "revert_act" })}
>
{prevActLabels[room.currentAct] ?? "Back"}
</Button>
</div>
)}
{room.currentAct === "live-event" && gameState && (
<JuryHost

View File

@@ -87,6 +87,18 @@ export class RoomManager {
return { newAct: nextAct }
}
revertAct(code: string, sessionId: string): { newAct: Act } | { error: string } {
const room = this.rooms.get(code)
if (!room) return { error: "Room not found" }
if (room.hostSessionId !== sessionId) return { error: "Only the host can revert acts" }
if (room.currentAct === "lobby") return { error: "Already at the first act" }
const currentIndex = ACTS.indexOf(room.currentAct)
const prevAct = ACTS[currentIndex - 1]!
room.currentAct = prevAct
return { newAct: prevAct }
}
endRoom(code: string, sessionId: string): { success: true } | { error: string } {
const room = this.rooms.get(code)
if (!room) return { error: "Room not found" }

View File

@@ -227,7 +227,25 @@ export function registerWebSocketRoutes() {
break
}
case "end_room": {
case "revert_act": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const result = roomManager.revertAct(roomCode, sessionId)
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcast(roomCode, {
type: "act_changed",
newAct: result.newAct,
})
broadcastGameStateToAll(roomCode)
break
}
case "end_room": {
if (!sessionId) {
sendError(ws, "Not joined")
return

View File

@@ -19,6 +19,10 @@ export const advanceActMessage = z.object({
type: z.literal("advance_act"),
})
export const revertActMessage = z.object({
type: z.literal("revert_act"),
})
export const endRoomMessage = z.object({
type: z.literal("end_room"),
})
@@ -54,6 +58,7 @@ export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
revertActMessage,
endRoomMessage,
submitPredictionMessage,
openJuryVoteMessage,