diff --git a/src/App.tsx b/src/App.tsx index 3a5d6ff..eab18e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,103 +5,21 @@ 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/problem/codeeditor/CodeEditor"; -import Statement from "./views/problem/statement/Statement"; +import Mission from "./pages/Mission"; +import UploadMissionForm from "./views/mission/UploadMissionForm"; function App() { return ( -
- - }/> -
}/> - }/> - }/> - +
+
+ + } /> + } /> + }/> + } /> + - {/* { - document.documentElement.setAttribute( - "data-theme", - state ? "dark" : "light" - ); - }} - /> -
- { - console.log(state); - }} - /> - { - console.log(state); - }} - /> - { - console.log(state); - }} - /> - - { - console.log(state); - }} - /> - { }} label="test" color="default" defaultState={true}/> - { }} label="test" color="primary" defaultState={true}/> - { }} label="test" color="secondary" defaultState={true}/> - { }} label="test" color="success" defaultState={true}/> - { }} label="test" color="warning" defaultState={true}/> - { }} label="test" color="danger" defaultState={true}/> - { }} color="default" defaultState={true}/> - { }} color="primary" defaultState={true}/> - { }} color="secondary" defaultState={true}/> - { }} color="success" defaultState={true}/> - { }} color="warning" defaultState={true}/> - { }} color="danger" defaultState={true}/> - - -
- - { }} text="Button" className="m-5" /> - { }} text="Button" className="m-5" /> - { }} text="Button" disabled className="m-5" /> - { }} text="Button" className="m-5" /> - { }} text="Button" className="m-5" /> - { }} text="Button" disabled className="m-5" /> -
-
*/}
); } diff --git a/src/assets/icons/groups/book.png b/src/assets/icons/groups/book.png new file mode 100644 index 0000000..260af3b Binary files /dev/null and b/src/assets/icons/groups/book.png differ diff --git a/src/assets/icons/groups/chevron-down.svg b/src/assets/icons/groups/chevron-down.svg new file mode 100644 index 0000000..cda1cc2 --- /dev/null +++ b/src/assets/icons/groups/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/groups/edit.svg b/src/assets/icons/groups/edit.svg new file mode 100644 index 0000000..abafac2 --- /dev/null +++ b/src/assets/icons/groups/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/groups/eye-closed.svg b/src/assets/icons/groups/eye-closed.svg new file mode 100644 index 0000000..a3a57eb --- /dev/null +++ b/src/assets/icons/groups/eye-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/groups/eye-open.png b/src/assets/icons/groups/eye-open.png new file mode 100644 index 0000000..eee5690 Binary files /dev/null and b/src/assets/icons/groups/eye-open.png differ diff --git a/src/assets/icons/groups/index.ts b/src/assets/icons/groups/index.ts new file mode 100644 index 0000000..86bef2e --- /dev/null +++ b/src/assets/icons/groups/index.ts @@ -0,0 +1,8 @@ +import Book from "./book.png" +import EyeClosed from "./eye-closed.svg"; +import EyeOpen from "./eye-open.png"; +import Edit from "./edit.svg"; +import UserAdd from "./user-profile-add.svg"; +import ChevroneDown from "./chevron-down.svg" + +export {Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown} \ No newline at end of file diff --git a/src/assets/icons/groups/user-profile-add.svg b/src/assets/icons/groups/user-profile-add.svg new file mode 100644 index 0000000..20bbea5 --- /dev/null +++ b/src/assets/icons/groups/user-profile-add.svg @@ -0,0 +1,3 @@ + + + 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/assets/icons/missions/icon-error.svg b/src/assets/icons/missions/icon-error.svg new file mode 100644 index 0000000..4d7e75d --- /dev/null +++ b/src/assets/icons/missions/icon-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/missions/icon-success.svg b/src/assets/icons/missions/icon-success.svg new file mode 100644 index 0000000..343b6c1 --- /dev/null +++ b/src/assets/icons/missions/icon-success.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/missions/index.ts b/src/assets/icons/missions/index.ts new file mode 100644 index 0000000..30948a7 --- /dev/null +++ b/src/assets/icons/missions/index.ts @@ -0,0 +1,4 @@ +import IconSuccess from "./icon-success.svg" +import IconError from "./icon-error.svg" + +export {IconError, IconSuccess} \ 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 cb7eb27..4058f4a 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -5,7 +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); @@ -18,8 +23,8 @@ const Home = () => { return ( -
-
+
+
@@ -27,10 +32,18 @@ const Home = () => { } /> } /> } /> - + } /> + } /> + } /> + } /> + {name} {dispatch(logout())}}>выйти} /> -
+ { + +
} /> + + }
); }; diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx new file mode 100644 index 0000000..01814da --- /dev/null +++ b/src/pages/Mission.tsx @@ -0,0 +1,188 @@ +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, useRef, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../redux/hooks'; +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 = () => { + + const dispatch = useAppDispatch(); + + // Получаем параметры из URL + const { missionId } = useParams<{ missionId: string }>(); + const mission = useAppSelector((state) => state.missions.currentMission); + const missionIdNumber = Number(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; + } + }; + }, []); + + + 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
Загрузка...
; + } + + + + 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 { + // 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: missionIdNumber, + legend: statementTexts.legend, + timeLimit: statementTexts.timeLimit, + output: statementTexts.output, + input: statementTexts.input, + sampleTests: statementTexts.sampleTests, + name: statementTexts.name, + 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); })} + /> +
+
+ { + await dispatch(submitMission({ + missionId: missionIdNumber, + language: language, + languageVersion: "latest", + sourceCode: code, + contestId: null, + + })).unwrap(); + dispatch(fetchMySubmitsByMission(missionIdNumber)); + }} /> +
+ +
+ +
+
+
+
+
+ ); +}; + +export default Mission; diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index ba9e681..41a31ce 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -6,7 +6,7 @@ interface AuthState { jwt: string | null; refreshToken: string | null; username: string | null; - status: "idle" | "loading" | "succeeded" | "failed"; + status: "idle" | "loading" | "successful" | "failed"; error: string | null; } @@ -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) => { @@ -97,10 +116,12 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { - state.status = "succeeded"; - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; + 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"; @@ -113,10 +134,12 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { - state.status = "succeeded"; - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; + 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"; @@ -129,7 +152,7 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => { - state.status = "succeeded"; + state.status = "successful"; state.username = action.payload.username; }); builder.addCase(refreshToken.rejected, (state, action: PayloadAction) => { @@ -143,13 +166,22 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => { - state.status = "succeeded"; + state.status = "successful"; state.username = action.payload.username; }); builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction) => { 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 new file mode 100644 index 0000000..ee1bcb8 --- /dev/null +++ b/src/redux/slices/missions.ts @@ -0,0 +1,146 @@ +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import axios from "../../axios"; + +// Типы данных +interface Statement { + id: number; + language: string; + statementTexts: Record; + mediaFiles?: { id: number; fileName: string; mediaUrl: string }[]; +} + +interface Mission { + id: number; + authorId: number; + name: string; + difficulty: number; + tags: string[]; + createdAt: string; + updatedAt: string; + statements?: Statement[]; +} + +interface MissionsState { + missions: Mission[]; + currentMission: Mission | null; + hasNextPage: boolean; + status: "idle" | "loading" | "successful" | "failed"; + error: string | null; +} + +// Инициализация состояния +const initialState: MissionsState = { + missions: [], + currentMission: null, + hasNextPage: false, + status: "idle", + error: null, +}; + +// AsyncThunk: Получение списка миссий +export const fetchMissions = createAsyncThunk( + "missions/fetchMissions", + async ( + { page = 0, pageSize = 10, tags = [] }: { page?: number; pageSize?: number; tags?: string[] }, + { rejectWithValue } + ) => { + try { + const params: any = { page, pageSize }; + if (tags) params.tags = tags; + const response = await axios.get("/missions", { params }); + return response.data; // { hasNextPage, missions } + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch missions"); + } + } +); + +// AsyncThunk: Получение миссии по id +export const fetchMissionById = createAsyncThunk( + "missions/fetchMissionById", + async (id: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/missions/${id}`); + return response.data; // Mission + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch mission"); + } + } +); + +// AsyncThunk: Загрузка миссии +export const uploadMission = createAsyncThunk( + "missions/uploadMission", + async ( + { file, name, difficulty, tags }: { file: File; name: string; difficulty: number; tags: string[] }, + { rejectWithValue } + ) => { + try { + const formData = new FormData(); + formData.append("MissionFile", file); + formData.append("Name", name); + formData.append("Difficulty", difficulty.toString()); + tags.forEach(tag => formData.append("Tags", tag)); + + const response = await axios.post("/missions/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; // Mission + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to upload mission"); + } + } +); + +// Slice +const missionsSlice = createSlice({ + name: "missions", + initialState, + reducers: {}, + extraReducers: (builder) => { + // fetchMissions + builder.addCase(fetchMissions.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchMissions.fulfilled, (state, action: PayloadAction<{ missions: Mission[]; hasNextPage: boolean }>) => { + state.status = "successful"; + state.missions = action.payload.missions; + state.hasNextPage = action.payload.hasNextPage; + }); + builder.addCase(fetchMissions.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // fetchMissionById + builder.addCase(fetchMissionById.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchMissionById.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.currentMission = action.payload; + }); + builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // uploadMission + builder.addCase(uploadMission.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(uploadMission.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.missions.unshift(action.payload); // Добавляем новую миссию в начало списка + }); + builder.addCase(uploadMission.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + }, +}); + +export const missionsReducer = missionsSlice.reducer; diff --git a/src/redux/slices/submit.ts b/src/redux/slices/submit.ts new file mode 100644 index 0000000..fbe3265 --- /dev/null +++ b/src/redux/slices/submit.ts @@ -0,0 +1,184 @@ +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import axios from "../../axios"; + +// Типы данных +export interface Submit { + id?: number; + missionId: number; + language: string; + languageVersion: string; + sourceCode: string; + contestId: number | null; +} + +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; +} + +// Начальное состояние +const initialState: SubmitState = { + submits: [], + submitsById: {}, // ✅ инициализация + currentSubmit: undefined, + status: "idle", + error: null, +}; + +// AsyncThunk: Отправка решения +export const submitMission = createAsyncThunk( + "submit/submitMission", + async (submitData: Submit, { rejectWithValue }) => { + try { + const response = await axios.post("/submits", submitData); + return response.data; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Submit failed"); + } + } +); + +// AsyncThunk: Получить все свои отправки +export const fetchMySubmits = createAsyncThunk( + "submit/fetchMySubmits", + async (_, { rejectWithValue }) => { + try { + const response = await axios.get("/submits/my"); + return response.data as Submit[]; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch submits"); + } + } +); + +// AsyncThunk: Получить конкретную отправку по ID +export const fetchSubmitById = createAsyncThunk( + "submit/fetchSubmitById", + async (id: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/submits/${id}`); + return response.data as Submit; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch submit"); + } + } +); + +// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура) +export const fetchMySubmitsByMission = createAsyncThunk( + "submit/fetchMySubmitsByMission", + async (missionId: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/submits/my/mission/${missionId}`); + return { missionId, data: response.data as MissionSubmit[] }; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits"); + } + } +); + +// Slice +const submitSlice = createSlice({ + name: "submit", + initialState, + reducers: { + clearCurrentSubmit: (state) => { + state.currentSubmit = undefined; + state.status = "idle"; + state.error = null; + }, + clearSubmitsByMission: (state, action: PayloadAction) => { + delete state.submitsById[action.payload]; + }, + }, + extraReducers: (builder) => { + // Отправка решения + builder.addCase(submitMission.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(submitMission.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.submits.push(action.payload); + }); + builder.addCase(submitMission.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // Получить все свои отправки + builder.addCase(fetchMySubmits.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchMySubmits.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.submits = action.payload; + }); + builder.addCase(fetchMySubmits.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // Получить отправку по ID + builder.addCase(fetchSubmitById.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchSubmitById.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.currentSubmit = action.payload; + }); + builder.addCase(fetchSubmitById.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ✅ Получить отправки по миссии + builder.addCase(fetchMySubmitsByMission.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + 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; + }); + }, +}); + +export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions; +export const submitReducer = submitSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 509071b..6ea89a8 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,6 +1,8 @@ import { configureStore } from "@reduxjs/toolkit"; import { authReducer } from "./slices/auth"; import { storeReducer } from "./slices/store"; +import { missionsReducer } from "./slices/missions"; +import { submitReducer } from "./slices/submit"; // использование @@ -17,6 +19,8 @@ export const store = configureStore({ //user: userReducer, auth: authReducer, store: storeReducer, + missions: missionsReducer, + submin: submitReducer, }, }); diff --git a/src/styles/index.css b/src/styles/index.css index 6356eee..2063dc2 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -2,11 +2,14 @@ @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; +@import "./latex-container.css"; + * { -webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/ /* outline: 1px solid green; */ } + :root { color-scheme: light dark; width: 100%; @@ -19,12 +22,13 @@ /* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */ font-weight: 400; line-height: 1.5; + background-color: var(--color-liquid-background); color: rgba(255, 255, 255, 0.87); } #root { width: 100%; - height: 100%; + height: 100vh; } body { @@ -37,7 +41,6 @@ body { } - /* Общий контейнер полосы прокрутки */ .thin-scrollbar::-webkit-scrollbar { width: 4px; /* ширина вертикального */ @@ -73,3 +76,42 @@ body { cursor: pointer; } + + +/* Общий контейнер полосы прокрутки */ +.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; + padding-left: 8px; +} +html::-webkit-scrollbar { + width: 8px; /* ширина вертикального */ +} +/* Трек (фон) */ +html::-webkit-scrollbar-track { + background: transparent; +} +/* Ползунок (thumb) */ +html::-webkit-scrollbar-thumb { + background-color: var(--color-liquid-lighter); + border-radius: 1000px; + cursor: pointer; +} \ No newline at end of file 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 new file mode 100644 index 0000000..9676fab --- /dev/null +++ b/src/views/home/articles/ArticleItem.tsx @@ -0,0 +1,41 @@ +import { cn } from "../../../lib/cn"; + +export interface ArticleItemProps { + id: number; + name: string; + tags: string[]; +} + +const ArticleItem: React.FC = ({ + id, name, tags +}) => { + return ( +
+
+ +
+ #{id} +
+
+ {name} +
+
+
+ {tags.map((v, i) => +
+ {v} +
+ )} +
+ +
+ ); +}; + +export default ArticleItem; diff --git a/src/views/home/articles/Articles.tsx b/src/views/home/articles/Articles.tsx new file mode 100644 index 0000000..de5f1d4 --- /dev/null +++ b/src/views/home/articles/Articles.tsx @@ -0,0 +1,171 @@ +import { useEffect } from "react"; +import { SecondaryButton } from "../../../components/button/SecondaryButton"; +import { useAppDispatch } from "../../../redux/hooks"; +import ArticleItem from "./ArticleItem"; +import { setMenuActivePage } from "../../../redux/slices/store"; + + +export interface Article { + id: number; + name: string; + tags: string[]; +} + + +const Articles = () => { + + const dispatch = useAppDispatch(); + + const articles: Article[] = [ + { + "id": 1, + "name": "Todo List App", + "tags": ["Sertificated", "state", "list"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + }, + { + "id": 2, + "name": "Search Filter Component", + "tags": ["filter", "props", "hooks"], + }, + { + "id": 3, + "name": "User Card List", + "tags": ["components", "props", "array"], + }, + { + "id": 4, + "name": "Theme Switcher", + "tags": ["Sertificated", "theme", "hooks"], + } + ]; + + useEffect(() => { + dispatch(setMenuActivePage("articles")) + }, []); + + return ( +
+
+ +
+
+ Статьи +
+ { }} + text="Создать статью" + className="absolute right-0" + /> +
+ +
+ +
+ +
+ + {articles.map((v, i) => ( + + ))} +
+ + +
+ pages +
+
+
+ ); +}; + +export default Articles; diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx index 6013949..e1d5432 100644 --- a/src/views/home/auth/Login.tsx +++ b/src/views/home/auth/Login.tsx @@ -4,7 +4,7 @@ import { Input } from "../../../components/input/Input"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { Link, useNavigate } from "react-router-dom"; import { loginUser } from "../../../redux/slices/auth"; -import { cn } from "../../../lib/cn"; +// import { cn } from "../../../lib/cn"; import { setMenuActivePage } from "../../../redux/slices/store"; import { Balloon } from "../../../assets/icons/auth"; import { SecondaryButton } from "../../../components/button/SecondaryButton"; @@ -18,14 +18,17 @@ const Login = () => { const [password, setPassword] = useState(""); const [submitClicked, setSubmitClicked] = useState(false); - const { status, error, jwt } = useAppSelector((state) => state.auth); + const { status, jwt } = useAppSelector((state) => state.auth); - const [err, setErr] = useState(""); + // const [err, setErr] = useState(""); // После успешного логина useEffect(() => { dispatch(setMenuActivePage("account")) + }, []); + + useEffect(() => { if (jwt) { navigate("/home/offices"); // или другая страница после входа } @@ -33,7 +36,6 @@ const Login = () => { const handleLogin = () => { // setErr(err == "" ? "Неверная почта и/или пароль" : ""); - // console.log(123); setSubmitClicked(true); if (!username || !password) return; @@ -58,8 +60,8 @@ const Login = () => {
- {setUsername(v)}} placeholder="login"/> - {setPassword(v)}} placeholder="abCD1234" /> + { setUsername(v) }} placeholder="login" /> + { setPassword(v) }} placeholder="abCD1234" />
{ /> {}} + onClick={() => { }} >
diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index 7aae6c7..4f39ef7 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -4,7 +4,7 @@ import { Input } from "../../../components/input/Input"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useNavigate } from "react-router-dom"; import { registerUser } from "../../../redux/slices/auth"; -import { cn } from "../../../lib/cn"; +// import { cn } from "../../../lib/cn"; import { setMenuActivePage } from "../../../redux/slices/store"; import { Balloon } from "../../../assets/icons/auth"; import { Link } from "react-router-dom"; @@ -23,11 +23,15 @@ const Register = () => { const [confirmPassword, setConfirmPassword] = useState(""); const [submitClicked, setSubmitClicked] = useState(false); - const { status, error, jwt } = useAppSelector((state) => state.auth); + const { status, jwt } = useAppSelector((state) => state.auth); // После успешной регистрации — переход в систему + + useEffect(() => { + dispatch(setMenuActivePage("account")) + }, []); + useEffect(() => { - dispatch(setMenuActivePage("account")); if (jwt) { navigate("/home"); } @@ -66,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/ContestItem.tsx b/src/views/home/contests/ContestItem.tsx new file mode 100644 index 0000000..5451ae7 --- /dev/null +++ b/src/views/home/contests/ContestItem.tsx @@ -0,0 +1,72 @@ +import { cn } from "../../../lib/cn"; + +export interface ContestItemProps { + id: number; + name: string; + authors: string[]; + startAt: string; + registerAt: string; + duration: number; + members: number; + statusRegister: "reg" | "nonreg"; + type: "first" | "second"; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, "0"); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + + + +const ContestItem: React.FC = ({ + id, name, authors, startAt, registerAt, duration, members, statusRegister, type +}) => { + const now = new Date(); + + const waitTime = new Date(startAt).getTime() - now.getTime(); + + return ( +
+
+ {name} +
+
+ {authors.map((v, i) =>

{v}

)} +
+
+ {formatDate(startAt)} +
+
+ {duration} +
+ { + waitTime > 0 && +
+ {waitTime} +
+ } +
+ {members} +
+
+ {statusRegister} +
+ +
+ ); +}; + +export default ContestItem; diff --git a/src/views/home/contests/Contests.tsx b/src/views/home/contests/Contests.tsx new file mode 100644 index 0000000..2d68158 --- /dev/null +++ b/src/views/home/contests/Contests.tsx @@ -0,0 +1,131 @@ +import { useEffect } from "react"; +import { SecondaryButton } from "../../../components/button/SecondaryButton"; +import { cn } from "../../../lib/cn"; +import { useAppDispatch } from "../../../redux/hooks"; +import ContestsBlock from "./ContestsBlock"; +import { setMenuActivePage } from "../../../redux/slices/store"; + + +interface Contest { + id: number; + name: string; + authors: string[]; + startAt: string; + registerAt: string; + duration: number; + members: number; + statusRegister: "reg" | "nonreg"; +} + + + +const Contests = () => { + + const dispatch = useAppDispatch(); + const now = new Date(); + const contests: Contest[] = [ + // === Прошедшие контесты === + { + id: 1, + name: "Code Marathon 2025", + authors: ["tourist", "Petr", "Semen", "Rotar"], + startAt: "2025-09-15T10:00:00.000Z", + registerAt: "2025-09-10T10:00:00.000Z", + duration: 180, + members: 4821, + statusRegister: "reg", + }, + { + id: 2, + name: "Autumn Cup 2025", + authors: ["awoo", "Benq"], + startAt: "2025-09-25T17:00:00.000Z", + registerAt: "2025-09-20T17:00:00.000Z", + duration: 150, + members: 3670, + statusRegister: "nonreg", + }, + + // === Контесты, которые сейчас идут === + { + id: 3, + name: "Halloween Challenge", + authors: ["Errichto", "Radewoosh"], + startAt: "2025-10-29T10:00:00.000Z", // начался сегодня + registerAt: "2025-10-25T10:00:00.000Z", + duration: 240, + members: 5123, + statusRegister: "reg", + }, + { + id: 4, + name: "October Blitz", + authors: ["neal", "Um_nik"], + startAt: "2025-10-29T12:00:00.000Z", + registerAt: "2025-10-24T12:00:00.000Z", + duration: 300, + members: 2890, + statusRegister: "nonreg", + }, + + // === Контесты, которые еще не начались === + { + id: 5, + name: "Winter Warmup", + authors: ["tourist", "rng_58"], + startAt: "2025-11-05T18:00:00.000Z", + registerAt: "2025-11-01T18:00:00.000Z", + duration: 180, + members: 2100, + statusRegister: "reg", + }, + { + id: 6, + name: "Global Coding Cup", + authors: ["maroonrk", "kostka"], + startAt: "2025-11-12T15:00:00.000Z", + registerAt: "2025-11-08T15:00:00.000Z", + duration: 240, + members: 1520, + statusRegister: "nonreg", + }, + ]; + + useEffect(() => { + dispatch(setMenuActivePage("contests")) + }, []); + + return ( +
+
+ +
+
+ Контесты +
+ { }} + text="Создать группу" + className="absolute right-0" + /> +
+ +
+ +
+ + + { + const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000; + return endTime >= now.getTime(); + })} /> + { + const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000; + return endTime < now.getTime(); + })} /> +
+
+ ); +}; + +export default Contests; diff --git a/src/views/home/contests/ContestsBlock.tsx b/src/views/home/contests/ContestsBlock.tsx new file mode 100644 index 0000000..ec14e55 --- /dev/null +++ b/src/views/home/contests/ContestsBlock.tsx @@ -0,0 +1,63 @@ +import { useState, FC } from "react"; +import { cn } from "../../../lib/cn"; +import { ChevroneDown } from "../../../assets/icons/groups"; +import ContestItem from "./ContestItem"; + + +interface Contest { + id: number; + name: string; + authors: string[]; + startAt: string; + registerAt: string; + duration: number; + members: number; + statusRegister: "reg" | "nonreg"; +} + +interface GroupsBlockProps { + contests: Contest[]; + title: string; + className?: string; +} + + +const GroupsBlock: FC = ({ contests, title, className }) => { + + + const [active, setActive] = useState(title != "Скрытые"); + + + return ( + +
+
{ + setActive(!active) + }}> + {title} + +
+
+
+
+ { + contests.map((v, i) => ) + } +
+ +
+
+
+ ); +}; + +export default GroupsBlock; diff --git a/src/views/home/groups/GroupItem.tsx b/src/views/home/groups/GroupItem.tsx new file mode 100644 index 0000000..d4821d4 --- /dev/null +++ b/src/views/home/groups/GroupItem.tsx @@ -0,0 +1,58 @@ +import { cn } from "../../../lib/cn"; +import { Book, UserAdd, Edit, EyeClosed, EyeOpen } from "../../../assets/icons/groups"; + +export interface GroupItemProps { + id: number; + role: "menager" | "member" | "owner" | "viewer"; + visible: boolean; + name: string; +} + + +interface IconComponentProps { + src: string; +} + +const IconComponent: React.FC = ({ + src +}) => { + + return +} + +const GroupItem: React.FC = ({ + id, name, visible, role +}) => { + return ( +
+
+ +
+
+ {name} +
+
+ { + (role == "menager" || role == "owner") && + } + { + (role == "menager" || role == "owner") && + } + { + visible == false && + } + { + visible == true && + } +
+
+
+
+ ); +}; + +export default GroupItem; diff --git a/src/views/home/groups/Groups.tsx b/src/views/home/groups/Groups.tsx new file mode 100644 index 0000000..34559f6 --- /dev/null +++ b/src/views/home/groups/Groups.tsx @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import { SecondaryButton } from "../../../components/button/SecondaryButton"; +import { cn } from "../../../lib/cn"; +import { useAppDispatch } from "../../../redux/hooks"; +import GroupsBlock from "./GroupsBlock"; +import { setMenuActivePage } from "../../../redux/slices/store"; + + +export interface Group { + id: number; + role: "menager" | "member" | "owner" | "viewer"; + visible: boolean; + name: string; +} + + +const Groups = () => { + + const dispatch = useAppDispatch(); + + const groups: Group[] = [ + { id: 1, role: "owner", name: "Main Administration", visible: true }, + { id: 2, role: "menager", name: "Project Managers", visible: true }, + { id: 3, role: "member", name: "Developers", visible: true }, + { id: 4, role: "viewer", name: "QA Viewers", visible: true }, + { id: 5, role: "member", name: "Design Team", visible: true }, + { id: 6, role: "owner", name: "Executive Board", visible: true }, + { id: 7, role: "menager", name: "HR Managers", visible: true }, + { id: 8, role: "viewer", name: "Marketing Reviewers", visible: false }, + { id: 9, role: "member", name: "Content Creators", visible: false }, + { id: 10, role: "menager", name: "Support Managers", visible: true }, + { id: 11, role: "viewer", name: "External Auditors", visible: false }, + { id: 12, role: "member", name: "Frontend Developers", visible: true }, + { id: 13, role: "member", name: "Backend Developers", visible: true }, + { id: 14, role: "viewer", name: "Guest Access", visible: false }, + { id: 15, role: "menager", name: "Operations", visible: true }, + ]; + + useEffect(() => { + dispatch(setMenuActivePage("groups")) + }, []); + + return ( +
+
+ +
+
+ Группы +
+ { }} + text="Создать группу" + className="absolute right-0" + /> +
+ +
+ +
+ + + v.visible && (v.role == "owner" || v.role == "menager"))} /> + v.visible && (v.role == "member" || v.role == "viewer"))} /> + v.visible == false)} /> +
+
+ ); +}; + +export default Groups; diff --git a/src/views/home/groups/GroupsBlock.tsx b/src/views/home/groups/GroupsBlock.tsx new file mode 100644 index 0000000..05e7e76 --- /dev/null +++ b/src/views/home/groups/GroupsBlock.tsx @@ -0,0 +1,59 @@ +import { useState, FC } from "react"; +import GroupItem from "./GroupItem"; +import { cn } from "../../../lib/cn"; +import { ChevroneDown } from "../../../assets/icons/groups"; + + +export interface Group { + id: number; + role: "menager" | "member" | "owner" | "viewer"; + visible: boolean; + name: string; +} + +interface GroupsBlockProps { + groups: Group[]; + title: string; + className?: string; +} + + +const GroupsBlock: FC = ({ groups, title, className }) => { + + + const [active, setActive] = useState(title != "Скрытые"); + + + return ( + +
+
{ + setActive(!active) + }}> + {title} + +
+
+
+ +
+ { + groups.map((v, i) => ) + } +
+
+
+
+ ); +}; + +export default GroupsBlock; diff --git a/src/views/home/menu/Menu.tsx b/src/views/home/menu/Menu.tsx index 0e505a1..0e5cab3 100644 --- a/src/views/home/menu/Menu.tsx +++ b/src/views/home/menu/Menu.tsx @@ -6,17 +6,17 @@ import { useAppSelector } from "../../../redux/hooks"; const Menu = () => { const menuItems = [ {text: "Главная", href: "/home", icon: Home, page: "home" }, - {text: "Задачи", href: "/home", icon: Clipboard, page: "clipboard" }, - {text: "Статьи", href: "/home", icon: Openbook, page: "openbool" }, - {text: "Группы", href: "/home", icon: Users, page: "users" }, - {text: "Контесты", href: "/home", icon: Cup, page: "cup" }, + {text: "Задачи", href: "/home/missions", icon: Clipboard, page: "missions" }, + {text: "Статьи", href: "/home/articles", icon: Openbook, page: "articles" }, + {text: "Группы", href: "/home/groups", icon: Users, page: "groups" }, + {text: "Контесты", href: "/home/contests", icon: Cup, page: "contests" }, {text: "Аккаунт", href: "/home/account", icon: Account, page: "account" }, ]; const activePage = useAppSelector((state) => state.store.menu.activePage); return ( -
- +
+
{menuItems.map((v, i) => ( diff --git a/src/views/home/missions/MissionItem.tsx b/src/views/home/missions/MissionItem.tsx new file mode 100644 index 0000000..2bdf6f8 --- /dev/null +++ b/src/views/home/missions/MissionItem.tsx @@ -0,0 +1,74 @@ +import { cn } from "../../../lib/cn"; +import { IconError, IconSuccess } from "../../../assets/icons/missions"; +import { useNavigate } from "react-router-dom"; + +export interface MissionItemProps { + id: number; + authorId: number; + name: string; + difficulty: "Easy" | "Medium" | "Hard"; + tags: string[]; + timeLimit: number; + memoryLimit: number; + createdAt: string; + updatedAt: string; + type: "first" | "second"; + status: "empty" | "success" | "error"; +} + +export function formatMilliseconds(ms: number): string { + const rounded = Math.round(ms) / 1000; + const formatted = rounded.toString().replace(/\.?0+$/, ''); + return `${formatted} c`; +} + +export function formatBytesToMB(bytes: number): string { + const megabytes = Math.floor(bytes / (1024 * 1024)); + return `${megabytes} МБ`; +} + +const MissionItem: React.FC = ({ + id, name, difficulty, timeLimit, memoryLimit, type, status +}) => { + const navigate = useNavigate(); + + return ( +
{navigate(`/mission/${id}`)}} + > +
+ #{id} +
+
+ {name} +
+
+ стандартный ввод/вывод {formatMilliseconds(timeLimit)}, {formatBytesToMB(memoryLimit)} +
+
+ {difficulty} +
+
+ { + status == "error" && + } + { + status == "success" && + } +
+
+ ); +}; + +export default MissionItem; diff --git a/src/views/home/missions/Missions.tsx b/src/views/home/missions/Missions.tsx new file mode 100644 index 0000000..c62b0db --- /dev/null +++ b/src/views/home/missions/Missions.tsx @@ -0,0 +1,82 @@ +import MissionItem from "./MissionItem"; +import { SecondaryButton } from "../../../components/button/SecondaryButton"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; +import { useEffect } from "react"; +import { setMenuActivePage } from "../../../redux/slices/store"; +import { useNavigate } from "react-router-dom"; +import { fetchMissions } from "../../../redux/slices/missions"; + + +export interface Mission { + id: number; + authorId: number; + name: string; + difficulty: "Easy" | "Medium" | "Hard"; + tags: string[]; + timeLimit: number; + memoryLimit: number; + createdAt: string; + updatedAt: string; +} + +const Missions = () => { + + const dispatch = useAppDispatch(); + const naivgate = useNavigate(); + + const missions = useAppSelector((state) => state.missions.missions); + + useEffect(() => { + dispatch(setMenuActivePage("missions")) + dispatch(fetchMissions({})) + }, []); + + + return ( +
+
+ +
+
+ Задачи +
+ {naivgate("/upload")}} + text="Создать задачу" + className="absolute right-0" + /> +
+ +
+ +
+ +
+ + {missions.map((v, i) => ( + + ))} +
+ + +
+ pages +
+
+
+ ); +}; + +export default Missions; diff --git a/src/views/mission/UploadMissionForm.tsx b/src/views/mission/UploadMissionForm.tsx new file mode 100644 index 0000000..f5ea22f --- /dev/null +++ b/src/views/mission/UploadMissionForm.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { uploadMission } from "../../redux/slices/missions"; + +const UploadMissionForm: React.FC = () => { + const dispatch = useAppDispatch(); + const { status, error } = useAppSelector(state => state.missions); + + // Локальные состояния формы + const [name, setName] = useState(""); + const [difficulty, setDifficulty] = useState(1); + const [tags, setTags] = useState([]); + const [tagsValue, setTagsValue] = useState(""); + const [file, setFile] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return alert("Выберите файл миссии!"); + + try { + dispatch(uploadMission({ file, name, difficulty, tags })); + + alert("Миссия успешно загружена!"); + setName(""); + setDifficulty(1); + setTags([]); + setFile(null); + } catch (err) { + console.error(err); + alert("Ошибка при загрузке миссии: " + err); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFile(e.target.files[0]); + } + }; + + const handleTagsChange = (e: React.ChangeEvent) => { + setTagsValue(e.target.value); + const value = e.target.value; + const tagsArray = value.split(",").map(tag => tag.trim()).filter(tag => tag); + setTags(tagsArray); + }; + + return ( +
+
+ + setName(e.target.value)} + className="w-full border px-2 py-1" + required + /> +
+ +
+ + setDifficulty(Number(e.target.value))} + className="w-full border px-2 py-1" + required + /> +
+ +
+ + +
+ +
+ + +
+ + + + {status === "failed" && error &&

{error}

} +
+ ); +}; + +export default UploadMissionForm; diff --git a/src/views/problem/codeeditor/CodeEditor.tsx b/src/views/mission/codeeditor/CodeEditor.tsx similarity index 87% rename from src/views/problem/codeeditor/CodeEditor.tsx rename to src/views/mission/codeeditor/CodeEditor.tsx index 94974cd..a9133d9 100644 --- a/src/views/problem/codeeditor/CodeEditor.tsx +++ b/src/views/mission/codeeditor/CodeEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Editor from "@monaco-editor/react"; import { upload } from "../../../assets/icons/input"; import { cn } from "../../../lib/cn"; @@ -14,15 +14,20 @@ const languageMap: Record = { csharp: "csharp" }; -const CodeEditor: React.FC = () => { - const [language, setLanguage] = useState("cpp"); +export interface CodeEditorProps { + onChange: (value: string) => void; + onChangeLanguage: (value: string) => void; +} + +const CodeEditor: React.FC = ({onChange, onChangeLanguage}) => { + 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" }, @@ -30,6 +35,13 @@ const CodeEditor: React.FC = () => { { value: "csharp", text: "C#" }, ]; + useEffect(() => { + onChange(code); + }, [code]) + useEffect(() => { + onChangeLanguage(language); + }, [language]) + const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -76,7 +88,7 @@ const CodeEditor: React.FC = () => { {/* Панель выбора языка и загрузки файла */}
- { setLanguage(v) }} /> + { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/>