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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user