This commit is contained in:
Виталий Лавшонок
2025-11-04 22:45:03 +03:00
parent 42da6684ba
commit 994954c817
11 changed files with 197 additions and 109 deletions

View File

@@ -8,8 +8,17 @@ import Home from './pages/Home';
import Mission from './pages/Mission'; import Mission from './pages/Mission';
import ArticleEditor from './pages/ArticleEditor'; import ArticleEditor from './pages/ArticleEditor';
import Article from './pages/Article'; import Article from './pages/Article';
import { useEffect } from 'react';
import { loadTokensFromLocalStorage } from './redux/slices/auth';
import { useAppDispatch } from './redux/hooks';
function App() { function App() {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(loadTokensFromLocalStorage());
}, []);
return ( return (
<div className="w-full h-full bg-liquid-background flex justify-center"> <div className="w-full h-full bg-liquid-background flex justify-center">
<div className="relative w-full max-w-[1600px] h-full "> <div className="relative w-full max-w-[1600px] h-full ">
@@ -29,3 +38,6 @@ function App() {
} }
export default App; export default App;
function useAppdispatch() {
throw new Error('Function not implemented.');
}

View File

@@ -11,6 +11,7 @@ const instance = axios.create({
instance.interceptors.request.use( instance.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState() const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState()
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }

7
src/hooks/useQuery.ts Normal file
View File

@@ -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]);
}

View File

@@ -8,6 +8,7 @@ import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
import { fetchMissionById } from '../redux/slices/missions'; import { fetchMissionById } from '../redux/slices/missions';
import Header from '../views/mission/statement/Header'; import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions'; import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
import { useQuery } from '../hooks/useQuery';
const Mission = () => { const Mission = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -16,6 +17,10 @@ const Mission = () => {
const { missionId } = useParams<{ missionId: string }>(); const { missionId } = useParams<{ missionId: string }>();
const mission = useAppSelector((state) => state.missions.currentMission); const mission = useAppSelector((state) => state.missions.currentMission);
const missionIdNumber = Number(missionId); const missionIdNumber = Number(missionId);
const query = useQuery();
const back = query.get('back') ?? undefined;
if (!missionId || isNaN(missionIdNumber)) { if (!missionId || isNaN(missionIdNumber)) {
return <Navigate to="/home" replace />; return <Navigate to="/home" replace />;
} }
@@ -38,7 +43,9 @@ const Mission = () => {
const hasWaiting = submissionsRef.current.some( const hasWaiting = submissionsRef.current.some(
(s: any) => (s: any) =>
s.solution.status == 'Waiting' || s.solution.status == 'Waiting' ||
s.solution.testerState === 'Waiting', s.solution.testerState === 'Waiting' ||
s.solution.status === 'Compiling' ||
s.solution.testerState === 'Compiling',
); );
if (!hasWaiting) { if (!hasWaiting) {
// Всё проверено — стоп // Всё проверено — стоп
@@ -73,7 +80,9 @@ const Mission = () => {
const hasWaiting = submissions.some( const hasWaiting = submissions.some(
(s) => (s) =>
s.solution.status === 'Waiting' || s.solution.status === 'Waiting' ||
s.solution.testerState === 'Waiting', s.solution.testerState === 'Waiting' ||
s.solution.status === 'Compiling' ||
s.solution.testerState === 'Compiling',
); );
if (hasWaiting) { if (hasWaiting) {
@@ -145,7 +154,7 @@ const Mission = () => {
return ( return (
<div className="h-screen grid grid-rows-[60px,1fr]"> <div className="h-screen grid grid-rows-[60px,1fr]">
<div className=""> <div className="">
<Header missionId={missionIdNumber} /> <Header missionId={missionIdNumber} back={back} />
</div> </div>
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]"> <div className="grid grid-cols-2 h-full min-h-0 gap-[20px]">

View File

@@ -1,25 +1,36 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios'; 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 { interface AuthState {
jwt: string | null; jwt: string | null;
refreshToken: string | null; refreshToken: string | null;
username: string | null; username: string | null;
email: string | null; // <-- добавили email
id: string | null;
status: 'idle' | 'loading' | 'successful' | 'failed'; status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null; error: string | null;
} }
// Инициализация состояния // 🔹 Инициализация состояния
const initialState: AuthState = { const initialState: AuthState = {
jwt: null, jwt: null,
refreshToken: null, refreshToken: null,
username: null, username: null,
email: null, // <-- добавили email
id: null,
status: 'idle', status: 'idle',
error: null, error: null,
}; };
// AsyncThunk: Регистрация // 🔹 AsyncThunk: Регистрация
export const registerUser = createAsyncThunk( export const registerUser = createAsyncThunk(
'auth/register', 'auth/register',
async ( async (
@@ -45,7 +56,7 @@ export const registerUser = createAsyncThunk(
}, },
); );
// AsyncThunk: Логин // 🔹 AsyncThunk: Логин
export const loginUser = createAsyncThunk( export const loginUser = createAsyncThunk(
'auth/login', 'auth/login',
async ( async (
@@ -66,7 +77,7 @@ export const loginUser = createAsyncThunk(
}, },
); );
// AsyncThunk: Обновление токена // 🔹 AsyncThunk: Обновление токена
export const refreshToken = createAsyncThunk( export const refreshToken = createAsyncThunk(
'auth/refresh', 'auth/refresh',
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => { async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
@@ -74,7 +85,7 @@ export const refreshToken = createAsyncThunk(
const response = await axios.post('/authentication/refresh', { const response = await axios.post('/authentication/refresh', {
refreshToken, refreshToken,
}); });
return response.data; // { username } return response.data; // { jwt, refreshToken }
} catch (err: any) { } catch (err: any) {
return rejectWithValue( return rejectWithValue(
err.response?.data?.message || 'Refresh token failed', err.response?.data?.message || 'Refresh token failed',
@@ -83,7 +94,7 @@ export const refreshToken = createAsyncThunk(
}, },
); );
// AsyncThunk: Получение информации о пользователе // 🔹 AsyncThunk: Получение информации о пользователе
export const fetchWhoAmI = createAsyncThunk( export const fetchWhoAmI = createAsyncThunk(
'auth/whoami', 'auth/whoami',
async (_, { rejectWithValue }) => { async (_, { rejectWithValue }) => {
@@ -98,10 +109,10 @@ export const fetchWhoAmI = createAsyncThunk(
}, },
); );
// AsyncThunk: Загрузка токенов из localStorage // 🔹 AsyncThunk: Загрузка токенов из localStorage
export const loadTokensFromLocalStorage = createAsyncThunk( export const loadTokensFromLocalStorage = createAsyncThunk(
'auth/loadTokens', 'auth/loadTokens',
async (_, {}) => { async () => {
const jwt = localStorage.getItem('jwt'); const jwt = localStorage.getItem('jwt');
const refreshToken = localStorage.getItem('refreshToken'); const refreshToken = localStorage.getItem('refreshToken');
@@ -114,7 +125,7 @@ export const loadTokensFromLocalStorage = createAsyncThunk(
}, },
); );
// Slice // 🔹 Slice
const authSlice = createSlice({ const authSlice = createSlice({
name: 'auth', name: 'auth',
initialState, initialState,
@@ -123,6 +134,8 @@ const authSlice = createSlice({
state.jwt = null; state.jwt = null;
state.refreshToken = null; state.refreshToken = null;
state.username = null; state.username = null;
state.email = null; // <-- очистка email
state.id = null;
state.status = 'idle'; state.status = 'idle';
state.error = null; state.error = null;
localStorage.removeItem('jwt'); localStorage.removeItem('jwt');
@@ -136,118 +149,145 @@ const authSlice = createSlice({
state.status = 'loading'; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase( builder.addCase(registerUser.fulfilled, (state, action) => {
registerUser.fulfilled, state.status = 'successful';
( state.jwt = action.payload.jwt;
state, state.refreshToken = action.payload.refreshToken;
action: PayloadAction<{ jwt: string; refreshToken: string }>,
) => { // 🔸 Декодируем JWT
state.status = 'successful'; const decoded = decodeJwt(action.payload.jwt);
state.jwt = action.payload.jwt; state.username =
state.refreshToken = action.payload.refreshToken; decoded[
axios.defaults.headers.common[ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
'Authorization' ] || null;
] = `Bearer ${action.payload.jwt}`; state.email =
localStorage.setItem('jwt', action.payload.jwt); decoded[
localStorage.setItem( 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
'refreshToken', ] || null;
action.payload.refreshToken, state.id =
); decoded[
}, 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
); ] || null;
builder.addCase(
registerUser.rejected, axios.defaults.headers.common[
(state, action: PayloadAction<any>) => { 'Authorization'
state.status = 'failed'; ] = `Bearer ${action.payload.jwt}`;
state.error = action.payload; 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) => { builder.addCase(loginUser.pending, (state) => {
state.status = 'loading'; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase( builder.addCase(loginUser.fulfilled, (state, action) => {
loginUser.fulfilled, state.status = 'successful';
( state.jwt = action.payload.jwt;
state, state.refreshToken = action.payload.refreshToken;
action: PayloadAction<{ jwt: string; refreshToken: string }>,
) => { // 🔸 Декодируем JWT
state.status = 'successful'; const decoded = decodeJwt(action.payload.jwt);
state.jwt = action.payload.jwt; state.username =
state.refreshToken = action.payload.refreshToken; decoded[
axios.defaults.headers.common[ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
'Authorization' ] || null;
] = `Bearer ${action.payload.jwt}`; state.email =
localStorage.setItem('jwt', action.payload.jwt); decoded[
localStorage.setItem( 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
'refreshToken', ] || null;
action.payload.refreshToken, state.id =
); decoded[
}, 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
); ] || null;
builder.addCase(
loginUser.rejected, axios.defaults.headers.common[
(state, action: PayloadAction<any>) => { 'Authorization'
state.status = 'failed'; ] = `Bearer ${action.payload.jwt}`;
state.error = action.payload; 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) => { builder.addCase(refreshToken.pending, (state) => {
state.status = 'loading'; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase( builder.addCase(refreshToken.fulfilled, (state, action) => {
refreshToken.fulfilled, state.status = 'successful';
(state, action: PayloadAction<{ username: string }>) => { state.jwt = action.payload.jwt;
state.status = 'successful'; state.refreshToken = action.payload.refreshToken;
state.username = action.payload.username;
}, // 🔸 Декодируем JWT
); const decoded = decodeJwt(action.payload.jwt);
builder.addCase( state.username =
refreshToken.rejected, decoded[
(state, action: PayloadAction<any>) => { 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
state.status = 'failed'; ] || null;
state.error = action.payload; 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) => { builder.addCase(fetchWhoAmI.pending, (state) => {
state.status = 'loading'; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase( builder.addCase(fetchWhoAmI.fulfilled, (state, action) => {
fetchWhoAmI.fulfilled, state.status = 'successful';
(state, action: PayloadAction<{ username: string }>) => { state.username = action.payload.username;
state.status = 'successful'; });
state.username = action.payload.username; builder.addCase(fetchWhoAmI.rejected, (state, action) => {
}, state.status = 'failed';
); state.error = action.payload as string;
builder.addCase( });
fetchWhoAmI.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
// Загрузка токенов из localStorage // Загрузка токенов из localStorage
builder.addCase( builder.addCase(
loadTokensFromLocalStorage.fulfilled, loadTokensFromLocalStorage.fulfilled,
( (state, action) => {
state,
action: PayloadAction<{
jwt: string | null;
refreshToken: string | null;
}>,
) => {
state.jwt = action.payload.jwt; state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken; state.refreshToken = action.payload.refreshToken;
if (action.payload.jwt) { 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[ axios.defaults.headers.common[
'Authorization' 'Authorization'
] = `Bearer ${action.payload.jwt}`; ] = `Bearer ${action.payload.jwt}`;

View File

@@ -6,9 +6,16 @@ import axios from '../../axios';
// ===================== // =====================
export interface Mission { export interface Mission {
missionId: number; id: number;
authorId: number;
name: string; name: string;
sortOrder: number; difficulty: number;
tags: string[];
createdAt: string;
updatedAt: string;
timeLimitMilliseconds: number;
memoryLimitBytes: number;
statements: null;
} }
export interface Member { export interface Member {

View File

@@ -23,6 +23,7 @@
line-height: 1.5; line-height: 1.5;
background-color: var(--color-liquid-background); background-color: var(--color-liquid-background);
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
overflow-x: hidden;
} }
#root { #root {

View File

@@ -18,7 +18,6 @@ const Contest = () => {
if (contestIdNumber === null) { if (contestIdNumber === null) {
return <Navigate to="/home/contests" replace />; return <Navigate to="/home/contests" replace />;
} }
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const contest = useAppSelector((state) => state.contests.selectedContest); const contest = useAppSelector((state) => state.contests.selectedContest);

View File

@@ -1,6 +1,7 @@
import { cn } from '../../../lib/cn'; import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from '../../../assets/icons/missions'; import { IconError, IconSuccess } from '../../../assets/icons/missions';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
export interface MissionItemProps { export interface MissionItemProps {
id: number; id: number;
@@ -31,6 +32,8 @@ const MissionItem: React.FC<MissionItemProps> = ({
status, status,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const path = location.pathname + location.search;
return ( return (
<div <div
@@ -45,7 +48,7 @@ const MissionItem: React.FC<MissionItemProps> = ({
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300', 'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
)} )}
onClick={() => { onClick={() => {
navigate(`/mission/${id}`); navigate(`/mission/${id}?back=${path}`);
}} }}
> >
<div className="text-[18px] font-bold">#{id}</div> <div className="text-[18px] font-bold">#{id}</div>

View File

@@ -28,8 +28,10 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
<div className="w-full"> <div className="w-full">
{contest.missions.map((v, i) => ( {contest.missions.map((v, i) => (
<MissionItem <MissionItem
id={v.missionId} id={v.id}
name={v.name} name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
type={i % 2 ? 'second' : 'first'} type={i % 2 ? 'second' : 'first'}
/> />
))} ))}

View File

@@ -9,9 +9,10 @@ import { useNavigate } from 'react-router-dom';
interface HeaderProps { interface HeaderProps {
missionId: number; missionId: number;
back?: string;
} }
const Header: React.FC<HeaderProps> = ({ missionId }) => { const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]"> <header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
@@ -29,7 +30,8 @@ const Header: React.FC<HeaderProps> = ({ missionId }) => {
alt="back" alt="back"
className="h-[24px] w-[24px] cursor-pointer" className="h-[24px] w-[24px] cursor-pointer"
onClick={() => { onClick={() => {
navigate('/home/missions'); if (back) navigate(back);
else navigate('/home/missions');
}} }}
/> />
@@ -39,7 +41,10 @@ const Header: React.FC<HeaderProps> = ({ missionId }) => {
alt="back" alt="back"
className="h-[24px] w-[24px] cursor-pointer" className="h-[24px] w-[24px] cursor-pointer"
onClick={() => { onClick={() => {
navigate(`/mission/${missionId - 1}`); if (missionId <= 1) return;
if (back)
navigate(`/mission/${missionId - 1}?back=${back}`);
else navigate(`/mission/${missionId - 1}`);
}} }}
/> />
<span>{missionId}</span> <span>{missionId}</span>
@@ -48,7 +53,9 @@ const Header: React.FC<HeaderProps> = ({ missionId }) => {
alt="back" alt="back"
className="h-[24px] w-[24px] cursor-pointer" className="h-[24px] w-[24px] cursor-pointer"
onClick={() => { onClick={() => {
navigate(`/mission/${missionId + 1}`); if (back)
navigate(`/mission/${missionId + 1}?back=${back}`);
else navigate(`/mission/${missionId + 1}`);
}} }}
/> />
</div> </div>