upload mission modal

This commit is contained in:
Виталий Лавшонок
2025-11-04 14:59:45 +03:00
parent 2e3a8779fc
commit 3cd8e14288
7 changed files with 380 additions and 250 deletions

View File

@@ -1,14 +1,12 @@
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from 'react-router-dom';
// import { PrimaryButton } from "./components/button/PrimaryButton"; // import { PrimaryButton } from "./components/button/PrimaryButton";
// import { SecondaryButton } from "./components/button/SecondaryButton"; // import { SecondaryButton } from "./components/button/SecondaryButton";
// import { Checkbox } from "./components/checkbox/Checkbox"; // import { Checkbox } from "./components/checkbox/Checkbox";
// import { Input } from "./components/input/Input"; // import { Input } from "./components/input/Input";
// import { Switch } from "./components/switch/Switch"; // import { Switch } from "./components/switch/Switch";
import Home from "./pages/Home"; import Home from './pages/Home';
import Mission from "./pages/Mission"; import Mission from './pages/Mission';
import UploadMissionForm from "./views/mission/UploadMissionForm"; import ArticleEditor from './pages/ArticleEditor';
import MarkdownEditor from "./views/articleeditor/Editor";
import ArticleEditor from "./pages/ArticleEditor";
function App() { function App() {
return ( return (
@@ -17,11 +15,12 @@ function App() {
<Routes> <Routes>
<Route path="/home/*" element={<Home />} /> <Route path="/home/*" element={<Home />} />
<Route path="/mission/:missionId" element={<Mission />} /> <Route path="/mission/:missionId" element={<Mission />} />
<Route path="/article/create/*" element={<ArticleEditor />} /> <Route
<Route path="/upload" element={<UploadMissionForm/>}/> path="/article/create/*"
<Route path="*" element={<MarkdownEditor onChange={(value: string) => {console.log(value)}}/>} /> element={<ArticleEditor />}
/>
<Route path="*" element={<Home />} />
</Routes> </Routes>
</div> </div>
</div> </div>
); );

View File

@@ -4,7 +4,7 @@ import { eyeClosed, eyeOpen } from "../../assets/icons/input";
interface inputProps { interface inputProps {
name?: string; name?: string;
type: "text" | "email" | "password" | "first_name"; type: "text" | "email" | "password" | "first_name" | "number";
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;

View File

@@ -1,7 +1,10 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from "../../axios"; import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
type Status = 'idle' | 'loading' | 'successful' | 'failed';
// Типы данных
interface Statement { interface Statement {
id: number; id: number;
language: string; language: string;
@@ -9,7 +12,7 @@ interface Statement {
mediaFiles?: { id: number; fileName: string; mediaUrl: string }[]; mediaFiles?: { id: number; fileName: string; mediaUrl: string }[];
} }
interface Mission { export interface Mission {
id: number; id: number;
authorId: number; authorId: number;
name: string; name: string;
@@ -24,123 +27,189 @@ interface MissionsState {
missions: Mission[]; missions: Mission[];
currentMission: Mission | null; currentMission: Mission | null;
hasNextPage: boolean; hasNextPage: boolean;
status: "idle" | "loading" | "successful" | "failed"; statuses: {
fetchList: Status;
fetchById: Status;
upload: Status;
};
error: string | null; error: string | null;
} }
// Инициализация состояния // ─── Инициализация состояния ──────────────────────────
const initialState: MissionsState = { const initialState: MissionsState = {
missions: [], missions: [],
currentMission: null, currentMission: null,
hasNextPage: false, hasNextPage: false,
status: "idle", statuses: {
fetchList: 'idle',
fetchById: 'idle',
upload: 'idle',
},
error: null, error: null,
}; };
// AsyncThunk: Получение списка миссий // ─── Async Thunks ─────────────────────────────────────
// GET /missions
export const fetchMissions = createAsyncThunk( export const fetchMissions = createAsyncThunk(
"missions/fetchMissions", 'missions/fetchMissions',
async ( async (
{ page = 0, pageSize = 10, tags = [] }: { page?: number; pageSize?: number; tags?: string[] }, {
{ rejectWithValue } page = 0,
pageSize = 10,
tags = [],
}: { page?: number; pageSize?: number; tags?: string[] },
{ rejectWithValue },
) => { ) => {
try { try {
const params: any = { page, pageSize }; const params: any = { page, pageSize };
if (tags) params.tags = tags; if (tags.length) params.tags = tags;
const response = await axios.get("/missions", { params }); const response = await axios.get('/missions', { params });
return response.data; // { hasNextPage, missions } return response.data; // { missions, hasNextPage }
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch missions"); return rejectWithValue(
} err.response?.data?.message || 'Ошибка при получении миссий',
);
} }
},
); );
// AsyncThunk: Получение миссии по id // GET /missions/{id}
export const fetchMissionById = createAsyncThunk( export const fetchMissionById = createAsyncThunk(
"missions/fetchMissionById", 'missions/fetchMissionById',
async (id: number, { rejectWithValue }) => { async (id: number, { rejectWithValue }) => {
try { try {
const response = await axios.get(`/missions/${id}`); const response = await axios.get(`/missions/${id}`);
return response.data; // Mission return response.data; // Mission
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission"); return rejectWithValue(
} err.response?.data?.message || 'Ошибка при получении миссии',
);
} }
},
); );
// AsyncThunk: Загрузка миссии // POST /missions/upload
export const uploadMission = createAsyncThunk( export const uploadMission = createAsyncThunk(
"missions/uploadMission", 'missions/uploadMission',
async ( async (
{ file, name, difficulty, tags }: { file: File; name: string; difficulty: number; tags: string[] }, {
{ rejectWithValue } file,
name,
difficulty,
tags,
}: { file: File; name: string; difficulty: number; tags: string[] },
{ rejectWithValue },
) => { ) => {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("MissionFile", file); formData.append('MissionFile', file);
formData.append("Name", name); formData.append('Name', name);
formData.append("Difficulty", difficulty.toString()); formData.append('Difficulty', difficulty.toString());
tags.forEach(tag => formData.append("Tags", tag)); tags.forEach((tag) => formData.append('Tags', tag));
const response = await axios.post("/missions/upload", formData, { const response = await axios.post('/missions/upload', formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
return response.data; // Mission return response.data; // Mission
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to upload mission"); return rejectWithValue(
} err.response?.data?.message || 'Ошибка при загрузке миссии',
);
} }
},
); );
// Slice // ─── Slice ────────────────────────────────────────────
const missionsSlice = createSlice({ const missionsSlice = createSlice({
name: "missions", name: 'missions',
initialState, initialState,
reducers: {}, 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) => { extraReducers: (builder) => {
// fetchMissions // ─── FETCH MISSIONS ───
builder.addCase(fetchMissions.pending, (state) => { builder.addCase(fetchMissions.pending, (state) => {
state.status = "loading"; state.statuses.fetchList = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase(fetchMissions.fulfilled, (state, action: PayloadAction<{ missions: Mission[]; hasNextPage: boolean }>) => { builder.addCase(
state.status = "successful"; fetchMissions.fulfilled,
(
state,
action: PayloadAction<{
missions: Mission[];
hasNextPage: boolean;
}>,
) => {
state.statuses.fetchList = 'successful';
state.missions = action.payload.missions; state.missions = action.payload.missions;
state.hasNextPage = action.payload.hasNextPage; state.hasNextPage = action.payload.hasNextPage;
}); },
builder.addCase(fetchMissions.rejected, (state, action: PayloadAction<any>) => { );
state.status = "failed"; builder.addCase(
fetchMissions.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchList = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
// fetchMissionById // ─── FETCH MISSION BY ID ───
builder.addCase(fetchMissionById.pending, (state) => { builder.addCase(fetchMissionById.pending, (state) => {
state.status = "loading"; state.statuses.fetchById = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase(fetchMissionById.fulfilled, (state, action: PayloadAction<Mission>) => { builder.addCase(
state.status = "successful"; fetchMissionById.fulfilled,
(state, action: PayloadAction<Mission>) => {
state.statuses.fetchById = 'successful';
state.currentMission = action.payload; state.currentMission = action.payload;
}); },
builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction<any>) => { );
state.status = "failed"; builder.addCase(
fetchMissionById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
// uploadMission // ─── UPLOAD MISSION ───
builder.addCase(uploadMission.pending, (state) => { builder.addCase(uploadMission.pending, (state) => {
state.status = "loading"; state.statuses.upload = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase(uploadMission.fulfilled, (state, action: PayloadAction<Mission>) => { builder.addCase(
state.status = "successful"; uploadMission.fulfilled,
state.missions.unshift(action.payload); // Добавляем новую миссию в начало списка (state, action: PayloadAction<Mission>) => {
}); state.statuses.upload = 'successful';
builder.addCase(uploadMission.rejected, (state, action: PayloadAction<any>) => { state.missions.unshift(action.payload);
state.status = "failed"; },
);
builder.addCase(
uploadMission.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.upload = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
}, },
}); });
export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions;
export const missionsReducer = missionsSlice.reducer; export const missionsReducer = missionsSlice.reducer;

View File

@@ -18,7 +18,7 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
if (status == "successful"){ if (status == "successful") {
setActive(false); setActive(false);
} }
}, [status]); }, [status]);
@@ -27,12 +27,12 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
<Modal className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" onOpenChange={setActive} open={active} backdrop="blur" > <Modal className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" onOpenChange={setActive} open={active} backdrop="blur" >
<div className="w-[500px]"> <div className="w-[500px]">
<div className="font-bold text-[30px]">Создать группу</div> <div className="font-bold text-[30px]">Создать группу</div>
<Input name="name" autocomplete="name" className="mt-[10px]" type="text" label="Название" onChange={(v) => { setName(v)}} placeholder="login" /> <Input name="name" autocomplete="name" className="mt-[10px]" type="text" label="Название" onChange={(v) => { setName(v) }} placeholder="login" />
<Input name="description" autocomplete="description" className="mt-[10px]" type="text" label="Описание" onChange={(v) => { setDescription(v)}} placeholder="login" /> <Input name="description" autocomplete="description" className="mt-[10px]" type="text" label="Описание" onChange={(v) => { setDescription(v) }} placeholder="login" />
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]"> <div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton onClick={() => {dispatch(createGroup({name, description}))}} text="Создать" disabled={status=="loading"}/> <PrimaryButton onClick={() => { dispatch(createGroup({ name, description })) }} text="Создать" disabled={status == "loading"} />
<SecondaryButton onClick={() => {setActive(false);}} text="Отмена" /> <SecondaryButton onClick={() => { setActive(false); }} text="Отмена" />
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -1,10 +1,11 @@
import MissionItem from "./MissionItem"; import MissionItem from "./MissionItem";
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from "../../../redux/slices/store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { fetchMissions } from "../../../redux/slices/missions"; import { fetchMissions } from "../../../redux/slices/missions";
import ModalCreate from "./ModalCreate";
export interface Mission { export interface Mission {
@@ -22,7 +23,7 @@ export interface Mission {
const Missions = () => { const Missions = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const naivgate = useNavigate(); const [modalActive, setModalActive] = useState<boolean>(false);
const missions = useAppSelector((state) => state.missions.missions); const missions = useAppSelector((state) => state.missions.missions);
@@ -41,8 +42,8 @@ const Missions = () => {
Задачи Задачи
</div> </div>
<SecondaryButton <SecondaryButton
onClick={() => {naivgate("/upload")}} onClick={() => {setModalActive(true)}}
text="Создать задачу" text="Добавить задачу"
className="absolute right-0" className="absolute right-0"
/> />
</div> </div>
@@ -75,6 +76,8 @@ const Missions = () => {
pages pages
</div> </div>
</div> </div>
<ModalCreate setActive={setModalActive} active={modalActive} />
</div> </div>
); );
}; };

View File

@@ -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<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>('');
const [difficulty, setDifficulty] = useState<number>(1);
const [file, setFile] = useState<File | null>(null);
const [tagInput, setTagInput] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
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<HTMLInputElement>) => {
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 (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]">
<div className="font-bold text-[30px]">Добавить задачу</div>
<Input
name="name"
autocomplete="name"
className="mt-[10px]"
type="text"
label="Название"
defaultState={name}
onChange={setName}
placeholder="В яблочко"
/>
<Input
name="difficulty"
autocomplete="difficulty"
className="mt-[10px]"
type="number"
label="Сложность"
defaultState={'' + difficulty}
onChange={(v) => setDifficulty(Number(v))}
placeholder="1"
/>
<div className="mt-4">
<label className="block mb-2">Файл задачи</label>
<input
type="file"
onChange={handleFileChange}
accept=".zip"
required
/>
</div>
{/* Теги */}
<div className="mb-[50px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
name="articleTag"
autocomplete="articleTag"
className="mt-[20px] max-w-[600px]"
type="text"
label="Теги"
onChange={(v) => setTagInput(v)}
defaultState={tagInput}
placeholder="arrays"
onKeyDown={(e) => {
if (e.key === 'Enter') addTag();
}}
/>
<PrimaryButton
onClick={addTag}
text="Добавить"
className="h-[40px] w-[140px]"
/>
</div>
<div className="flex flex-wrap gap-[10px] mt-2">
{tags.map((tag) => (
<div
key={tag}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
>
<span>{tag}</span>
<button
onClick={() => removeTag(tag)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div>
))}
</div>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleSubmit}
text={status === 'loading' ? 'Загрузка...' : 'Создать'}
disabled={status === 'loading'}
/>
<SecondaryButton
onClick={() => setActive(false)}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalCreate;

View File

@@ -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<string[]>([]);
const [tagsValue, setTagsValue] = useState<string>("");
const [file, setFile] = useState<File | null>(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<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
}
};
const handleTagsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTagsValue(e.target.value);
const value = e.target.value;
const tagsArray = value.split(",").map(tag => tag.trim()).filter(tag => tag);
setTags(tagsArray);
};
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-4 border rounded">
<div className="mb-4">
<label className="block mb-1">Название миссии</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full border px-2 py-1"
required
/>
</div>
<div className="mb-4">
<label className="block mb-1">Сложность</label>
<input
type="number"
value={difficulty}
min={1}
max={5}
onChange={e => setDifficulty(Number(e.target.value))}
className="w-full border px-2 py-1"
required
/>
</div>
<div className="mb-4">
<label className="block mb-1">Теги (через запятую)</label>
<input
type="text"
value={tagsValue}
onChange={handleTagsChange}
className="w-full border px-2 py-1"
/>
</div>
<div className="mb-4">
<label className="block mb-1">Файл миссии</label>
<input type="file" onChange={handleFileChange} accept=".zip" required />
</div>
<button
type="submit"
disabled={status === "loading"}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{status === "loading" ? "Загрузка..." : "Загрузить миссию"}
</button>
{status === "failed" && error && <p className="text-red-500 mt-2">{error}</p>}
</form>
);
};
export default UploadMissionForm;