party game pages

This commit is contained in:
2026-04-13 15:22:41 +03:00
parent d9d743bec2
commit 8efa6f28ad
2 changed files with 722 additions and 0 deletions
+551
View File
@@ -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>
)
}
+171
View File
@@ -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>
)
}