From 8efa6f28ad666f5a5f94978096647d5fd859d69a Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 13 Apr 2026 15:22:41 +0300 Subject: [PATCH] party game pages --- frontend/src/components/PartyGame.tsx | 551 ++++++++++++++++++++++++++ frontend/src/components/PartyPage.tsx | 171 ++++++++ 2 files changed, 722 insertions(+) create mode 100644 frontend/src/components/PartyGame.tsx create mode 100644 frontend/src/components/PartyPage.tsx diff --git a/frontend/src/components/PartyGame.tsx b/frontend/src/components/PartyGame.tsx new file mode 100644 index 0000000..1e9e6a1 --- /dev/null +++ b/frontend/src/components/PartyGame.tsx @@ -0,0 +1,551 @@ +import { useState, useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'motion/react' +import { IconCrownFilled, IconTrophyFilled, + IconCircleFilled, IconCheckFilled, IconSendFilled, + IconBoltFilled } from '@tabler/icons-react' +import { useStore } from '../store' + +const MODE_COLORS = { + wrong: '#e8594a', + bluff: '#8b5cf6', + reverse: '#3b82f6', + speed: '#f59e0b', + roulette: '#22c55e', +} + +function modeColorFor(colors: Record, mode: string): string { + return colors[mode] ?? colors.wrong +} + +function Countdown({ deadline }: { deadline: number | null }) { + const [secs, setSecs] = useState(0) + + useEffect(() => { + if (!deadline) return + const tick = () => { + const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)) + setSecs(left) + } + tick() + const id = setInterval(tick, 250) + return () => clearInterval(id) + }, [deadline]) + + const urgent = secs > 0 && secs <= 5 + + return ( +
+ {secs} +
+ ) +} + +function Scoreboard({ scores, myId }: { scores: { id: string; name: string; color: string; score: number }[]; myId: string | null }) { + const { t } = useTranslation() + const sorted = [...scores].sort((a, b) => b.score - a.score) + return ( +
+
{t('party.scores')}
+ {sorted.map((p, i) => ( +
+ {i === 0 && scores.length > 1 && p.score > 0 && } +
+ ))} +
+ ) +} + +function SubmitPhase({ prompt, placeholder, maxLen }: { prompt: string; placeholder: string; maxLen?: number }) { + const { t } = useTranslation() + const clue = useStore(s => s.partyClue) + const mySubmission = useStore(s => s.partyMySubmission) + const submitAnswer = useStore(s => s.partySubmitAnswer) + const [draft, setDraft] = useState('') + const inputRef = useRef(null) + const phase = useStore(s => s.partyPhase) + const round = useStore(s => s.partyRound) + const max = maxLen || 50 + + useEffect(() => { + if (phase === 'submit') { + queueMicrotask(() => setDraft('')) + setTimeout(() => inputRef.current?.focus(), 100) + } + }, [phase, round]) + + const handleSubmit = () => { + const text = draft.trim() + if (!text || mySubmission) return + submitAnswer(text) + } + + return ( + +
{prompt}
+
{clue}
+ + + {!mySubmission ? ( + +
+ setDraft(e.target.value.slice(0, max))} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }} + placeholder={placeholder} + aria-label={placeholder} + maxLength={max} + spellCheck={false} + /> + {draft.length}/{max} +
+ +
+ ) : ( + + + )} +
+
+ ) +} + +function VotePhase({ prompt }: { prompt: string }) { + const { t } = useTranslation() + const clue = useStore(s => s.partyClue) + const answers = useStore(s => s.partyAnswers) + const myVote = useStore(s => s.partyMyVote) + const voteAnswer = useStore(s => s.partyVoteAnswer) + + const handleVote = (id: string) => { + if (myVote) return + voteAnswer(id) + } + + return ( + +
{prompt}
+
{clue}
+
+ {answers.map((a, i) => ( + handleVote(a.id)} + disabled={!!myVote} + initial={{ opacity: 0, y: 12 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: i * 0.06 }} + whileHover={!myVote ? { scale: 1.02 } : undefined} + whileTap={!myVote ? { scale: 0.97 } : undefined} + > + {a.text} + {myVote === a.id && } + + ))} +
+ + {myVote && ( + + {t('party.voteLocked')} + + )} + +
+ ) +} + +function ResultsPhase({ realLabel }: { realLabel?: string }) { + const { t } = useTranslation() + const clue = useStore(s => s.partyClue) + const results = useStore(s => s.partyResults) + const realAnswer = useStore(s => s.partyRealAnswer) + if (!results) return null + const label = realLabel || t('party.resultLabels.realAnswer') + + return ( + +
{t('party.results')}
+
{clue}
+ {realAnswer && ( + + {label}: {realAnswer} + + )} +
+ {[...results].sort((a, b) => b.votes - a.votes).map((r, i) => { + const maxVotes = Math.max(1, ...results.map(x => x.votes)) + return ( + 0 && !r.isReal ? 'party-result-top' : ''}`} + initial={{ opacity: 0, x: -15 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: i * 0.08 }} + > +
+ + + + {r.votes} {r.votes === 1 ? t('party.vote') : t('party.votes')} + +
+
{r.text}
+
+ +
+
+ ) + })} +
+
+ ) +} + +function SpeedRacePhase() { + const { t } = useTranslation() + const clue = useStore(s => s.partyClue) + const correctPlayers = useStore(s => s.partyCorrectPlayers) + const speedAnswer = useStore(s => s.partySpeedAnswer) + const myId = useStore(s => s.mpMyId) + const [draft, setDraft] = useState('') + const [, setSent] = useState(false) + const inputRef = useRef(null) + const phase = useStore(s => s.partyPhase) + const round = useStore(s => s.partyRound) + + useEffect(() => { + if (phase === 'race') { + queueMicrotask(() => { setDraft(''); setSent(false) }) + setTimeout(() => inputRef.current?.focus(), 100) + } + }, [phase, round]) + + const alreadyCorrect = correctPlayers.some(p => p.id === myId) + + const handleSubmit = () => { + const text = draft.trim() + if (!text || alreadyCorrect) return + speedAnswer(text) + setDraft('') + setSent(true) + setTimeout(() => setSent(false), 500) + } + + return ( + +
{t('party.typeAnswer')}
+
{clue}
+ + + {alreadyCorrect ? ( + + + ) : ( + +
+ setDraft(e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 20))} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }} + placeholder={t('party.yourAnswer')} + aria-label={t('party.yourAnswer')} + maxLength={20} + spellCheck={false} + /> +
+ +
+ )} +
+ + + {correctPlayers.length > 0 && ( + + + {correctPlayers.map(p => ( + + + ))} + + + )} + +
+ ) +} + +function RouletteChoosePhase() { + const { t } = useTranslation() + const clue = useStore(s => s.partyClue) + const choices = useStore(s => s.partyChoices) + const myPick = useStore(s => s.partyMyPick) + const pickChoice = useStore(s => s.partyPickChoice) + + return ( + +
{t('party.pickDefinition')}
+
{clue}
+
+ {choices.map((c, i) => ( + !myPick && pickChoice(c.id)} + disabled={!!myPick} + initial={{ opacity: 0, y: 12 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: i * 0.08 }} + whileHover={!myPick ? { scale: 1.02 } : undefined} + whileTap={!myPick ? { scale: 0.97 } : undefined} + > + {c.label} + {c.text} + {myPick === c.id && } + + ))} +
+ + {myPick && ( + + {t('party.lockedIn')} + + )} + +
+ ) +} + +export default function PartyGame() { + const { t } = useTranslation() + const phase = useStore(s => s.partyPhase) + const round = useStore(s => s.partyRound) + const totalRounds = useStore(s => s.partyTotalRounds) + const deadline = useStore(s => s.partyDeadline) + const scores = useStore(s => s.partyScores) + const myId = useStore(s => s.mpMyId) + const subMode = useStore(s => s.partySubMode) + const realAnswer = useStore(s => s.partyRealAnswer) + + const liveText = useMemo(() => { + if (phase === 'submit' || phase === 'vote' || phase === 'race' || phase === 'choose') { + return t('party.phaseAnnounce', { phase, current: round, total: totalRounds }) + } else if (phase === 'results') { + return t('party.resultsAnnounce', { answer: realAnswer || '' }) + } else if (phase === 'finished') { + return t('party.gameOver') + } + return null + }, [phase, round, realAnswer, totalRounds, t]) + + const subLabels = { + wrong: t('party.subModes.wrong'), + bluff: t('party.subModes.bluff'), + reverse: t('party.subModes.reverse'), + speed: t('party.subModes.speed'), + roulette: t('party.subModes.roulette'), + } + + const subLabelFor = (labels: Record, mode: string): string => { + return labels[mode] ?? labels.wrong + } + const finishedColor = modeColorFor(MODE_COLORS, subMode || 'wrong') + const finishedTitle = subLabelFor(subLabels, subMode || 'wrong') + + if (phase === 'finished') { + const sorted = [...scores].sort((a, b) => b.score - a.score) + const winner = sorted[0] + return ( + +
+ + +
+ {sorted.map((p, i) => ( + + + {i === 0 && winner ? : `#${i + 1}`} + + + ))} +
+
+
+ ) + } + + const submitPrompt = subMode === 'bluff' ? t('party.prompts.fakeDefinition') + : subMode === 'reverse' ? t('party.prompts.writeClue') + : t('party.prompts.wrongAnswer') + + const submitPlaceholder = subMode === 'bluff' ? t('party.placeholders.soundReal') + : subMode === 'reverse' ? t('party.placeholders.bestClue') + : t('party.placeholders.funniestWrong') + + const votePrompt = subMode === 'bluff' ? t('party.prompts.realDefinition') + : subMode === 'reverse' ? t('party.prompts.voteBestClue') + : t('party.prompts.voteFunniest') + + const resultLabel = subMode === 'bluff' ? t('party.resultLabels.realDefinition') + : subMode === 'reverse' ? t('party.resultLabels.originalClue') + : subMode === 'roulette' ? t('party.correct') + : t('party.resultLabels.realAnswer') + + const modeColor = modeColorFor(MODE_COLORS, subMode || 'wrong') + const modeTitle = subLabelFor(subLabels, subMode || 'wrong') + + return ( +
+
{liveText}
+
+

{modeTitle}

+
{t('party.round', { current: round, total: totalRounds })}
+ + + + {phase === 'submit' && ( + + )} + + {phase === 'vote' && } + + {phase === 'race' && } + + {phase === 'choose' && } + + {phase === 'results' && } + +
+ + + {scores.length > 0 && phase !== 'finished' && ( + + + + )} + +
+ ) +} diff --git a/frontend/src/components/PartyPage.tsx b/frontend/src/components/PartyPage.tsx new file mode 100644 index 0000000..b467619 --- /dev/null +++ b/frontend/src/components/PartyPage.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'motion/react' +import { IconConfettiFilled, IconCopyFilled, IconCircleFilled, + IconPlayerPlayFilled, IconX } from '@tabler/icons-react' +import { useStore } from '../store' +import PartyGame from './PartyGame' + +export default function PartyPage() { + const { t } = useTranslation() + const SUB_LABELS = { + wrong: t('party.subModes.wrong'), + bluff: t('party.subModes.bluff'), + reverse: t('party.subModes.reverse'), + speed: t('party.subModes.speed'), + roulette: t('party.subModes.roulette'), + } + + const subLabelFor = (labels: Record, mode: string): string => { + return labels[mode] ?? labels.wrong + } + const mpRoomCode = useStore(s => s.mpRoomCode) + const mpPlayers = useStore(s => s.mpPlayers) + const mpStarted = useStore(s => s.mpStarted) + const mpMyId = useStore(s => s.mpMyId) + const mpIsCreator = useStore(s => s.mpIsCreator) + const mpStartGame = useStore(s => s.mpStartGame) + const mpLeaveRoom = useStore(s => s.mpLeaveRoom) + const partySubMode = useStore(s => s.partySubMode) + const partyPhase = useStore(s => s.partyPhase) + const partyScores = useStore(s => s.partyScores) + + const [copied, setCopied] = useState(false) + const [confirmLeave, setConfirmLeave] = useState(false) + + const copyCode = () => { + if (!mpRoomCode) return + navigator.clipboard.writeText(mpRoomCode) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const handleLeave = () => { + if (mpStarted && partyPhase !== 'finished') { + setConfirmLeave(true) + } else { + mpLeaveRoom() + } + } + + const confirmAndLeave = () => { + if (partyScores.length > 0 && partyPhase !== 'finished') { + useStore.setState({ partyPhase: 'finished' }) + setTimeout(() => mpLeaveRoom(), 3000) + } else { + mpLeaveRoom() + } + setConfirmLeave(false) + } + + if (mpStarted) { + return ( +
+
+ + +
+ {mpPlayers.map(p => ( + + ))} + {mpPlayers.length}p +
+ +
+ + + {confirmLeave && ( + setConfirmLeave(false)} + initial={{ opacity: 0 }} animate={{ opacity: 1 }} + exit={{ opacity: 0 }} transition={{ duration: 0.15 }}> + e.stopPropagation()} + initial={{ opacity: 0, scale: 0.96, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.96, y: 10 }} + transition={{ duration: 0.2 }}> +

+ {mpIsCreator ? t('party.leaveEndGame') : t('party.leaveConfirm')} +

+
+ + +
+
+
+ )} +
+
+ ) + } + + return ( +
+
+ +
+ + +
+ ) +}