import { useState, useEffect, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'motion/react' import { IconX, IconMessagesFilled, IconCopyFilled, IconCircleFilled, IconBoltFilled, IconHeartFilled, IconArrowBigRightFilled, IconClockFilled, IconPlayerPlayFilled, IconConfettiFilled, IconChevronDownFilled } from '@tabler/icons-react' import confetti from 'canvas-confetti' import { useStore } from '../store' import { useFocusTrap } from '../hooks/useFocusTrap' interface RecentRoom { code: string mode: string ts: number } function loadRecentRooms(): RecentRoom[] { try { return JSON.parse(localStorage.getItem('mp-recent-rooms') || '[]') } catch { return [] } } function saveRecentRoom(code: string, mode: string) { const rooms = loadRecentRooms().filter(r => r.code !== code) rooms.unshift({ code, mode, ts: Date.now() }) localStorage.setItem('mp-recent-rooms', JSON.stringify(rooms.slice(0, 8))) } export default function MultiplayerPanel({ onClose }: { onClose: () => void }) { const trapRef = useFocusTrap(true) const { t } = useTranslation() const puzzle = useStore(s => s.puzzle) const mpConnected = useStore(s => s.mpConnected) const mpRoomCode = useStore(s => s.mpRoomCode) const mpMode = useStore(s => s.mpMode) const mpPlayers = useStore(s => s.mpPlayers) const mpStarted = useStore(s => s.mpStarted) const mpCurrentTurn = useStore(s => s.mpCurrentTurn) const mpMyId = useStore(s => s.mpMyId) const mpIsCreator = useStore(s => s.mpIsCreator) const mpCreateRoom = useStore(s => s.mpCreateRoom) const mpJoinRoom = useStore(s => s.mpJoinRoom) const mpLeaveRoom = useStore(s => s.mpLeaveRoom) const mpStartGame = useStore(s => s.mpStartGame) const mpPassTurn = useStore(s => s.mpPassTurn) const partySubMode = useStore(s => s.partySubMode) const [joinCode, setJoinCode] = useState('') const [mode, setMode] = useState<'coop' | 'competitive' | 'party'>('coop') const [partySub, setPartySub] = useState('wrong') const [partyDropOpen, setPartyDropOpen] = useState(false) const partyBtnRef = useRef(null) const partyDropRef = useRef(null) const [partyDropPos, setPartyDropPos] = useState({ top: 0, left: 0, width: 0 }) const [err, setErr] = useState(null) const [busy, setBusy] = useState(false) const [copied, setCopied] = useState(false) const [recentRooms, setRecentRooms] = useState(loadRecentRooms) const PARTY_MODES: { id: string; name: string; desc: string }[] = [ { id: 'wrong', name: t('party.subModes.wrong'), desc: t('multiplayer.partyModes.wrongDesc') }, { id: 'bluff', name: t('party.subModes.bluff'), desc: t('multiplayer.partyModes.bluffDesc') }, { id: 'reverse', name: t('party.subModes.reverse'), desc: t('multiplayer.partyModes.reverseDesc') }, { id: 'speed', name: t('party.subModes.speed'), desc: t('multiplayer.partyModes.speedDesc') }, { id: 'roulette', name: t('party.subModes.roulette'), desc: t('multiplayer.partyModes.rouletteDesc') }, ] const updatePartyDropPos = useCallback(() => { if (!partyBtnRef.current) return const rect = partyBtnRef.current.getBoundingClientRect() setPartyDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }) }, []) useEffect(() => { if (!partyDropOpen) return updatePartyDropPos() const onDown = (e: MouseEvent) => { const target = e.target if (target instanceof Node && partyDropRef.current && !partyDropRef.current.contains(target) && partyBtnRef.current && !partyBtnRef.current.contains(target)) { setPartyDropOpen(false) } } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPartyDropOpen(false) } window.addEventListener('scroll', updatePartyDropPos, true) document.addEventListener('mousedown', onDown) document.addEventListener('keydown', onKey) return () => { window.removeEventListener('scroll', updatePartyDropPos, true) document.removeEventListener('mousedown', onDown) document.removeEventListener('keydown', onKey) } }, [partyDropOpen, updatePartyDropPos]) useEffect(() => { if (mpRoomCode && mpMode) { saveRecentRoom(mpRoomCode, mpMode) queueMicrotask(() => setRecentRooms(loadRecentRooms())) } }, [mpRoomCode, mpMode]) const handleCreate = async () => { if (mode !== 'party' && !puzzle) return setErr(null) setBusy(true) try { await mpCreateRoom(mode, mode === 'party' ? partySub : undefined) if (mode === 'party') onClose() } catch (e) { setErr(e instanceof Error ? e.message : t('multiplayer.createFailed')) } setBusy(false) } const handleJoin = async (code?: string) => { const c = (code || joinCode).trim().toUpperCase() if (c.length < 4) return setErr(null) setBusy(true) try { await mpJoinRoom(c) onClose() } catch (e) { setErr(e instanceof Error ? e.message : t('multiplayer.joinFailed')) } setBusy(false) } const copyCode = () => { if (!mpRoomCode) return navigator.clipboard.writeText(mpRoomCode) setCopied(true) setTimeout(() => setCopied(false), 1500) } const inRoom = mpConnected && mpRoomCode const isMyTurn = mpStarted && mpCurrentTurn === mpMyId const currentPlayer = mpPlayers.find(p => p.id === mpCurrentTurn) return ( e.stopPropagation()} role="dialog" aria-modal="true" aria-label={t('multiplayer.playTogether')} 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 }} >

{t('multiplayer.playTogether')}

{err && ( {err} )} {!inRoom ? (
{t('multiplayer.createRoom')}
{mode === 'party' && ( setPartyDropOpen(!partyDropOpen)} initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }} style={{ overflow: 'hidden' }}> {PARTY_MODES.find(m => m.id === partySub)?.name || t('party.subModes.wrong')} )} {createPortal( {partyDropOpen && ( {PARTY_MODES.map(m => ( ))} )} , document.body, )}
{t('multiplayer.joinRoom')}
setJoinCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6))} placeholder={t('multiplayer.roomCode')} aria-label={t('multiplayer.roomCode')} maxLength={6} spellCheck={false} autoComplete="off" />
{recentRooms.length > 0 && (
{t('multiplayer.recentRooms')}
{recentRooms.map(r => ( ))}
)}
) : (
<>
{mpMode === 'party' ? : mpMode === 'coop' ? : } {mpMode === 'party' ? t('multiplayer.partyLabel') : mpMode === 'coop' ? t('multiplayer.coop') : t('multiplayer.race')} {mpMode !== 'party' && - {t('multiplayer.turnBased')}} {mpMode === 'party' && - { partySubMode === 'bluff' ? t('multiplayer.bluff') : partySubMode === 'reverse' ? t('multiplayer.reverseClue') : partySubMode === 'speed' ? t('multiplayer.speedRound') : partySubMode === 'roulette' ? t('multiplayer.roulette') : t('multiplayer.wrongAnswersOnly') }}
{mpStarted && currentPlayer && mpMode !== 'party' && (
{isMyTurn ? t('multiplayer.yourTurn') : t('multiplayer.playerTurn', { name: currentPlayer.name })}
)} {!mpStarted && (
)}
{t('party.players', { count: mpPlayers.length })}
{mpPlayers.map(p => (
{p.name} {p.id === mpMyId && {t('multiplayer.you')}} {mpStarted && p.id === mpCurrentTurn && {t('multiplayer.playing')}}
))} {mpPlayers.length === 0 && (
{t('multiplayer.waitingForPlayersEllipsis')}
)}
{!mpStarted && mpIsCreator && ( )} {mpStarted && isMyTurn && mpMode !== 'party' && ( )}
)}
) }