diff --git a/src/App.css b/src/App.css index 851b489..aaa1cae 100644 --- a/src/App.css +++ b/src/App.css @@ -46,6 +46,12 @@ html, body, #root { -webkit-font-smoothing: antialiased; } +.draft-container { + flex-grow: 1; + overflow-y: auto; + padding: 20px; +} + .page-header { display: flex; align-items: center; diff --git a/src/pages/DraftPage.jsx b/src/pages/DraftPage.jsx index a4a0ee9..464172a 100644 --- a/src/pages/DraftPage.jsx +++ b/src/pages/DraftPage.jsx @@ -1,141 +1,213 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; -import {useMatchSession} from './useMatchSession.js'; +import React, {useEffect, useState} from 'react'; +import {useParams} from 'react-router-dom'; +import {useMatchSession} from './useMatchSession.jsx'; +import {useNavigate} from 'react-router-dom'; import './DraftPage.css'; import {useUser} from "../context/UserContext.jsx"; +import Header from "./components/layout/Header.jsx"; +import ActionTimer from "./components/ui/ActionTimer.jsx"; +import SettingsModal from "./components/ui/SettingsModal.jsx"; const DraftPage = () => { - const { matchId } = useParams(); - const { sessionData, sendBan, isConnected, opponent } = useMatchSession(matchId); - const {user} = useUser(); + const navigate = useNavigate(); + const {matchId} = useParams(); + const {sessionData, sendBan, isConnected, opponent} = useMatchSession(matchId); + const {user, logout} = useUser(); + const [wsTimeLeft, setWsTimeLeft] = useState(20); + const [showSettings, setShowSettings] = useState(false); + + const amIFirstUser = user?.id === sessionData?.firstUserId; + const isMyTurn = amIFirstUser ? sessionData?.firstUserMove : !sessionData?.firstUserMove; + + useEffect(() => { + if (!isConnected || !sessionData) return; + const interval = setInterval(() => { + setWsTimeLeft((prev) => { + if (prev === null || prev <= 0) return 20; + return +(prev - 0.1).toFixed(1); + }); + }, 100); + + return () => clearInterval(interval); + }, [isMyTurn, isConnected, sessionData]); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + if (!isConnected) return
Установка соединения с сервером...
; if (!sessionData) return
Загрузка данных драфта...
; - const amIFirstUser = user?.id === sessionData.firstUserId; - const isMyTurn = amIFirstUser ? sessionData.firstUserMove : !sessionData.firstUserMove; - const { optionsStates: categories} = sessionData; + const {optionsStates: categories} = sessionData; return ( -
- {/* Обновленный заголовок сессии с данными оппонента */} -
-
- - - {/* Оппонент внутри Хедера */} - {opponent ? ( -
- Avatar { - // Чтобы избежать бесконечного цикла, если и заглушка упадет: - if (e.target.src !== 'https://img.icons8.com/windows/32/94a3b8/user-male-circle.png') { - e.target.src = 'https://img.icons8.com/windows/32/94a3b8/user-male-circle.png'; - e.target.style.padding = '4px'; // Немного отступа для иконки - e.target.style.background = '#334155'; - } - }} - /> - -
- Оппонент - {opponent.nickname} -
-
- ★ {opponent.rating ?? 1000} -
-
- ) : ( -
Загрузка оппонента...
- )} -
- - {/* Индикатор хода */} -
- {isMyTurn ? "Вы баните опцию" : "Оппонент банит опцию"} -
-
- -
-
- {categories.map(({ category, options, active }) => { - - - const remainCount = options ? options.filter(o => !o.banned).length : 0; - - const isCategoryFinished = !active && remainCount === 1; - - return ( -
-
-

{category}

- {active && Текущий выбор} - {isCategoryFinished && Выбрано} +
+
setShowSettings(true)}/> + + {wsTimeLeft !== null && ( + + )} + +
+ +
+
+ + + {/* Оппонент внутри Хедера */} + {opponent ? ( +
+ Avatar { + // Чтобы избежать бесконечного цикла, если и заглушка упадет: + if (e.target.src !== 'https://img.icons8.com/windows/32/94a3b8/user-male-circle.png') { + e.target.src = 'https://img.icons8.com/windows/32/94a3b8/user-male-circle.png'; + e.target.style.padding = '4px'; // Немного отступа для иконки + e.target.style.background = '#334155'; + } + }} + /> + +
+ Оппонент + {opponent.nickname}
- -
- {options && options.length > 0 ? ( - // Копируем массив через [...] и сортируем по свойству option (строка с именем) - [...options] - .sort((a, b) => a.option.localeCompare(b.option)) - .map(({ option, banned }) => { - const isClickable = active && !banned; - - let btnClass = "draft-ban-btn"; - if (banned) { - btnClass += " banned"; - } else if (isCategoryFinished) { - btnClass += " selected-winner"; - } - - return ( - - ); - }) - ) : ( -

Опций не осталось

- )} +
+ ★ {opponent.rating ?? 1000}
- ); - })} -
-
+ ) : ( +
Загрузка оппонента...
+ )} +
+ + {/* Индикатор хода */} +
+ {isMyTurn ? "Вы баните опцию" : "Оппонент банит опцию"} +
+ + +
+
+ {categories.map(({category, options, active}) => { + + + const remainCount = options ? options.filter(o => !o.banned).length : 0; + + const isCategoryFinished = !active && remainCount === 1; + + return ( +
+
+

{category}

+ {active && Текущий выбор} + {isCategoryFinished && Выбрано} +
+ +
+ {options && options.length > 0 ? ( + // Копируем массив через [...] и сортируем по свойству option (строка с именем) + [...options] + .sort((a, b) => a.option.localeCompare(b.option)) + .map(({option, banned}) => { + const isClickable = active && !banned; + + let btnClass = "draft-ban-btn"; + if (banned) { + btnClass += " banned"; + } else if (isCategoryFinished) { + btnClass += " selected-winner"; + } + + return ( + + ); + }) + ) : ( +

Опций не осталось

+ )} +
+
+ ); + })} +
+
+ setShowSettings(false)} + onLogout={handleLogout} + /> + ); }; diff --git a/src/pages/WorkspacePage.jsx b/src/pages/WorkspacePage.jsx index d532e30..5249578 100644 --- a/src/pages/WorkspacePage.jsx +++ b/src/pages/WorkspacePage.jsx @@ -10,76 +10,101 @@ import RightWorkspace from './components/workspace/RightWorkspace'; import './WorkspacePage.css'; import MatchResultOverlay from './MatchResultOverlay'; import { useMatchStore } from "./useMatchStore.js"; -import {useMatchSession} from "./useMatchSession.js"; +import {useMatchSession} from "./useMatchSession.jsx"; +import {api} from "../api/axiosConfig.js"; function WorkspacePage() { - const {matchId} = useParams(); - const { logout } = useUser(); - const navigate = useNavigate(); - const [isDarkMode, setIsDarkMode] = useState(true); - const [isSwapped, setIsSwapped] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const { userSubmissions } = useMatchSession(); - - const matchResult = useMatchStore((state) => state.matchResult); - - useEffect(() => { - if (isDarkMode) { - document.body.classList.remove('light-theme'); - } else { - document.body.classList.add('light-theme'); - } - }, [isDarkMode]); - - const handleLogout = () => { - logout(); - navigate('/login'); - }; - - return ( -
-
setShowSettings(true)} /> - -
- - {isSwapped ? ( - - ) : ( - - )} + const {matchId} = useParams(); + const { logout } = useUser(); + const navigate = useNavigate(); + const [isDarkMode, setIsDarkMode] = useState(true); + const [isSwapped, setIsSwapped] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const { userSubmissions } = useMatchSession(matchId); + const [matchOptions, setMatchOptions] = useState(null); + const matchResult = useMatchStore((state) => state.matchResult); - -
-
+ useEffect(() => { + if (isDarkMode) { + document.body.classList.remove('light-theme'); + } else { + document.body.classList.add('light-theme'); + } + }, [isDarkMode]); - {isSwapped ? ( - - ) : ( - - )} -
-
- -
- - setShowSettings(false)} - onLogout={handleLogout} - themeConfig={{ isDarkMode, setIsDarkMode }} - workspaceConfig={{ isSwapped, setIsSwapped }} - /> - - {matchResult && ( - { + let ignore = false; + + const fetchOptions = async () => { + try { + const response = await api.get(`/match/${matchId}/options`); + if (!ignore) { + console.log(`options : ${response.data.statement}`); + setMatchOptions(response.data); + } + } catch (error) { + console.error("Ошибка при загрузке опций матча:", error.message); + } + }; + + if (matchId) { + fetchOptions(); + } + + return () => { + ignore = true; + }; + }, [matchId]); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+
setShowSettings(true)} /> + +
+ + {isSwapped ? ( + + ) : ( + + )} + + +
+
+ + {isSwapped ? ( + + ) : ( + + )} +
+
+ +
+ + setShowSettings(false)} + onLogout={handleLogout} + themeConfig={{ isDarkMode, setIsDarkMode }} + workspaceConfig={{ isSwapped, setIsSwapped }} /> - )} -
- ); + {matchResult && ( + + )} + +
+ ); } export default WorkspacePage; \ No newline at end of file diff --git a/src/pages/WorkspacePage.test.jsx b/src/pages/WorkspacePage.test.jsx index e06aa7a..4cd24a2 100644 --- a/src/pages/WorkspacePage.test.jsx +++ b/src/pages/WorkspacePage.test.jsx @@ -30,12 +30,18 @@ vi.mock('./components/workspace/LeftWorkspace', () => ({ default: () =>
vi.mock('./components/workspace/RightWorkspace', () => ({ default: () =>
})); vi.mock('./components/layout/Footer', () => ({ default: () =>
})); -vi.mock('./useMatchSession.js', () => ({ - useMatchSession: () => ({ userSubmissions: [] }), +vi.mock('../context/WebSocketContext', () => ({ + useWebSocket: () => ({ + isConnected: true, + publish: vi.fn(), + }), })); -vi.mock('./useMatchStore.js', () => ({ - useMatchStore: (selector) => selector({ matchResult: null }), +vi.mock('../context/UserContext', () => ({ + useUser: () => ({ + user: { id: 'test-user-id', nickname: 'CodzillaPro' }, + logout: vi.fn(), + }), })); describe('WorkspacePage', () => { diff --git a/src/pages/components/ui/ActionTimer.jsx b/src/pages/components/ui/ActionTimer.jsx new file mode 100644 index 0000000..b9c0682 --- /dev/null +++ b/src/pages/components/ui/ActionTimer.jsx @@ -0,0 +1,37 @@ +export default function ActionTimer({ timeLeft, totalDuration = 20, isMyTurn = true }) { + + const percentage = Math.max(0, Math.min(100, (timeLeft / totalDuration) * 100)); + + + const getBarColor = () => { + if (isMyTurn) { + + if (percentage > 50) return '#22c55e'; + if (percentage > 20) return '#eab308'; + return '#f97316'; + } else { + + if (percentage > 50) return '#a855f7'; + if (percentage > 20) return '#b91c1c'; + return '#ef4444'; + } + }; + + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/components/workspace/LeftWorkspace.jsx b/src/pages/components/workspace/LeftWorkspace.jsx index c21cc00..a5e4d54 100644 --- a/src/pages/components/workspace/LeftWorkspace.jsx +++ b/src/pages/components/workspace/LeftWorkspace.jsx @@ -10,23 +10,25 @@ import {multipartApi} from "../../../api/axiosConfig.js"; const DEFAULT_CODE = "# Напишите ваш код здесь...\n"; -// const languages = [ -// -// { id: 71, name: "Python" }, -// -// { id: 54, name: "C++" }, -// -// { id: 63, name: "JavaScript" } -// -// ]; -// -// const idToLanguage = { -// 71 : 'python', -// 54 : 'cpp', -// 63 : 'javascript' -// }; +const enumToLanguage = { + 'PY': 'python', + 'CPP': 'cpp', + 'JAVA': 'java' +}; -export default function LeftWorkspace({isDarkMode, position = 'left', matchId, submissions = []}) { +const complexityStyles = { + 'EASY': {label: 'Easy', color: '#22c55e', bg: 'rgba(34, 197, 94, 0.1)'}, + 'MEDIUM': {label: 'Medium', color: '#f97316', bg: 'rgba(249, 115, 22, 0.1)'}, + 'HARD': {label: 'Hard', color: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)'} +}; + +const displayLanguages = { + 'PY': 'Python', + 'CPP': 'C++', + 'JAVA': 'Java' +}; + +export default function LeftWorkspace({isDarkMode, position = 'left', matchId, submissions = [], matchOptions = null}) { // const [languageId, setLanguageId] = useState(71); const [code, setCode] = useState(DEFAULT_CODE); const [showResetModal, setShowResetModal] = useState(false); @@ -125,17 +127,43 @@ export default function LeftWorkspace({isDarkMode, position = 'left', matchId, s - {/* setLanguageId(e.target.value)}*/} - {/*>*/} - {/* {languages.map((lang) => (*/} - {/* */} - {/* ))}*/} - {/**/} + {matchOptions && ( +
+ {/* Бейдж Языка */} + + {displayLanguages[matchOptions.language] || matchOptions.language} + + + {/* Бейдж Сложности */} + {matchOptions.problemLevel && ( + + {complexityStyles[matchOptions.problemLevel.toUpperCase()]?.label || matchOptions.problemLevel} + + )} +
+ )}