From friends playtest feedback: - blindtest "who added it?" (and mixed): hide the per-player vote status — the contributor never validates, which gave them away - blindtest sound: set YT player `origin`/`enablejsapi` (fixes the postMessage origin warning) and add a per-game "Activer le son" gesture for non-DJ (browsers block audible autoplay without a user gesture) - track list: render each player's own tracks from a server-sent, authoritative blindtest:myTracks event (emitted on submit/remove/reconnect) instead of local state that desynced on remount — fixes the "3/3 but only 2 shown" case - quiz: reset the free-answer input between questions (was pre-filled with the previous answer) Verified e2e: myTracks emitted on submit + reconnect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
6.5 KiB
TypeScript
220 lines
6.5 KiB
TypeScript
import { useEffect, useState } from "react"
|
|
import type {
|
|
QuizPerPlayerResult,
|
|
QuizQuestionPayload,
|
|
QuizRevealTruth,
|
|
RoomSnapshot,
|
|
} from "@nerdware/shared"
|
|
import { Button } from "@workspace/ui/components/button"
|
|
import { useRoomStore } from "@/store/room"
|
|
import { Countdown } from "@/components/countdown"
|
|
import { useI18n, type Dict } from "@/i18n/context"
|
|
|
|
const inputClass =
|
|
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
|
|
|
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
|
const MAX_BLUR = 22
|
|
|
|
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|
const round = useRoomStore((s) => s.round)
|
|
const reveal = useRoomStore((s) => s.reveal)
|
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
|
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
|
|
const hasVoted = useRoomStore((s) => s.hasVoted)
|
|
const playerId = useRoomStore((s) => s.playerId)
|
|
const vote = useRoomStore((s) => s.vote)
|
|
const voteText = useRoomStore((s) => s.voteText)
|
|
const { t } = useI18n()
|
|
|
|
if (!round) {
|
|
return <p className="text-muted-foreground text-sm">{t.common.loading}</p>
|
|
}
|
|
|
|
const question = round.payload as QuizQuestionPayload
|
|
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
|
const showReveal = truth !== null
|
|
const isImage = question.format === "image_reveal"
|
|
const isText = question.format === "free" || isImage
|
|
const myResult = reveal
|
|
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
|
: undefined
|
|
|
|
function choiceClass(index: number): string {
|
|
const base =
|
|
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors"
|
|
if (showReveal) {
|
|
if (index === truth?.correctIndex) {
|
|
return `${base} border-green-500 bg-green-500/15 font-medium`
|
|
}
|
|
if (index === myChoiceIndex) {
|
|
return `${base} border-red-500 bg-red-500/10`
|
|
}
|
|
return `${base} border-input opacity-60`
|
|
}
|
|
if (index === myChoiceIndex) {
|
|
return `${base} border-primary bg-primary/10 font-medium`
|
|
}
|
|
return `${base} border-input hover:bg-muted/60`
|
|
}
|
|
|
|
return (
|
|
<div className="flex w-full max-w-lg flex-col gap-5">
|
|
<header className="flex items-center justify-between">
|
|
<span className="text-muted-foreground text-xs uppercase">
|
|
{t.game.question(snapshot.currentRound + 1, snapshot.totalRounds)}
|
|
{question.category ? ` · ${question.category}` : ""}
|
|
</span>
|
|
{!showReveal && (
|
|
<Countdown
|
|
key={round.endsAt}
|
|
startsAt={round.startsAt}
|
|
endsAt={round.endsAt}
|
|
/>
|
|
)}
|
|
</header>
|
|
|
|
<h2 className="font-heading text-xl font-semibold text-balance">
|
|
{question.prompt}
|
|
</h2>
|
|
|
|
{isImage && question.imageUrl && (
|
|
<ImageReveal
|
|
src={
|
|
question.imageUrl.startsWith("http")
|
|
? question.imageUrl
|
|
: `${SERVER_URL}${question.imageUrl}`
|
|
}
|
|
startsAt={round.startsAt}
|
|
endsAt={round.endsAt}
|
|
revealed={showReveal}
|
|
/>
|
|
)}
|
|
|
|
{isText ? (
|
|
<FreeAnswer
|
|
key={round.endsAt}
|
|
disabled={showReveal || hasVoted}
|
|
onSubmit={voteText}
|
|
t={t}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{(question.choices ?? []).map((choice, index) => (
|
|
<button
|
|
key={index}
|
|
className={choiceClass(index)}
|
|
disabled={showReveal || hasVoted}
|
|
onClick={() => vote(index)}
|
|
>
|
|
{choice}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!showReveal &&
|
|
(hasVoted ? (
|
|
<p className="text-muted-foreground text-center text-xs">
|
|
{t.game.answerSent}
|
|
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
|
|
</p>
|
|
) : (
|
|
voteProgress && (
|
|
<p className="text-muted-foreground text-center text-xs">
|
|
{t.game.answeredCount(voteProgress.count, voteProgress.total)}
|
|
</p>
|
|
)
|
|
))}
|
|
|
|
{showReveal && (
|
|
<div className="flex flex-col items-center gap-1">
|
|
{isText && (
|
|
<p className="text-sm">
|
|
<span className="text-muted-foreground">{t.game.answer} </span>
|
|
<span className="font-medium">{truth?.answer}</span>
|
|
</p>
|
|
)}
|
|
<p
|
|
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
|
>
|
|
{myResult?.correct
|
|
? t.game.correct
|
|
: !hasVoted
|
|
? t.game.noAnswer
|
|
: t.game.wrong}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ImageReveal({
|
|
src,
|
|
startsAt,
|
|
endsAt,
|
|
revealed,
|
|
}: {
|
|
src: string
|
|
startsAt: number
|
|
endsAt: number
|
|
revealed: boolean
|
|
}) {
|
|
// Le flou décroît avec le temps (cadencé par les timestamps serveur) : tous
|
|
// les clients voient le même niveau de flou au même instant.
|
|
const [blur, setBlur] = useState(MAX_BLUR)
|
|
|
|
useEffect(() => {
|
|
if (revealed) {
|
|
return
|
|
}
|
|
const id = setInterval(() => {
|
|
const now = Date.now()
|
|
const p =
|
|
now < startsAt ? 0 : Math.min(1, (now - startsAt) / (endsAt - startsAt))
|
|
setBlur(MAX_BLUR * (1 - p))
|
|
}, 200)
|
|
return () => clearInterval(id)
|
|
}, [revealed, startsAt, endsAt])
|
|
|
|
const displayBlur = revealed ? 0 : blur
|
|
|
|
return (
|
|
<div className="bg-card relative flex w-full justify-center overflow-hidden rounded-2xl p-2">
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
|
|
style={{ filter: `blur(${displayBlur}px)` }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FreeAnswer({
|
|
disabled,
|
|
onSubmit,
|
|
t,
|
|
}: {
|
|
disabled: boolean
|
|
onSubmit: (text: string) => void
|
|
t: Dict
|
|
}) {
|
|
const [text, setText] = useState("")
|
|
return (
|
|
<div className="flex gap-2">
|
|
<input
|
|
className={inputClass}
|
|
placeholder={t.game.yourAnswer}
|
|
value={text}
|
|
disabled={disabled}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
|
|
/>
|
|
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
|
|
{t.game.validate}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|