Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 49 additions & 35 deletions src/pages/BattlePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="layout-container">
<Header onSettingsClick={() => setShowSettings(true)} />

<main className="battle-main">
<h1 className="battle-title">Арена CodZilla</h1>
<p className="battle-subtitle">
Выбери задачу, напиши оптимальный код и разгроми соперника
</p>

<button className="btn-battle" onClick={() => navigate('/workspace')} >
В БОЙ!
</button>
</main>

<Footer />

<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onLogout={handleLogout}
/>
</div>
);
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 (
<div className="layout-container">
<Header onSettingsClick={() => setShowSettings(true)} />

<main className="battle-main">
<h1 className="battle-title">Арена CodZilla</h1>
<p className="battle-subtitle">
Выбери задачу, напиши оптимальный код и разгроми соперника
</p>

<button
className="btn-battle"
onClick={enterQueue}
disabled={isConnecting}
>
{isConnecting ? 'Подключение...' : 'В БОЙ!'}
</button>
</main>

<Footer />

<QueueModal
isOpen={isModalOpen}
queueSize={queueSize}
waitSeconds={waitSeconds}
onLeave={leaveQueue}
/>

<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onLogout={handleLogout}
/>
</div>
);
}
8 changes: 0 additions & 8 deletions src/pages/BattlePage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@ describe('BattlePage', () => {

const renderComponent = () => render(<BrowserRouter><BattlePage /></BrowserRouter>);

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();
Expand Down
82 changes: 82 additions & 0 deletions src/pages/components/ui/QueueModal.css
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions src/pages/components/ui/QueueModal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="queue-overlay">
<div className="queue-modal">
<div className="queue-modal__spinner" aria-hidden="true" />

<h2 className="queue-modal__title">Поиск соперника</h2>

<div className="queue-modal__stats">
<div className="queue-modal__stat">
<span className="queue-modal__stat-label">В очереди</span>
<span className="queue-modal__stat-value">{queueSize}</span>
</div>
<div className="queue-modal__stat">
<span className="queue-modal__stat-label">Ожидание</span>
<span className="queue-modal__stat-value queue-modal__timer">
{formatTime(waitSeconds)}
</span>
</div>
</div>

<button className="btn btn-danger queue-modal__leave" onClick={onLeave}>
Отменить поиск
</button>
</div>
</div>
);
}
117 changes: 117 additions & 0 deletions src/pages/useQueueSSE.js
Original file line number Diff line number Diff line change
@@ -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]);

Check warning on line 57 in src/pages/useQueueSSE.js

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'closeSSE'. Either include it or remove the dependency array

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,
};
};
Loading