From 3cd8e142885412ec1c346a82f841ffb9bf5a2f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:59:45 +0300 Subject: [PATCH] upload mission modal --- src/App.tsx | 39 ++- src/components/input/Input.tsx | 2 +- src/redux/slices/missions.ts | 305 +++++++++++++++--------- src/views/home/groups/ModalCreate.tsx | 12 +- src/views/home/missions/Missions.tsx | 11 +- src/views/home/missions/ModalCreate.tsx | 160 +++++++++++++ src/views/mission/UploadMissionForm.tsx | 101 -------- 7 files changed, 380 insertions(+), 250 deletions(-) create mode 100644 src/views/home/missions/ModalCreate.tsx delete mode 100644 src/views/mission/UploadMissionForm.tsx diff --git a/src/App.tsx b/src/App.tsx index d9a3403..7bf8098 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,30 +1,29 @@ -import { Route, Routes } from "react-router-dom"; +import { Route, Routes } from 'react-router-dom'; // import { PrimaryButton } from "./components/button/PrimaryButton"; // import { SecondaryButton } from "./components/button/SecondaryButton"; // import { Checkbox } from "./components/checkbox/Checkbox"; // import { Input } from "./components/input/Input"; // import { Switch } from "./components/switch/Switch"; -import Home from "./pages/Home"; -import Mission from "./pages/Mission"; -import UploadMissionForm from "./views/mission/UploadMissionForm"; -import MarkdownEditor from "./views/articleeditor/Editor"; -import ArticleEditor from "./pages/ArticleEditor"; +import Home from './pages/Home'; +import Mission from './pages/Mission'; +import ArticleEditor from './pages/ArticleEditor'; function App() { - return ( -
-
- - } /> - } /> - } /> - }/> - {console.log(value)}}/>} /> - - -
-
- ); + return ( +
+
+ + } /> + } /> + } + /> + } /> + +
+
+ ); } export default App; diff --git a/src/components/input/Input.tsx b/src/components/input/Input.tsx index 42fa814..6835065 100644 --- a/src/components/input/Input.tsx +++ b/src/components/input/Input.tsx @@ -4,7 +4,7 @@ import { eyeClosed, eyeOpen } from "../../assets/icons/input"; interface inputProps { name?: string; - type: "text" | "email" | "password" | "first_name"; + type: "text" | "email" | "password" | "first_name" | "number"; error?: string; disabled?: boolean; required?: boolean; diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index ee1bcb8..93f24eb 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -1,146 +1,215 @@ -import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; -import axios from "../../axios"; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; + +// ─── Типы ──────────────────────────────────────────── + +type Status = 'idle' | 'loading' | 'successful' | 'failed'; -// Типы данных interface Statement { - id: number; - language: string; - statementTexts: Record; - mediaFiles?: { id: number; fileName: string; mediaUrl: string }[]; + 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[]; +export 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; + missions: Mission[]; + currentMission: Mission | null; + hasNextPage: boolean; + statuses: { + fetchList: Status; + fetchById: Status; + upload: Status; + }; + error: string | null; } -// Инициализация состояния +// ─── Инициализация состояния ────────────────────────── + const initialState: MissionsState = { - missions: [], - currentMission: null, - hasNextPage: false, - status: "idle", - error: null, + missions: [], + currentMission: null, + hasNextPage: false, + statuses: { + fetchList: 'idle', + fetchById: 'idle', + upload: 'idle', + }, + error: null, }; -// AsyncThunk: Получение списка миссий +// ─── Async Thunks ───────────────────────────────────── + +// GET /missions 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"); - } - } + 'missions/fetchMissions', + async ( + { + page = 0, + pageSize = 10, + tags = [], + }: { page?: number; pageSize?: number; tags?: string[] }, + { rejectWithValue }, + ) => { + try { + const params: any = { page, pageSize }; + if (tags.length) params.tags = tags; + const response = await axios.get('/missions', { params }); + return response.data; // { missions, hasNextPage } + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при получении миссий', + ); + } + }, ); -// AsyncThunk: Получение миссии по id +// GET /missions/{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"); - } - } + '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 || 'Ошибка при получении миссии', + ); + } + }, ); -// AsyncThunk: Загрузка миссии +// POST /missions/upload 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)); + '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"); - } - } + 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 || 'Ошибка при загрузке миссии', + ); + } + }, ); -// Slice +// ─── 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; - }); + name: 'missions', + initialState, + reducers: { + clearCurrentMission: (state) => { + state.currentMission = null; + }, + setMissionsStatus: ( + state, + action: PayloadAction<{ + key: keyof MissionsState['statuses']; + status: Status; + }>, + ) => { + const { key, status } = action.payload; + state.statuses[key] = status; + }, + }, + extraReducers: (builder) => { + // ─── FETCH MISSIONS ─── + builder.addCase(fetchMissions.pending, (state) => { + state.statuses.fetchList = 'loading'; + state.error = null; + }); + builder.addCase( + fetchMissions.fulfilled, + ( + state, + action: PayloadAction<{ + missions: Mission[]; + hasNextPage: boolean; + }>, + ) => { + state.statuses.fetchList = 'successful'; + state.missions = action.payload.missions; + state.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase( + fetchMissions.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchList = '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; - }); + // ─── FETCH MISSION BY ID ─── + builder.addCase(fetchMissionById.pending, (state) => { + state.statuses.fetchById = 'loading'; + state.error = null; + }); + builder.addCase( + fetchMissionById.fulfilled, + (state, action: PayloadAction) => { + state.statuses.fetchById = 'successful'; + state.currentMission = action.payload; + }, + ); + builder.addCase( + fetchMissionById.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchById = '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; - }); - }, + // ─── UPLOAD MISSION ─── + builder.addCase(uploadMission.pending, (state) => { + state.statuses.upload = 'loading'; + state.error = null; + }); + builder.addCase( + uploadMission.fulfilled, + (state, action: PayloadAction) => { + state.statuses.upload = 'successful'; + state.missions.unshift(action.payload); + }, + ); + builder.addCase( + uploadMission.rejected, + (state, action: PayloadAction) => { + state.statuses.upload = 'failed'; + state.error = action.payload; + }, + ); + }, }); +export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions; export const missionsReducer = missionsSlice.reducer; diff --git a/src/views/home/groups/ModalCreate.tsx b/src/views/home/groups/ModalCreate.tsx index c77c82d..bfaa28a 100644 --- a/src/views/home/groups/ModalCreate.tsx +++ b/src/views/home/groups/ModalCreate.tsx @@ -18,7 +18,7 @@ const ModalCreate: FC = ({ active, setActive }) => { const dispatch = useAppDispatch(); useEffect(() => { - if (status == "successful"){ + if (status == "successful") { setActive(false); } }, [status]); @@ -27,12 +27,12 @@ const ModalCreate: FC = ({ active, setActive }) => {
Создать группу
- { setName(v)}} placeholder="login" /> - { setDescription(v)}} placeholder="login" /> + { setName(v) }} placeholder="login" /> + { setDescription(v) }} placeholder="login" /> -
- {dispatch(createGroup({name, description}))}} text="Создать" disabled={status=="loading"}/> - {setActive(false);}} text="Отмена" /> +
+ { dispatch(createGroup({ name, description })) }} text="Создать" disabled={status == "loading"} /> + { setActive(false); }} text="Отмена" />
diff --git a/src/views/home/missions/Missions.tsx b/src/views/home/missions/Missions.tsx index c62b0db..187fbc4 100644 --- a/src/views/home/missions/Missions.tsx +++ b/src/views/home/missions/Missions.tsx @@ -1,10 +1,11 @@ import MissionItem from "./MissionItem"; import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { setMenuActivePage } from "../../../redux/slices/store"; import { useNavigate } from "react-router-dom"; import { fetchMissions } from "../../../redux/slices/missions"; +import ModalCreate from "./ModalCreate"; export interface Mission { @@ -22,7 +23,7 @@ export interface Mission { const Missions = () => { const dispatch = useAppDispatch(); - const naivgate = useNavigate(); + const [modalActive, setModalActive] = useState(false); const missions = useAppSelector((state) => state.missions.missions); @@ -41,8 +42,8 @@ const Missions = () => { Задачи
{naivgate("/upload")}} - text="Создать задачу" + onClick={() => {setModalActive(true)}} + text="Добавить задачу" className="absolute right-0" /> @@ -75,6 +76,8 @@ const Missions = () => { pages + + ); }; diff --git a/src/views/home/missions/ModalCreate.tsx b/src/views/home/missions/ModalCreate.tsx new file mode 100644 index 0000000..22b50d1 --- /dev/null +++ b/src/views/home/missions/ModalCreate.tsx @@ -0,0 +1,160 @@ +import { FC, useEffect, useState } from 'react'; +import { Modal } from '../../../components/modal/Modal'; +import { PrimaryButton } from '../../../components/button/PrimaryButton'; +import { SecondaryButton } from '../../../components/button/SecondaryButton'; +import { Input } from '../../../components/input/Input'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { + setMissionsStatus, + uploadMission, +} from '../../../redux/slices/missions'; + +interface ModalCreateProps { + active: boolean; + setActive: (value: boolean) => void; +} + +const ModalCreate: FC = ({ active, setActive }) => { + const [name, setName] = useState(''); + const [difficulty, setDifficulty] = useState(1); + const [file, setFile] = useState(null); + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState([]); + + const status = useAppSelector((state) => state.missions.statuses.upload); + const dispatch = useAppDispatch(); + + const addTag = () => { + const newTag = tagInput.trim(); + if (newTag && !tags.includes(newTag)) { + setTags([...tags, newTag]); + setTagInput(''); + } + }; + + const removeTag = (tagToRemove: string) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFile(e.target.files[0]); + } + }; + + const handleSubmit = async () => { + if (!file) return alert('Выберите файл миссии!'); + dispatch(uploadMission({ file, name, difficulty, tags })); + }; + + useEffect(() => { + if (status === 'successful') { + alert('Миссия успешно загружена!'); + setName(''); + setDifficulty(1); + setTags([]); + setFile(null); + dispatch(setMissionsStatus({ key: 'upload', status: 'idle' })); + setActive(false); + } + }, [status]); + + return ( + +
+
Добавить задачу
+ + + + setDifficulty(Number(v))} + placeholder="1" + /> + +
+ + +
+ + {/* Теги */} +
+
+ setTagInput(v)} + defaultState={tagInput} + placeholder="arrays" + onKeyDown={(e) => { + if (e.key === 'Enter') addTag(); + }} + /> + +
+
+ {tags.map((tag) => ( +
+ {tag} + +
+ ))} +
+
+ +
+ + setActive(false)} + text="Отмена" + /> +
+
+
+ ); +}; + +export default ModalCreate; diff --git a/src/views/mission/UploadMissionForm.tsx b/src/views/mission/UploadMissionForm.tsx deleted file mode 100644 index f5ea22f..0000000 --- a/src/views/mission/UploadMissionForm.tsx +++ /dev/null @@ -1,101 +0,0 @@ -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;