diff --git a/src/pages/BattlePage.jsx b/src/pages/BattlePage.jsx index 244fc10..e726ca6 100644 --- a/src/pages/BattlePage.jsx +++ b/src/pages/BattlePage.jsx @@ -4,46 +4,60 @@ import { useUser } from '../context/UserContext'; import Header from './components/layout/Header'; import Footer from './components/layout/Footer'; import SettingsModal from './components/ui/SettingsModal'; +import QueueModal from './components/ui/QueueModal'; +import { useQueueSSE } from './useQueueSSE'; import './BattlePage.css'; -import '../api/axiosConfig.js' if (typeof global === 'undefined') { window.global = window; } export default function BattlePage() { - const navigate = useNavigate(); - const { logout } = useUser(); - const [showSettings, setShowSettings] = useState(false); - - - const handleLogout = () => { - logout(); - navigate('/login'); - }; - - return ( -
-
setShowSettings(true)} /> - -
-

Арена CodZilla

-

- Выбери задачу, напиши оптимальный код и разгроми соперника -

- - -
- -
- ); + const navigate = useNavigate(); + const { logout } = useUser(); + const [showSettings, setShowSettings] = useState(false); + + const { isModalOpen, queueSize, waitSeconds, isConnecting, enterQueue, leaveQueue } = + useQueueSSE(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+
setShowSettings(true)} /> + +
+

Арена CodZilla

+

+ Выбери задачу, напиши оптимальный код и разгроми соперника +

+ + +
+ +
+ ); } \ No newline at end of file diff --git a/src/pages/BattlePage.test.jsx b/src/pages/BattlePage.test.jsx index 9500a38..cde494e 100644 --- a/src/pages/BattlePage.test.jsx +++ b/src/pages/BattlePage.test.jsx @@ -39,14 +39,6 @@ describe('BattlePage', () => { const renderComponent = () => render(); - it('кнопка "В БОЙ!" перенаправляет на /workspace', async () => { - const user = userEvent.setup(); - renderComponent(); - - await user.click(screen.getByRole('button', { name: 'В БОЙ!' })); - expect(mockNavigate).toHaveBeenCalledWith('/workspace'); - }); - it('нажатие на PVP в Header перенаправляет на /battle (остается на месте)', async () => { const user = userEvent.setup(); renderComponent(); diff --git a/src/pages/components/ui/QueueModal.css b/src/pages/components/ui/QueueModal.css new file mode 100644 index 0000000..e5d07db --- /dev/null +++ b/src/pages/components/ui/QueueModal.css @@ -0,0 +1,82 @@ +.queue-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.queue-modal { + background: var(--bg-card, #1e1e2e); + border: 1px solid rgba(255, 82, 82, 0.3); + border-radius: 12px; + padding: 40px 48px; + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + min-width: 320px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); +} + +/* Спиннер */ +.queue-modal__spinner { + width: 56px; + height: 56px; + border: 4px solid rgba(255, 82, 82, 0.2); + border-top-color: #ff5252; + border-radius: 50%; + animation: queue-spin 0.9s linear infinite; +} + +@keyframes queue-spin { + to { transform: rotate(360deg); } +} + +.queue-modal__title { + font-family: var(--font-ui); + font-size: 1.4rem; + color: var(--text-primary); + margin: 0; + letter-spacing: 1px; +} + +.queue-modal__stats { + display: flex; + gap: 40px; +} + +.queue-modal__stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.queue-modal__stat-label { + font-size: 0.8rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.queue-modal__stat-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--text-primary); + font-family: var(--font-ui); +} + +.queue-modal__timer { + color: #ff5252; + font-variant-numeric: tabular-nums; +} + +.queue-modal__leave { + margin-top: 4px; + padding: 10px 32px; + font-size: 0.95rem; +} \ No newline at end of file diff --git a/src/pages/components/ui/QueueModal.jsx b/src/pages/components/ui/QueueModal.jsx new file mode 100644 index 0000000..c35234e --- /dev/null +++ b/src/pages/components/ui/QueueModal.jsx @@ -0,0 +1,38 @@ +import './QueueModal.css'; + +const formatTime = (totalSeconds) => { + const m = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); + const s = (totalSeconds % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +}; + +export default function QueueModal({ isOpen, queueSize, waitSeconds, onLeave }) { + if (!isOpen) return null; + + return ( +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/pages/useQueueSSE.js b/src/pages/useQueueSSE.js new file mode 100644 index 0000000..39a3cd7 --- /dev/null +++ b/src/pages/useQueueSSE.js @@ -0,0 +1,117 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api/axiosConfig'; + +const BASE_URL = 'http://localhost:8080'; + +export const useQueueSSE = () => { + const navigate = useNavigate(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [queueSize, setQueueSize] = useState(0); + const [waitSeconds, setWaitSeconds] = useState(0); + const [isConnecting, setIsConnecting] = useState(false); + + const esRef = useRef(null); + const timerRef = useRef(null); + const waitSecondsRef = useRef(0); + + const startTimer = useCallback((initialSeconds = 0) => { + waitSecondsRef.current = initialSeconds; + setWaitSeconds(initialSeconds); + clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + waitSecondsRef.current += 1; + setWaitSeconds(waitSecondsRef.current); + }, 1000); + }, []); + + const stopTimer = useCallback(() => { + clearInterval(timerRef.current); + timerRef.current = null; + }, []); + + const openSSE = useCallback(() => { + if (esRef.current) return; + + const es = new EventSource(`${BASE_URL}/matchmaking/queue/stream`, { + withCredentials: true, + }); + + es.addEventListener('queue-size', (e) => { + setQueueSize(Number(e.data)); + }); + + es.addEventListener('match-found', (e) => { + const sessionId = e.data; + stopTimer(); + closeSSE(); + setIsModalOpen(false); + navigate(`/match/${sessionId}/draft`); + }); + + es.onerror = () => { + }; + + esRef.current = es; + }, [navigate, stopTimer]); + + const closeSSE = useCallback(() => { + if (esRef.current) { + esRef.current.close(); + esRef.current = null; + } + }, []); + + const enterQueue = useCallback(async () => { + setIsConnecting(true); + try { + const { data } = await api.get('/matchmaking/queue/status'); + + if (data.status === 'WAITING') { + setQueueSize(data.queueSize); + openSSE(); + startTimer(data.waitingSeconds); + } else { + await api.post('/matchmaking/queue'); + setQueueSize(data.queueSize + 1); + openSSE(); + startTimer(0); + } + setIsModalOpen(true); + } catch (err) { + console.error('[Queue] enterQueue error:', err); + } finally { + setIsConnecting(false); + } + }, [openSSE, startTimer]); + + const leaveQueue = useCallback(async () => { + try { + await api.delete('/matchmaking/queue'); + } catch (err) { + console.error('[Queue] leaveQueue error:', err); + } finally { + stopTimer(); + closeSSE(); + setIsModalOpen(false); + setWaitSeconds(0); + } + }, [closeSSE, stopTimer]); + + useEffect(() => { + return () => { + stopTimer(); + closeSSE(); + }; + }, [stopTimer, closeSSE]); + + return { + isModalOpen, + queueSize, + waitSeconds, + isConnecting, + enterQueue, + leaveQueue, + }; +}; \ No newline at end of file