party game pages
This commit is contained in:
@@ -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<string, string>, 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 (
|
||||
<div className={`party-timer ${urgent ? 'party-timer-urgent' : ''}`} role="timer" aria-live={urgent ? 'assertive' : 'off'}>
|
||||
{secs}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="party-scoreboard">
|
||||
<div className="party-scoreboard-title">{t('party.scores')}</div>
|
||||
{sorted.map((p, i) => (
|
||||
<div key={p.id} className={`party-score-row ${p.id === myId ? 'party-score-you' : ''}`}>
|
||||
{i === 0 && scores.length > 1 && p.score > 0 && <span aria-label={t('party.firstPlace')}><IconCrownFilled size={10} style={{ color: '#fbbf24' }} /></span>}
|
||||
<IconCircleFilled size={8} style={{ color: p.color }} aria-hidden="true" />
|
||||
<span className="party-score-name">{p.name}</span>
|
||||
<span className="party-score-pts">{p.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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 (
|
||||
<motion.div
|
||||
key="submit"
|
||||
className="party-phase-content"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<div className="party-phase-label">{prompt}</div>
|
||||
<div className="party-clue">{clue}</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!mySubmission ? (
|
||||
<motion.div key="input" className="party-submit-area"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<div className="party-input-wrap">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="party-input"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value.slice(0, max))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
maxLength={max}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="party-char-count">{draft.length}/{max}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary party-submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<IconSendFilled size={14} aria-hidden="true" /> {t('party.submit')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="submitted"
|
||||
className="party-submitted"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<IconCheckFilled size={16} aria-hidden="true" />
|
||||
<span>{t('party.submittedWaiting')}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
key="vote"
|
||||
className="party-phase-content"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<div className="party-phase-label">{prompt}</div>
|
||||
<div className="party-clue party-clue-sm">{clue}</div>
|
||||
<div className="party-answers">
|
||||
{answers.map((a, i) => (
|
||||
<motion.button
|
||||
key={a.id}
|
||||
className={`party-answer-card ${myVote === a.id ? 'party-answer-selected' : ''}`}
|
||||
onClick={() => 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}
|
||||
>
|
||||
<span className="party-answer-text">{a.text}</span>
|
||||
{myVote === a.id && <IconCheckFilled size={14} className="party-answer-check" />}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{myVote && (
|
||||
<motion.div className="party-voted"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
{t('party.voteLocked')}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
key="results"
|
||||
className="party-phase-content"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<div className="party-phase-label">{t('party.results')}</div>
|
||||
<div className="party-clue party-clue-sm">{clue}</div>
|
||||
{realAnswer && (
|
||||
<motion.div
|
||||
className="party-real-answer"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{label}: <span className="party-real-answer-word">{realAnswer}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="party-results-list">
|
||||
{[...results].sort((a, b) => b.votes - a.votes).map((r, i) => {
|
||||
const maxVotes = Math.max(1, ...results.map(x => x.votes))
|
||||
return (
|
||||
<motion.div
|
||||
key={r.id}
|
||||
className={`party-result ${r.isReal ? 'party-result-real' : ''} ${i === 0 && r.votes > 0 && !r.isReal ? 'party-result-top' : ''}`}
|
||||
initial={{ opacity: 0, x: -15 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
>
|
||||
<div className="party-result-header">
|
||||
<span className="party-result-author" style={{ color: r.authorColor }}>
|
||||
<IconCircleFilled size={8} aria-hidden="true" /> {r.isReal ? label : r.author}
|
||||
</span>
|
||||
<span className="party-result-votes">
|
||||
{r.votes} {r.votes === 1 ? t('party.vote') : t('party.votes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="party-result-text">{r.text}</div>
|
||||
<div className="party-result-bar-track">
|
||||
<motion.div
|
||||
className="party-result-bar-fill"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(r.votes / maxVotes) * 100}%` }}
|
||||
transition={{ duration: 0.5, delay: i * 0.08 + 0.2 }}
|
||||
style={{ background: r.isReal ? 'var(--correct)' : r.authorColor }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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 (
|
||||
<motion.div
|
||||
key="race"
|
||||
className="party-phase-content"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<div className="party-phase-label">{t('party.typeAnswer')}</div>
|
||||
<div className="party-clue">{clue}</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{alreadyCorrect ? (
|
||||
<motion.div key="correct"
|
||||
className="party-submitted"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<IconCheckFilled size={16} aria-hidden="true" />
|
||||
<span>{t('party.correct')}</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="speed-input" className="party-submit-area"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<div className="party-input-wrap">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="party-input"
|
||||
value={draft}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary party-submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<IconBoltFilled size={14} aria-hidden="true" /> {t('party.go')}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{correctPlayers.length > 0 && (
|
||||
<motion.div key="feed" className="party-speed-feed"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<AnimatePresence>
|
||||
{correctPlayers.map(p => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className="party-speed-correct"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<IconCircleFilled size={8} style={{ color: p.color }} aria-hidden="true" />
|
||||
<span>{p.name}</span>
|
||||
<span className="party-speed-pts">+{p.points}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
key="choose"
|
||||
className="party-phase-content"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<div className="party-phase-label">{t('party.pickDefinition')}</div>
|
||||
<div className="party-clue">{clue}</div>
|
||||
<div className="party-roulette-options">
|
||||
{choices.map((c, i) => (
|
||||
<motion.button
|
||||
key={c.id}
|
||||
className={`party-roulette-option ${myPick === c.id ? 'party-roulette-picked' : ''}`}
|
||||
onClick={() => !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}
|
||||
>
|
||||
<span className="party-roulette-label">{c.label}</span>
|
||||
<span className="party-roulette-text">{c.text}</span>
|
||||
{myPick === c.id && <IconCheckFilled size={14} className="party-answer-check" />}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{myPick && (
|
||||
<motion.div className="party-voted"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
{t('party.lockedIn')}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, string>, 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 (
|
||||
<motion.div className="party-game"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25 }}>
|
||||
<div className="party-final">
|
||||
<motion.div
|
||||
className="party-final-header"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<IconTrophyFilled size={32} style={{ color: '#fbbf24' }} aria-hidden="true" />
|
||||
<h2 style={{ color: finishedColor }}>{finishedTitle}</h2>
|
||||
</motion.div>
|
||||
<div className="party-final-list">
|
||||
{sorted.map((p, i) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className={`party-final-row ${i === 0 ? 'party-final-winner' : ''}`}
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<span className="party-final-rank">
|
||||
{i === 0 && winner ? <span aria-label={t('party.firstPlace')}><IconCrownFilled size={14} style={{ color: '#fbbf24' }} /></span> : `#${i + 1}`}
|
||||
</span>
|
||||
<IconCircleFilled size={10} style={{ color: p.color }} aria-hidden="true" />
|
||||
<span className="party-final-name">{p.name}</span>
|
||||
<span className="party-final-pts">{p.score}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="party-game">
|
||||
<div className="visually-hidden" aria-live="polite">{liveText}</div>
|
||||
<div className="party-main">
|
||||
<h2 className="party-mode-title" style={{ color: modeColor }}>{modeTitle}</h2>
|
||||
<div className="party-round">{t('party.round', { current: round, total: totalRounds })}</div>
|
||||
<Countdown deadline={deadline} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'submit' && (
|
||||
<SubmitPhase
|
||||
prompt={submitPrompt}
|
||||
placeholder={submitPlaceholder}
|
||||
maxLen={subMode === 'bluff' ? 80 : 50}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'vote' && <VotePhase prompt={votePrompt} />}
|
||||
|
||||
{phase === 'race' && <SpeedRacePhase />}
|
||||
|
||||
{phase === 'choose' && <RouletteChoosePhase />}
|
||||
|
||||
{phase === 'results' && <ResultsPhase realLabel={resultLabel} />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{scores.length > 0 && phase !== 'finished' && (
|
||||
<motion.div key="scoreboard"
|
||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }} transition={{ duration: 0.2 }}>
|
||||
<Scoreboard scores={scores} myId={myId} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>, 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 (
|
||||
<div className="party-page">
|
||||
<div className="party-page-bar">
|
||||
<button type="button" className="header-brand" onClick={handleLeave}>{t('app.name')}</button>
|
||||
<button type="button" className="party-page-room" onClick={copyCode} aria-label={t('party.roomCode')}>
|
||||
<IconConfettiFilled size={14} aria-hidden="true" />
|
||||
<span className="party-page-code">{mpRoomCode}</span>
|
||||
<span className="party-page-sub">{subLabelFor(SUB_LABELS, partySubMode || 'wrong')}</span>
|
||||
{copied && <span className="party-page-copied">{t('party.copied')}</span>}
|
||||
</button>
|
||||
<div className="party-page-players-compact">
|
||||
{mpPlayers.map(p => (
|
||||
<IconCircleFilled key={p.id} size={8} style={{ color: p.color }} />
|
||||
))}
|
||||
<span>{mpPlayers.length}p</span>
|
||||
</div>
|
||||
<button className="btn btn-sm party-page-leave" onClick={handleLeave}>
|
||||
<IconX size={12} /> {t('party.leave')}
|
||||
</button>
|
||||
</div>
|
||||
<PartyGame />
|
||||
<AnimatePresence>
|
||||
{confirmLeave && (
|
||||
<motion.div className="settings-overlay" key="confirm-overlay"
|
||||
onClick={() => setConfirmLeave(false)}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<motion.div className="party-confirm" role="alertdialog" aria-modal="true" onClick={e => 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 }}>
|
||||
<p className="party-confirm-text">
|
||||
{mpIsCreator ? t('party.leaveEndGame') : t('party.leaveConfirm')}
|
||||
</p>
|
||||
<div className="party-confirm-actions">
|
||||
<button className="btn btn-sm" onClick={() => setConfirmLeave(false)}>{t('party.cancel')}</button>
|
||||
<button className="btn btn-sm party-confirm-leave" onClick={confirmAndLeave}>
|
||||
{mpIsCreator ? t('party.endAndLeave') : t('party.leave')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="party-page">
|
||||
<div className="party-page-bar" style={{ justifyContent: 'flex-start' }}>
|
||||
<button type="button" className="header-brand" onClick={mpLeaveRoom}>{t('app.name')}</button>
|
||||
</div>
|
||||
<motion.div className="party-lobby"
|
||||
initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}>
|
||||
<IconConfettiFilled size={32} className="party-lobby-icon" aria-hidden="true" />
|
||||
<h2 className="party-lobby-title">{t('party.title')}</h2>
|
||||
<p className="party-lobby-sub">{subLabelFor(SUB_LABELS, partySubMode || 'wrong')}</p>
|
||||
|
||||
<button type="button" className="party-lobby-code" onClick={copyCode} aria-label={t('party.roomCode')}>
|
||||
<span className="party-lobby-code-label">{t('party.roomCode')}</span>
|
||||
<span className="party-lobby-code-value">{mpRoomCode}</span>
|
||||
<span className="party-lobby-code-copy">
|
||||
{copied ? t('party.copied') : <IconCopyFilled size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="party-lobby-players">
|
||||
<div className="mp-section-title">{t('party.players', { count: mpPlayers.length })}</div>
|
||||
<AnimatePresence>
|
||||
{mpPlayers.map((p, i) => (
|
||||
<motion.div key={p.id} className="party-lobby-player"
|
||||
initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }} transition={{ duration: 0.15, delay: i * 0.03 }}>
|
||||
<IconCircleFilled size={10} style={{ color: p.color }} />
|
||||
<span>{p.name}</span>
|
||||
{p.id === mpMyId && <span className="mp-player-you">{t('multiplayer.you')}</span>}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{mpPlayers.length < 2 && (
|
||||
<motion.p className="party-lobby-hint" key="hint"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
{t('party.waitingForPlayers')}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="party-lobby-actions">
|
||||
{mpIsCreator && (
|
||||
<button className="btn btn-primary" onClick={mpStartGame} disabled={mpPlayers.length < 2} style={{ width: '100%' }}>
|
||||
<IconPlayerPlayFilled size={14} /> {mpPlayers.length < 2 ? t('party.needMorePlayers') : t('party.startGame')}
|
||||
</button>
|
||||
)}
|
||||
{!mpIsCreator && (
|
||||
<p className="party-lobby-hint">{t('party.waitingForHost')}</p>
|
||||
)}
|
||||
<button className="btn btn-sm party-page-leave" onClick={mpLeaveRoom} style={{ width: '100%' }}>
|
||||
<IconX size={14} /> {t('party.leaveRoom')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user