diff --git a/src/App.tsx b/src/App.tsx index 6b269fc..eab18e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,9 +5,6 @@ import { Route, Routes } from "react-router-dom"; // import { Input } from "./components/input/Input"; // import { Switch } from "./components/switch/Switch"; import Home from "./pages/Home"; -// import CodeEditor from "./views/mission/codeeditor/CodeEditor"; -import Statement from "./views/mission/statement/Statement"; -import MissionStatement from "./views/mission/statement/Mission"; import Mission from "./pages/Mission"; import UploadMissionForm from "./views/mission/UploadMissionForm"; @@ -19,9 +16,7 @@ function App() { } /> } /> }/> - {/* } /> */} - } /> - } /> + } /> diff --git a/src/assets/icons/header/arrow-left-sm.svg b/src/assets/icons/header/arrow-left-sm.svg new file mode 100644 index 0000000..a78e6db --- /dev/null +++ b/src/assets/icons/header/arrow-left-sm.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/header/chevron-left.svg b/src/assets/icons/header/chevron-left.svg new file mode 100644 index 0000000..77ecb49 --- /dev/null +++ b/src/assets/icons/header/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/header/chevron-right.svg b/src/assets/icons/header/chevron-right.svg new file mode 100644 index 0000000..5df23ae --- /dev/null +++ b/src/assets/icons/header/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/header/index.ts b/src/assets/icons/header/index.ts new file mode 100644 index 0000000..5ea2a4b --- /dev/null +++ b/src/assets/icons/header/index.ts @@ -0,0 +1,5 @@ +import arrowLeft from "./arrow-left-sm.svg"; +import chevroneLeft from "./chevron-left.svg" +import chevroneRight from "./chevron-right.svg" + +export {arrowLeft, chevroneLeft, chevroneRight} \ No newline at end of file diff --git a/src/axios.ts b/src/axios.ts index 1dc691d..3da69b2 100644 --- a/src/axios.ts +++ b/src/axios.ts @@ -7,4 +7,18 @@ const instance = axios.create({ }, }); +// Request interceptor: автоматически подставляет JWT, если есть +instance.interceptors.request.use( + (config) => { + const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState() + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + export default instance; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4f41306..4058f4a 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -5,11 +5,12 @@ import Register from "../views/home/auth/Register"; import Menu from "../views/home/menu/Menu"; import { useAppDispatch, useAppSelector } from "../redux/hooks"; import { useEffect } from "react"; -import { fetchWhoAmI } from "../redux/slices/auth"; +import { fetchWhoAmI, logout } from "../redux/slices/auth"; import Missions from "../views/home/missions/Missions"; import Articles from "../views/home/articles/Articles"; import Groups from "../views/home/groups/Groups"; import Contests from "../views/home/contests/Contests"; +import { PrimaryButton } from "../components/button/PrimaryButton"; const Home = () => { const name = useAppSelector((state) => state.auth.username); @@ -35,7 +36,7 @@ const Home = () => { } /> } /> } /> - + {name} {dispatch(logout())}}>выйти} /> { diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx index 64af650..01814da 100644 --- a/src/pages/Mission.tsx +++ b/src/pages/Mission.tsx @@ -2,10 +2,12 @@ import { useParams, Navigate } from 'react-router-dom'; import CodeEditor from '../views/mission/codeeditor/CodeEditor'; import Statement, { StatementData } from '../views/mission/statement/Statement'; import { PrimaryButton } from '../components/button/PrimaryButton'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../redux/hooks'; -import { submitMission } from '../redux/slices/submit'; +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'; const Mission = () => { @@ -15,35 +17,86 @@ const Mission = () => { const { missionId } = useParams<{ missionId: string }>(); const mission = useAppSelector((state) => state.missions.currentMission); const missionIdNumber = Number(missionId); - - const [code, setCode] = useState(""); - const [language, setLanguage] = useState(""); - - // Если missionId нет, редиректим на /home - - // Если missionId нет или не число — редиректим if (!missionId || isNaN(missionIdNumber)) { return ; } + const [code, setCode] = useState(""); + const [language, setLanguage] = useState(""); + + const pollingRef = useRef(null); + const submissions = useAppSelector((state) => state.submin.submitsById[missionIdNumber] || []); + useEffect(() => { dispatch(fetchMissionById(missionIdNumber)); + dispatch(fetchMySubmitsByMission(missionIdNumber)); + }, [missionIdNumber]); + + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }; }, []); -if (!mission || !mission.statements || mission.statements.length === 0) { - return
Загрузка или миссия не найдена...
; -} + + useEffect(() => { + if (submissions.length === 0) return; + + const hasWaiting = submissions.some( + s => s.solution.status === "Waiting" || s.solution.testerState === "Waiting" + ); + + if (hasWaiting) { + startPolling(); + } + }, [submissions]); + + + if (!mission || !mission.statements || mission.statements.length === 0) { + return
Загрузка...
; + } -const statementRaw = mission.statements[0]; + interface StatementData { + id: number; + legend?: string; + timeLimit?: number; + output?: string; + input?: string; + sampleTests?: any[]; + name?: string; + memoryLimit?: number; + tags?: string[]; + notes?: string; + html?: string; + mediaFiles?: any[]; + } + let statementData: StatementData = { id: mission.id }; try { - const statementTexts = JSON.parse(statementRaw.statementTexts["problem-properties.json"]); - // console.log(mission); + // 1. Берём первый statement с форматом Latex и языком russian + const latexStatement = mission.statements.find( + (stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Latex" + ); + + // 2. Берём первый statement с форматом Html и языком russian + const htmlStatement = mission.statements.find( + (stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html" + ); + + if (!latexStatement) throw new Error("Не найден блок Latex на русском"); + if (!htmlStatement) throw new Error("Не найден блок Html на русском"); + + // 3. Парсим данные из problem-properties.json + const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]); + statementData = { - id: statementRaw.id, + id: missionIdNumber, legend: statementTexts.legend, timeLimit: statementTexts.timeLimit, output: statementTexts.output, @@ -53,41 +106,81 @@ const statementRaw = mission.statements[0]; memoryLimit: statementTexts.memoryLimit, tags: mission.tags, notes: statementTexts.notes, + html: htmlStatement.statementTexts["problem.html"], + mediaFiles: latexStatement.mediaFiles }; } catch (err) { console.error("Ошибка парсинга statementTexts:", err); } - + + + + + const startPolling = () => { + if (pollingRef.current) + return; + + pollingRef.current = setInterval(async () => { + dispatch(fetchMySubmitsByMission(missionIdNumber)); + + const hasWaiting = submissions.some( + (s: any) => s.solution.status == "Waiting" || s.solution.testerState === "Waiting" + ); + if (!hasWaiting) { + // Всё проверено — стоп + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + }, 5000); // 10 секунд + }; + + return ( -
-
+
+
+
-
-
- { setCode(value); }} - onChangeLanguage={((value: string) => { setLanguage(value); })} + +
+
+
-
- { - dispatch(submitMission({ - missionId: missionIdNumber, - language: language, - languageVersion: "latest", - sourceCode: code, - contestId: null, - })) - }} /> +
+
+
+ { setCode(value); }} + onChangeLanguage={((value: string) => { setLanguage(value); })} + /> +
+
+ { + await dispatch(submitMission({ + missionId: missionIdNumber, + language: language, + languageVersion: "latest", + sourceCode: code, + contestId: null, + + })).unwrap(); + dispatch(fetchMySubmitsByMission(missionIdNumber)); + }} /> +
+ +
+ +
+
-
); }; diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index cd40719..41a31ce 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -77,6 +77,22 @@ export const fetchWhoAmI = createAsyncThunk( } ); +// AsyncThunk: Загрузка токенов из localStorage +export const loadTokensFromLocalStorage = createAsyncThunk( + "auth/loadTokens", + async (_, { dispatch }) => { + const jwt = localStorage.getItem("jwt"); + const refreshToken = localStorage.getItem("refreshToken"); + + if (jwt && refreshToken) { + axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`; + return { jwt, refreshToken }; + } else { + return { jwt: null, refreshToken: null }; + } + } +); + // Slice const authSlice = createSlice({ name: "auth", @@ -88,6 +104,9 @@ const authSlice = createSlice({ state.username = null; state.status = "idle"; state.error = null; + localStorage.removeItem("jwt"); + localStorage.removeItem("refreshToken"); + delete axios.defaults.headers.common['Authorization']; }, }, extraReducers: (builder) => { @@ -98,9 +117,11 @@ const authSlice = createSlice({ }); builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { state.status = "successful"; - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; 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"; @@ -114,9 +135,11 @@ const authSlice = createSlice({ }); builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { state.status = "successful"; - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; 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"; @@ -150,6 +173,15 @@ const authSlice = createSlice({ state.status = "failed"; state.error = action.payload; }); + + // Загрузка токенов из localStorage + builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => { + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + if (action.payload.jwt) { + axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; + } + }); }, }); diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index 8beb53d..ee1bcb8 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -17,7 +17,7 @@ interface Mission { tags: string[]; createdAt: string; updatedAt: string; - statements: Statement[] | null; + statements?: Statement[]; } interface MissionsState { @@ -120,7 +120,6 @@ const missionsSlice = createSlice({ }); builder.addCase(fetchMissionById.fulfilled, (state, action: PayloadAction) => { state.status = "successful"; - console.log(action.payload); state.currentMission = action.payload; }); builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction) => { diff --git a/src/redux/slices/submit.ts b/src/redux/slices/submit.ts index e970497..fbe3265 100644 --- a/src/redux/slices/submit.ts +++ b/src/redux/slices/submit.ts @@ -11,17 +11,33 @@ export interface Submit { contestId: number | null; } -export interface SubmitStatus { - SubmitId: number; - State: string; - ErrorCode: string; - Message: string; - CurrentTest: number; - AmountOfTests: number; +export interface Solution { + id: number; + missionId: number; + language: string; + languageVersion: string; + sourceCode: string; + status: string; + time: string; + testerState: string; + testerErrorCode: string; + testerMessage: string; + currentTest: number; + amountOfTests: number; +} + +export interface MissionSubmit { + id: number; + userId: number; + solution: Solution; + contestId: number | null; + contestName: string | null; + sourceType: string; } interface SubmitState { submits: Submit[]; + submitsById: Record; // ✅ добавлено currentSubmit?: Submit; status: "idle" | "loading" | "successful" | "failed"; error: string | null; @@ -30,6 +46,7 @@ interface SubmitState { // Начальное состояние const initialState: SubmitState = { submits: [], + submitsById: {}, // ✅ инициализация currentSubmit: undefined, status: "idle", error: null, @@ -74,13 +91,13 @@ export const fetchSubmitById = createAsyncThunk( } ); -// AsyncThunk: Получить свои отправки для конкретной миссии +// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура) export const fetchMySubmitsByMission = createAsyncThunk( "submit/fetchMySubmitsByMission", async (missionId: number, { rejectWithValue }) => { try { const response = await axios.get(`/submits/my/mission/${missionId}`); - return response.data as Submit[]; + return { missionId, data: response.data as MissionSubmit[] }; } catch (err: any) { return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits"); } @@ -97,6 +114,9 @@ const submitSlice = createSlice({ state.status = "idle"; state.error = null; }, + clearSubmitsByMission: (state, action: PayloadAction) => { + delete state.submitsById[action.payload]; + }, }, extraReducers: (builder) => { // Отправка решения @@ -141,15 +161,18 @@ const submitSlice = createSlice({ state.error = action.payload; }); - // Получить отправки по миссии + // ✅ Получить отправки по миссии builder.addCase(fetchMySubmitsByMission.pending, (state) => { state.status = "loading"; state.error = null; }); - builder.addCase(fetchMySubmitsByMission.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.submits = action.payload; - }); + builder.addCase( + fetchMySubmitsByMission.fulfilled, + (state, action: PayloadAction<{ missionId: number; data: MissionSubmit[] }>) => { + state.status = "successful"; + state.submitsById[action.payload.missionId] = action.payload.data; + } + ); builder.addCase(fetchMySubmitsByMission.rejected, (state, action: PayloadAction) => { state.status = "failed"; state.error = action.payload; @@ -157,5 +180,5 @@ const submitSlice = createSlice({ }, }); -export const { clearCurrentSubmit } = submitSlice.actions; +export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions; export const submitReducer = submitSlice.reducer; diff --git a/src/styles/index.css b/src/styles/index.css index ecfc61b..2063dc2 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -2,6 +2,8 @@ @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; +@import "./latex-container.css"; + * { -webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/ /* outline: 1px solid green; */ @@ -26,7 +28,7 @@ #root { width: 100%; - height: 100%; + height: 100vh; } body { @@ -39,7 +41,6 @@ body { } - /* Общий контейнер полосы прокрутки */ .thin-scrollbar::-webkit-scrollbar { width: 4px; /* ширина вертикального */ @@ -77,6 +78,25 @@ body { +/* Общий контейнер полосы прокрутки */ +.thin-dark-scrollbar::-webkit-scrollbar { + width: 4px; /* ширина вертикального */ +} + +/* Трек (фон) */ +.thin-dark-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +/* Ползунок (thumb) */ +.thin-dark-scrollbar::-webkit-scrollbar-thumb { + background: var(--color-liquid-lighter); + border-radius: 1000px; + cursor: pointer; +} + + + html { scrollbar-gutter: stable; diff --git a/src/styles/latex-container.css b/src/styles/latex-container.css new file mode 100644 index 0000000..600b047 --- /dev/null +++ b/src/styles/latex-container.css @@ -0,0 +1,26 @@ + +.latex-container p { + text-align: justify; /* выравнивание по ширине */ + text-justify: inter-word; + margin-bottom: 0.8em; /* небольшой отступ между абзацами */ + line-height: 1.2; + /* text-indent: 1em; */ +} + +.latex-container ol { + padding-left: 1.5em; /* отступ для нумерации */ + margin: 0.5em 0; /* небольшой отступ сверху и снизу */ + line-height: 1.5; /* удобный межстрочный интервал */ + font-family: "Inter", sans-serif; + font-size: 1rem; +} + +.latex-container ol li { + margin-bottom: 0.4em; /* расстояние между пунктами */ +} + +.latex-container .section-title{ + font-size: 16px; + font-weight: bold; +} + diff --git a/src/views/home/articles/ArticleItem.tsx b/src/views/home/articles/ArticleItem.tsx index a7dfdc5..9676fab 100644 --- a/src/views/home/articles/ArticleItem.tsx +++ b/src/views/home/articles/ArticleItem.tsx @@ -9,7 +9,6 @@ export interface ArticleItemProps { const ArticleItem: React.FC = ({ id, name, tags }) => { - console.log(id); return (
{ // После успешного логина useEffect(() => { - console.log(submitClicked); dispatch(setMenuActivePage("account")) }, []); @@ -37,7 +36,6 @@ const Login = () => { const handleLogin = () => { // setErr(err == "" ? "Неверная почта и/или пароль" : ""); - // console.log(123); setSubmitClicked(true); if (!username || !password) return; diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index 38f0e12..4f39ef7 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -28,7 +28,6 @@ const Register = () => { // После успешной регистрации — переход в систему useEffect(() => { - console.log(submitClicked); dispatch(setMenuActivePage("account")) }, []); @@ -71,7 +70,7 @@ const Register = () => {
{ console.log(value) }} + onChange={(value: boolean) => { value; }} className="p-0 w-fit m-[2.75px]" size="md" color="secondary" diff --git a/src/views/home/contests/ContestsBlock.tsx b/src/views/home/contests/ContestsBlock.tsx index 63a5d32..ec14e55 100644 --- a/src/views/home/contests/ContestsBlock.tsx +++ b/src/views/home/contests/ContestsBlock.tsx @@ -37,7 +37,6 @@ const GroupsBlock: FC = ({ contests, title, className }) => { active && "border-b-liquid-lighter" )} onClick={() => { - console.log(active); setActive(!active) }}> {title} diff --git a/src/views/home/groups/GroupItem.tsx b/src/views/home/groups/GroupItem.tsx index eafd491..d4821d4 100644 --- a/src/views/home/groups/GroupItem.tsx +++ b/src/views/home/groups/GroupItem.tsx @@ -26,7 +26,6 @@ const IconComponent: React.FC = ({ const GroupItem: React.FC = ({ id, name, visible, role }) => { - console.log(id); return (
diff --git a/src/views/home/groups/GroupsBlock.tsx b/src/views/home/groups/GroupsBlock.tsx index fd4e2e1..05e7e76 100644 --- a/src/views/home/groups/GroupsBlock.tsx +++ b/src/views/home/groups/GroupsBlock.tsx @@ -33,7 +33,6 @@ const GroupsBlock: FC = ({ groups, title, className }) => { active && " border-b-liquid-lighter" )} onClick={() => { - console.log(active); setActive(!active) }}> {title} diff --git a/src/views/mission/codeeditor/CodeEditor.tsx b/src/views/mission/codeeditor/CodeEditor.tsx index cd048c4..a9133d9 100644 --- a/src/views/mission/codeeditor/CodeEditor.tsx +++ b/src/views/mission/codeeditor/CodeEditor.tsx @@ -20,14 +20,14 @@ export interface CodeEditorProps { } const CodeEditor: React.FC = ({onChange, onChangeLanguage}) => { - const [language, setLanguage] = useState("cpp"); + const [language, setLanguage] = useState("C++"); const [code, setCode] = useState(""); const [isDragging, setIsDragging] = useState(false); const items = [ { value: "c", text: "C" }, - { value: "cpp", text: "C++" }, + { value: "C++", text: "C++" }, { value: "java", text: "Java" }, { value: "python", text: "Python" }, { value: "pascal", text: "Pascal" }, @@ -88,7 +88,7 @@ const CodeEditor: React.FC = ({onChange, onChangeLanguage}) => {/* Панель выбора языка и загрузки файла */}
- { setLanguage(v) }} /> + { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/>