From 994954c81756b9f9e94d0f8197ae9fc4df8f3bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:45:03 +0300 Subject: [PATCH] contests --- src/App.tsx | 12 ++ src/axios.ts | 1 + src/hooks/useQuery.ts | 7 + src/pages/Mission.tsx | 15 +- src/redux/slices/auth.ts | 234 +++++++++++++++---------- src/redux/slices/contests.ts | 11 +- src/styles/index.css | 1 + src/views/home/contest/Contest.tsx | 1 - src/views/home/contest/MissionItem.tsx | 5 +- src/views/home/contest/Missions.tsx | 4 +- src/views/mission/statement/Header.tsx | 15 +- 11 files changed, 197 insertions(+), 109 deletions(-) create mode 100644 src/hooks/useQuery.ts diff --git a/src/App.tsx b/src/App.tsx index 6d0ec91..522fbb5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,8 +8,17 @@ import Home from './pages/Home'; import Mission from './pages/Mission'; import ArticleEditor from './pages/ArticleEditor'; import Article from './pages/Article'; +import { useEffect } from 'react'; +import { loadTokensFromLocalStorage } from './redux/slices/auth'; +import { useAppDispatch } from './redux/hooks'; function App() { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(loadTokensFromLocalStorage()); + }, []); + return (
@@ -29,3 +38,6 @@ function App() { } export default App; +function useAppdispatch() { + throw new Error('Function not implemented.'); +} diff --git a/src/axios.ts b/src/axios.ts index f7cb60b..9a27882 100644 --- a/src/axios.ts +++ b/src/axios.ts @@ -11,6 +11,7 @@ const instance = axios.create({ instance.interceptors.request.use( (config) => { const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState() + if (token) { config.headers.Authorization = `Bearer ${token}`; } diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts new file mode 100644 index 0000000..63ccae5 --- /dev/null +++ b/src/hooks/useQuery.ts @@ -0,0 +1,7 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +export function useQuery() { + const { search } = useLocation(); + return useMemo(() => new URLSearchParams(search), [search]); +} diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx index 24a81d7..66f1191 100644 --- a/src/pages/Mission.tsx +++ b/src/pages/Mission.tsx @@ -8,6 +8,7 @@ import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit'; import { fetchMissionById } from '../redux/slices/missions'; import Header from '../views/mission/statement/Header'; import MissionSubmissions from '../views/mission/statement/MissionSubmissions'; +import { useQuery } from '../hooks/useQuery'; const Mission = () => { const dispatch = useAppDispatch(); @@ -16,6 +17,10 @@ const Mission = () => { const { missionId } = useParams<{ missionId: string }>(); const mission = useAppSelector((state) => state.missions.currentMission); const missionIdNumber = Number(missionId); + + const query = useQuery(); + const back = query.get('back') ?? undefined; + if (!missionId || isNaN(missionIdNumber)) { return ; } @@ -38,7 +43,9 @@ const Mission = () => { const hasWaiting = submissionsRef.current.some( (s: any) => s.solution.status == 'Waiting' || - s.solution.testerState === 'Waiting', + s.solution.testerState === 'Waiting' || + s.solution.status === 'Compiling' || + s.solution.testerState === 'Compiling', ); if (!hasWaiting) { // Всё проверено — стоп @@ -73,7 +80,9 @@ const Mission = () => { const hasWaiting = submissions.some( (s) => s.solution.status === 'Waiting' || - s.solution.testerState === 'Waiting', + s.solution.testerState === 'Waiting' || + s.solution.status === 'Compiling' || + s.solution.testerState === 'Compiling', ); if (hasWaiting) { @@ -145,7 +154,7 @@ const Mission = () => { return (
-
+
diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index 6ea53ca..2fba03e 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -1,25 +1,36 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; -// Типы данных +// 🔹 Функция для декодирования JWT +function decodeJwt(token: string) { + const [, payload] = token.split('.'); + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(decodeURIComponent(escape(json))); +} + +// 🔹 Типы данных interface AuthState { jwt: string | null; refreshToken: string | null; username: string | null; + email: string | null; // <-- добавили email + id: string | null; status: 'idle' | 'loading' | 'successful' | 'failed'; error: string | null; } -// Инициализация состояния +// 🔹 Инициализация состояния const initialState: AuthState = { jwt: null, refreshToken: null, username: null, + email: null, // <-- добавили email + id: null, status: 'idle', error: null, }; -// AsyncThunk: Регистрация +// 🔹 AsyncThunk: Регистрация export const registerUser = createAsyncThunk( 'auth/register', async ( @@ -45,7 +56,7 @@ export const registerUser = createAsyncThunk( }, ); -// AsyncThunk: Логин +// 🔹 AsyncThunk: Логин export const loginUser = createAsyncThunk( 'auth/login', async ( @@ -66,7 +77,7 @@ export const loginUser = createAsyncThunk( }, ); -// AsyncThunk: Обновление токена +// 🔹 AsyncThunk: Обновление токена export const refreshToken = createAsyncThunk( 'auth/refresh', async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => { @@ -74,7 +85,7 @@ export const refreshToken = createAsyncThunk( const response = await axios.post('/authentication/refresh', { refreshToken, }); - return response.data; // { username } + return response.data; // { jwt, refreshToken } } catch (err: any) { return rejectWithValue( err.response?.data?.message || 'Refresh token failed', @@ -83,7 +94,7 @@ export const refreshToken = createAsyncThunk( }, ); -// AsyncThunk: Получение информации о пользователе +// 🔹 AsyncThunk: Получение информации о пользователе export const fetchWhoAmI = createAsyncThunk( 'auth/whoami', async (_, { rejectWithValue }) => { @@ -98,10 +109,10 @@ export const fetchWhoAmI = createAsyncThunk( }, ); -// AsyncThunk: Загрузка токенов из localStorage +// 🔹 AsyncThunk: Загрузка токенов из localStorage export const loadTokensFromLocalStorage = createAsyncThunk( 'auth/loadTokens', - async (_, {}) => { + async () => { const jwt = localStorage.getItem('jwt'); const refreshToken = localStorage.getItem('refreshToken'); @@ -114,7 +125,7 @@ export const loadTokensFromLocalStorage = createAsyncThunk( }, ); -// Slice +// 🔹 Slice const authSlice = createSlice({ name: 'auth', initialState, @@ -123,6 +134,8 @@ const authSlice = createSlice({ state.jwt = null; state.refreshToken = null; state.username = null; + state.email = null; // <-- очистка email + state.id = null; state.status = 'idle'; state.error = null; localStorage.removeItem('jwt'); @@ -136,118 +149,145 @@ const authSlice = createSlice({ state.status = 'loading'; state.error = null; }); - builder.addCase( - registerUser.fulfilled, - ( - state, - action: PayloadAction<{ jwt: string; refreshToken: string }>, - ) => { - state.status = 'successful'; - state.jwt = action.payload.jwt; - state.refreshToken = action.payload.refreshToken; - axios.defaults.headers.common[ - 'Authorization' - ] = `Bearer ${action.payload.jwt}`; - localStorage.setItem('jwt', action.payload.jwt); - localStorage.setItem( - 'refreshToken', - action.payload.refreshToken, - ); - }, - ); - builder.addCase( - registerUser.rejected, - (state, action: PayloadAction) => { - state.status = 'failed'; - state.error = action.payload; - }, - ); + builder.addCase(registerUser.fulfilled, (state, action) => { + state.status = 'successful'; + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + + // 🔸 Декодируем JWT + const decoded = decodeJwt(action.payload.jwt); + state.username = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ] || null; + state.email = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + ] || null; + state.id = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' + ] || null; + + axios.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${action.payload.jwt}`; + localStorage.setItem('jwt', action.payload.jwt); + localStorage.setItem('refreshToken', action.payload.refreshToken); + }); + builder.addCase(registerUser.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.payload as string; + }); // Логин builder.addCase(loginUser.pending, (state) => { state.status = 'loading'; state.error = null; }); - builder.addCase( - loginUser.fulfilled, - ( - state, - action: PayloadAction<{ jwt: string; refreshToken: string }>, - ) => { - state.status = 'successful'; - state.jwt = action.payload.jwt; - state.refreshToken = action.payload.refreshToken; - axios.defaults.headers.common[ - 'Authorization' - ] = `Bearer ${action.payload.jwt}`; - localStorage.setItem('jwt', action.payload.jwt); - localStorage.setItem( - 'refreshToken', - action.payload.refreshToken, - ); - }, - ); - builder.addCase( - loginUser.rejected, - (state, action: PayloadAction) => { - state.status = 'failed'; - state.error = action.payload; - }, - ); + builder.addCase(loginUser.fulfilled, (state, action) => { + state.status = 'successful'; + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + + // 🔸 Декодируем JWT + const decoded = decodeJwt(action.payload.jwt); + state.username = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ] || null; + state.email = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + ] || null; + state.id = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' + ] || null; + + axios.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${action.payload.jwt}`; + localStorage.setItem('jwt', action.payload.jwt); + localStorage.setItem('refreshToken', action.payload.refreshToken); + }); + builder.addCase(loginUser.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.payload as string; + }); // Обновление токена builder.addCase(refreshToken.pending, (state) => { state.status = 'loading'; state.error = null; }); - builder.addCase( - refreshToken.fulfilled, - (state, action: PayloadAction<{ username: string }>) => { - state.status = 'successful'; - state.username = action.payload.username; - }, - ); - builder.addCase( - refreshToken.rejected, - (state, action: PayloadAction) => { - state.status = 'failed'; - state.error = action.payload; - }, - ); + builder.addCase(refreshToken.fulfilled, (state, action) => { + state.status = 'successful'; + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + + // 🔸 Декодируем JWT + const decoded = decodeJwt(action.payload.jwt); + state.username = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ] || null; + state.email = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + ] || null; + state.id = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' + ] || null; + + axios.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${action.payload.jwt}`; + localStorage.setItem('jwt', action.payload.jwt); + localStorage.setItem('refreshToken', action.payload.refreshToken); + }); + builder.addCase(refreshToken.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.payload as string; + }); // Получение информации о пользователе builder.addCase(fetchWhoAmI.pending, (state) => { state.status = 'loading'; state.error = null; }); - builder.addCase( - fetchWhoAmI.fulfilled, - (state, action: PayloadAction<{ username: string }>) => { - state.status = 'successful'; - state.username = action.payload.username; - }, - ); - builder.addCase( - fetchWhoAmI.rejected, - (state, action: PayloadAction) => { - state.status = 'failed'; - state.error = action.payload; - }, - ); + builder.addCase(fetchWhoAmI.fulfilled, (state, action) => { + state.status = 'successful'; + state.username = action.payload.username; + }); + builder.addCase(fetchWhoAmI.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.payload as string; + }); // Загрузка токенов из localStorage builder.addCase( loadTokensFromLocalStorage.fulfilled, - ( - state, - action: PayloadAction<{ - jwt: string | null; - refreshToken: string | null; - }>, - ) => { + (state, action) => { state.jwt = action.payload.jwt; state.refreshToken = action.payload.refreshToken; + if (action.payload.jwt) { + const decoded = decodeJwt(action.payload.jwt); + state.username = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ] || null; + state.email = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + ] || null; + state.id = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' + ] || null; + axios.defaults.headers.common[ 'Authorization' ] = `Bearer ${action.payload.jwt}`; diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index f19abcd..a5302ec 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -6,9 +6,16 @@ import axios from '../../axios'; // ===================== export interface Mission { - missionId: number; + id: number; + authorId: number; name: string; - sortOrder: number; + difficulty: number; + tags: string[]; + createdAt: string; + updatedAt: string; + timeLimitMilliseconds: number; + memoryLimitBytes: number; + statements: null; } export interface Member { diff --git a/src/styles/index.css b/src/styles/index.css index b873281..69b3072 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -23,6 +23,7 @@ line-height: 1.5; background-color: var(--color-liquid-background); color: rgba(255, 255, 255, 0.87); + overflow-x: hidden; } #root { diff --git a/src/views/home/contest/Contest.tsx b/src/views/home/contest/Contest.tsx index fee728f..d184dd4 100644 --- a/src/views/home/contest/Contest.tsx +++ b/src/views/home/contest/Contest.tsx @@ -18,7 +18,6 @@ const Contest = () => { if (contestIdNumber === null) { return ; } - const dispatch = useAppDispatch(); const contest = useAppSelector((state) => state.contests.selectedContest); diff --git a/src/views/home/contest/MissionItem.tsx b/src/views/home/contest/MissionItem.tsx index 1648e4e..ee9579b 100644 --- a/src/views/home/contest/MissionItem.tsx +++ b/src/views/home/contest/MissionItem.tsx @@ -1,6 +1,7 @@ import { cn } from '../../../lib/cn'; import { IconError, IconSuccess } from '../../../assets/icons/missions'; import { useNavigate } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; export interface MissionItemProps { id: number; @@ -31,6 +32,8 @@ const MissionItem: React.FC = ({ status, }) => { const navigate = useNavigate(); + const location = useLocation(); + const path = location.pathname + location.search; return (
= ({ 'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300', )} onClick={() => { - navigate(`/mission/${id}`); + navigate(`/mission/${id}?back=${path}`); }} >
#{id}
diff --git a/src/views/home/contest/Missions.tsx b/src/views/home/contest/Missions.tsx index e4ef5a7..00d9824 100644 --- a/src/views/home/contest/Missions.tsx +++ b/src/views/home/contest/Missions.tsx @@ -28,8 +28,10 @@ const ContestMissions: FC = ({ contest }) => {
{contest.missions.map((v, i) => ( ))} diff --git a/src/views/mission/statement/Header.tsx b/src/views/mission/statement/Header.tsx index 51220a5..979957e 100644 --- a/src/views/mission/statement/Header.tsx +++ b/src/views/mission/statement/Header.tsx @@ -9,9 +9,10 @@ import { useNavigate } from 'react-router-dom'; interface HeaderProps { missionId: number; + back?: string; } -const Header: React.FC = ({ missionId }) => { +const Header: React.FC = ({ missionId, back }) => { const navigate = useNavigate(); return (
@@ -29,7 +30,8 @@ const Header: React.FC = ({ missionId }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - navigate('/home/missions'); + if (back) navigate(back); + else navigate('/home/missions'); }} /> @@ -39,7 +41,10 @@ const Header: React.FC = ({ missionId }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - navigate(`/mission/${missionId - 1}`); + if (missionId <= 1) return; + if (back) + navigate(`/mission/${missionId - 1}?back=${back}`); + else navigate(`/mission/${missionId - 1}`); }} /> {missionId} @@ -48,7 +53,9 @@ const Header: React.FC = ({ missionId }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - navigate(`/mission/${missionId + 1}`); + if (back) + navigate(`/mission/${missionId + 1}?back=${back}`); + else navigate(`/mission/${missionId + 1}`); }} />