Merge pull request 'dev' (#1) from dev into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 33s
Reviewed-on: #1
102
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 (
|
||||
<div className="flex w-full h-full ">
|
||||
<Routes>
|
||||
<Route path="/home/*" element={<Home/>}/>
|
||||
<Route path="/editor" element={<div className="box-border p-[50px] w-full h-[800px] relative bg-red-8001"><CodeEditor/></div>}/>
|
||||
<Route path="/statement" element={<div className="box-border p-[50px] w-full h-[800px] relative bg-red-8001"><Statement/></div>}/>
|
||||
<Route path="*" element={<Home/>}/>
|
||||
</Routes>
|
||||
<div className="w-full h-full bg-liquid-background flex justify-center">
|
||||
<div className="relative w-full max-w-[1600px] h-full ">
|
||||
<Routes>
|
||||
<Route path="/home/*" element={<Home />} />
|
||||
<Route path="/mission/:missionId" element={<Mission />} />
|
||||
<Route path="/upload" element={<UploadMissionForm/>}/>
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
|
||||
{/* <Switch
|
||||
className=" fixed top-0 left-0 z-full"
|
||||
variant="theme"
|
||||
color="secondary"
|
||||
onChange={(state: boolean) => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
state ? "dark" : "light"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="">
|
||||
<Input
|
||||
id="first_name"
|
||||
label="Фамилия"
|
||||
variant="bordered"
|
||||
//placeholder="test"
|
||||
radius="sm"
|
||||
className="m-2"
|
||||
required
|
||||
onChange={(state: string) => {
|
||||
console.log(state);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
variant="flat"
|
||||
id="given_name"
|
||||
label="Имя"
|
||||
//placeholder="test"
|
||||
radius="sm"
|
||||
className="m-2"
|
||||
required
|
||||
onChange={(state: string) => {
|
||||
console.log(state);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
variant="faded"
|
||||
type="email"
|
||||
label="Почта"
|
||||
radius="sm"
|
||||
className="m-2"
|
||||
required
|
||||
onChange={(state: string) => {
|
||||
console.log(state);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
labelType="in-fixed"
|
||||
type="password"
|
||||
label="Пароль"
|
||||
radius="sm"
|
||||
className="m-2"
|
||||
required
|
||||
onChange={(state: string) => {
|
||||
console.log(state);
|
||||
}}
|
||||
/>
|
||||
<Checkbox onChange={() => { }} label="test" color="default" defaultState={true}/>
|
||||
<Checkbox onChange={() => { }} label="test" color="primary" defaultState={true}/>
|
||||
<Checkbox onChange={() => { }} label="test" color="secondary" defaultState={true}/>
|
||||
<Checkbox onChange={() => { }} label="test" color="success" defaultState={true}/>
|
||||
<Checkbox onChange={() => { }} label="test" color="warning" defaultState={true}/>
|
||||
<Checkbox onChange={() => { }} label="test" color="danger" defaultState={true}/>
|
||||
<Switch onChange={() => { }} color="default" defaultState={true}/>
|
||||
<Switch onChange={() => { }} color="primary" defaultState={true}/>
|
||||
<Switch onChange={() => { }} color="secondary" defaultState={true}/>
|
||||
<Switch onChange={() => { }} color="success" defaultState={true}/>
|
||||
<Switch onChange={() => { }} color="warning" defaultState={true}/>
|
||||
<Switch onChange={() => { }} color="danger" defaultState={true}/>
|
||||
|
||||
|
||||
<div className="grid grid-rows-3 grid-cols-2 grid-flow-col">
|
||||
|
||||
<PrimaryButton onClick={() => { }} text="Button" className="m-5" />
|
||||
<PrimaryButton onClick={() => { }} text="Button" className="m-5" />
|
||||
<PrimaryButton onClick={() => { }} text="Button" disabled className="m-5" />
|
||||
<SecondaryButton onClick={() => { }} text="Button" className="m-5" />
|
||||
<SecondaryButton onClick={() => { }} text="Button" className="m-5" />
|
||||
<SecondaryButton onClick={() => { }} text="Button" disabled className="m-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full"></div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/assets/icons/groups/book.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
3
src/assets/icons/groups/chevron-down.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 10L12.0008 14.58L17 10" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 222 B |
3
src/assets/icons/groups/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.7992 19.5516H19.7992M4.19922 19.5516L8.5652 18.6719C8.79698 18.6252 9.0098 18.5111 9.17694 18.3438L18.9506 8.5648C19.4192 8.09594 19.4189 7.33595 18.9499 6.86749L16.8795 4.79942C16.4107 4.33115 15.6511 4.33147 15.1827 4.80013L5.40798 14.5802C5.24117 14.7471 5.12727 14.9595 5.08052 15.1908L4.19922 19.5516Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 507 B |
3
src/assets/icons/groups/eye-closed.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.4004 19.5L5.40039 4.5M10.2004 10.4416C9.82697 10.8533 9.60039 11.394 9.60039 11.9863C9.60039 13.2761 10.6749 14.3217 12.0004 14.3217C12.6115 14.3217 13.1693 14.0994 13.593 13.7334M20.4392 14.3217C21.2654 13.0848 21.6004 12.0761 21.6004 12.0761C21.6004 12.0761 19.4158 5.1 12.0004 5.1C11.5841 5.1 11.1843 5.12199 10.8004 5.16349M17.4004 17.3494C16.023 18.2281 14.2497 18.8495 12.0004 18.8127C4.67731 18.693 2.40039 12.0761 2.40039 12.0761C2.40039 12.0761 3.45825 8.69808 6.60039 6.64332" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 662 B |
BIN
src/assets/icons/groups/eye-open.png
Normal file
|
After Width: | Height: | Size: 662 B |
8
src/assets/icons/groups/index.ts
Normal file
@@ -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}
|
||||
3
src/assets/icons/groups/user-profile-add.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.40039 21.5999C1.40033 22.1522 1.84799 22.6 2.40028 22.6C2.95256 22.6001 3.40033 22.1524 3.40039 21.6001L2.40039 21.6L1.40039 21.5999ZM2.4008 17.9996L3.4008 17.9997L2.4008 17.9996ZM12.6004 15.4C13.1527 15.4 13.6004 14.9523 13.6004 14.4C13.6004 13.8477 13.1527 13.4 12.6004 13.4V14.4V15.4ZM21.6004 16.6C22.1527 16.6 22.6004 16.1523 22.6004 15.6C22.6004 15.0477 22.1527 14.6 21.6004 14.6V15.6V16.6ZM16.2004 14.6C15.6481 14.6 15.2004 15.0477 15.2004 15.6C15.2004 16.1523 15.6481 16.6 16.2004 16.6V15.6V14.6ZM17.9004 18.2999C17.9004 18.8522 18.3481 19.2999 18.9004 19.2999C19.4527 19.2999 19.9004 18.8522 19.9004 18.2999H18.9004H17.9004ZM19.9004 12.8999C19.9004 12.3476 19.4527 11.8999 18.9004 11.8999C18.3481 11.8999 17.9004 12.3476 17.9004 12.8999H18.9004H19.9004ZM14.4004 6.00002H13.4004C13.4004 7.43596 12.2363 8.60002 10.8004 8.60002V9.60002V10.6C13.3409 10.6 15.4004 8.54053 15.4004 6.00002H14.4004ZM10.8004 9.60002V8.60002C9.36445 8.60002 8.20039 7.43596 8.20039 6.00002H7.20039H6.20039C6.20039 8.54053 8.25988 10.6 10.8004 10.6V9.60002ZM7.20039 6.00002H8.20039C8.20039 4.56408 9.36445 3.40002 10.8004 3.40002V2.40002V1.40002C8.25988 1.40002 6.20039 3.45951 6.20039 6.00002H7.20039ZM10.8004 2.40002V3.40002C12.2363 3.40002 13.4004 4.56408 13.4004 6.00002H14.4004H15.4004C15.4004 3.45951 13.3409 1.40002 10.8004 1.40002V2.40002ZM2.40039 21.6L3.40039 21.6001L3.4008 17.9997L2.4008 17.9996L1.4008 17.9995L1.40039 21.5999L2.40039 21.6ZM6.00079 14.4V13.4C3.46049 13.4 1.40108 15.4592 1.4008 17.9995L2.4008 17.9996L3.4008 17.9997C3.40096 16.5639 4.56497 15.4 6.00079 15.4V14.4ZM6.00079 14.4V15.4H12.6004V14.4V13.4H6.00079V14.4ZM21.6004 15.6V14.6H18.9004V15.6V16.6H21.6004V15.6ZM18.9004 15.6V14.6H16.2004V15.6V16.6H18.9004V15.6ZM18.9004 18.2999H19.9004V15.6H18.9004H17.9004V18.2999H18.9004ZM18.9004 15.6H19.9004V12.8999H18.9004H17.9004V15.6H18.9004Z" fill="#EDF6F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
3
src/assets/icons/header/arrow-left-sm.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.1667 16.375L7 12M7 12L11.1667 7.625M7 12H17" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
3
src/assets/icons/header/chevron-left.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 17L10 12L15 7" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 214 B |
3
src/assets/icons/header/chevron-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 7L15 12L10 17" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 214 B |
5
src/assets/icons/header/index.ts
Normal file
@@ -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}
|
||||
3
src/assets/icons/missions/icon-error.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.7101 8.28999C15.6171 8.19626 15.5065 8.12187 15.3847 8.0711C15.2628 8.02033 15.1321 7.99419 15.0001 7.99419C14.8681 7.99419 14.7374 8.02033 14.6155 8.0711C14.4936 8.12187 14.383 8.19626 14.2901 8.28999L12.0001 10.59L9.71008 8.28999C9.52178 8.10168 9.26638 7.9959 9.00008 7.9959C8.73378 7.9959 8.47838 8.10168 8.29008 8.28999C8.10178 8.47829 7.99599 8.73369 7.99599 8.99999C7.99599 9.26629 8.10178 9.52168 8.29008 9.70999L10.5901 12L8.29008 14.29C8.19635 14.383 8.12196 14.4936 8.07119 14.6154C8.02042 14.7373 7.99428 14.868 7.99428 15C7.99428 15.132 8.02042 15.2627 8.07119 15.3846C8.12196 15.5064 8.19635 15.617 8.29008 15.71C8.38304 15.8037 8.49364 15.8781 8.6155 15.9289C8.73736 15.9796 8.86807 16.0058 9.00008 16.0058C9.13209 16.0058 9.2628 15.9796 9.38466 15.9289C9.50652 15.8781 9.61712 15.8037 9.71008 15.71L12.0001 13.41L14.2901 15.71C14.383 15.8037 14.4936 15.8781 14.6155 15.9289C14.7374 15.9796 14.8681 16.0058 15.0001 16.0058C15.1321 16.0058 15.2628 15.9796 15.3847 15.9289C15.5065 15.8781 15.6171 15.8037 15.7101 15.71C15.8038 15.617 15.8782 15.5064 15.929 15.3846C15.9797 15.2627 16.0059 15.132 16.0059 15C16.0059 14.868 15.9797 14.7373 15.929 14.6154C15.8782 14.4936 15.8038 14.383 15.7101 14.29L13.4101 12L15.7101 9.70999C15.8038 9.61702 15.8782 9.50642 15.929 9.38456C15.9797 9.26271 16.0059 9.132 16.0059 8.99999C16.0059 8.86798 15.9797 8.73727 15.929 8.61541C15.8782 8.49355 15.8038 8.38295 15.7101 8.28999ZM19.0701 4.92999C18.1476 3.97489 17.0442 3.21306 15.8241 2.68897C14.6041 2.16488 13.2919 1.88902 11.9641 1.87748C10.6363 1.86595 9.3195 2.11896 8.09054 2.62177C6.86158 3.12458 5.74506 3.86711 4.80613 4.80604C3.8672 5.74497 3.12467 6.86148 2.62186 8.09045C2.11905 9.31941 1.86604 10.6362 1.87757 11.964C1.88911 13.2918 2.16498 14.604 2.68907 15.824C3.21316 17.0441 3.97498 18.1475 4.93008 19.07C5.85255 20.0251 6.95599 20.7869 8.17603 21.311C9.39607 21.8351 10.7083 22.111 12.0361 22.1225C13.3639 22.134 14.6807 21.881 15.9096 21.3782C17.1386 20.8754 18.2551 20.1329 19.194 19.1939C20.133 18.255 20.8755 17.1385 21.3783 15.9095C21.8811 14.6806 22.1341 13.3638 22.1226 12.036C22.111 10.7082 21.8352 9.39598 21.3111 8.17594C20.787 6.9559 20.0252 5.85246 19.0701 4.92999ZM17.6601 17.66C16.3521 18.9694 14.6306 19.7848 12.7889 19.9673C10.9471 20.1498 9.09908 19.688 7.5596 18.6608C6.02012 17.6335 4.88444 16.1042 4.34605 14.3335C3.80767 12.5627 3.89988 10.6601 4.60699 8.94977C5.3141 7.23941 6.59235 5.82714 8.22397 4.95355C9.85559 4.07996 11.7396 3.79912 13.5551 4.15886C15.3706 4.5186 17.0051 5.49668 18.1803 6.92645C19.3555 8.35622 19.9986 10.1492 20.0001 12C20.0036 13.0513 19.7987 14.0928 19.397 15.0644C18.9953 16.0359 18.405 16.9181 17.6601 17.66Z" fill="#F13E5F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
3
src/assets/icons/missions/icon-success.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.72 8.79L10.43 13.09L8.78 11.44C8.69036 11.3353 8.58004 11.2503 8.45597 11.1903C8.33191 11.1303 8.19678 11.0965 8.05906 11.0912C7.92134 11.0859 7.78401 11.1091 7.65568 11.1594C7.52736 11.2096 7.41081 11.2859 7.31335 11.3833C7.2159 11.4808 7.13964 11.5974 7.08937 11.7257C7.03909 11.854 7.01589 11.9913 7.02121 12.1291C7.02653 12.2668 7.06026 12.4019 7.12028 12.526C7.1803 12.65 7.26532 12.7604 7.37 12.85L9.72 15.21C9.81344 15.3027 9.92426 15.376 10.0461 15.4258C10.1679 15.4755 10.2984 15.5008 10.43 15.5C10.6923 15.4989 10.9437 15.3947 11.13 15.21L16.13 10.21C16.2237 10.117 16.2981 10.0064 16.3489 9.88458C16.3997 9.76272 16.4258 9.63201 16.4258 9.5C16.4258 9.36799 16.3997 9.23728 16.3489 9.11542C16.2981 8.99356 16.2237 8.88296 16.13 8.79C15.9426 8.60375 15.6892 8.49921 15.425 8.49921C15.1608 8.49921 14.9074 8.60375 14.72 8.79ZM12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="#10BE59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
4
src/assets/icons/missions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import IconSuccess from "./icon-success.svg"
|
||||
import IconError from "./icon-error.svg"
|
||||
|
||||
export {IconError, IconSuccess}
|
||||
14
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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-full w-full bg-liquid-background grid grid-cols-[250px,1fr] divide-x-[1px] divide-liquid-lighter">
|
||||
<div className="">
|
||||
<div className="w-full bg-liquid-background grid grid-cols-[250px,1fr,250px] divide-x-[1px] divide-liquid-lighter">
|
||||
<div className="min-h-screen">
|
||||
<Menu />
|
||||
</div>
|
||||
<div className="">
|
||||
@@ -27,10 +32,18 @@ const Home = () => {
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="account" element={<Login />} />
|
||||
<Route path="register" element={<Register />} />
|
||||
<Route path="*" element={name} />
|
||||
<Route path="missions/*" element={<Missions/>} />
|
||||
<Route path="articles/*" element={<Articles/>} />
|
||||
<Route path="groups/*" element={<Groups/>} />
|
||||
<Route path="contests/*" element={<Contests/>} />
|
||||
<Route path="*" element={<>{name}<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton></>} />
|
||||
</Routes>
|
||||
|
||||
</div>
|
||||
{
|
||||
<Routes>
|
||||
<Route path="articles/*" element={<div></div>} />
|
||||
</Routes>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
188
src/pages/Mission.tsx
Normal file
@@ -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 <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
const [code, setCode] = useState<string>("");
|
||||
const [language, setLanguage] = useState<string>("");
|
||||
|
||||
const pollingRef = useRef<number | null>(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 <div>Загрузка...</div>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 (
|
||||
|
||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||
<div className="">
|
||||
<Header missionId={missionIdNumber} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]">
|
||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||
<Statement
|
||||
{...statementData}
|
||||
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto min-h-0 overflow-hidden pb-[20px]">
|
||||
<div className=' grid grid-rows-[1fr,45px,230px] grid-flow-row h-full w-full gap-[20px] '>
|
||||
<div className='w-full relative '>
|
||||
<CodeEditor
|
||||
onChange={(value: string) => { setCode(value); }}
|
||||
onChangeLanguage={((value: string) => { setLanguage(value); })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton text='Отправить' onClick={async () => {
|
||||
await dispatch(submitMission({
|
||||
missionId: missionIdNumber,
|
||||
language: language,
|
||||
languageVersion: "latest",
|
||||
sourceCode: code,
|
||||
contestId: null,
|
||||
|
||||
})).unwrap();
|
||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className='h-full w-full '>
|
||||
<MissionSubmissions missionId={missionIdNumber} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mission;
|
||||
@@ -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<any>) => {
|
||||
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<any>) => {
|
||||
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<any>) => {
|
||||
@@ -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<any>) => {
|
||||
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}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
146
src/redux/slices/missions.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import axios from "../../axios";
|
||||
|
||||
// Типы данных
|
||||
interface Statement {
|
||||
id: number;
|
||||
language: string;
|
||||
statementTexts: Record<string, string>;
|
||||
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<any>) => {
|
||||
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<Mission>) => {
|
||||
state.status = "successful";
|
||||
state.currentMission = action.payload;
|
||||
});
|
||||
builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction<any>) => {
|
||||
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<Mission>) => {
|
||||
state.status = "successful";
|
||||
state.missions.unshift(action.payload); // Добавляем новую миссию в начало списка
|
||||
});
|
||||
builder.addCase(uploadMission.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const missionsReducer = missionsSlice.reducer;
|
||||
184
src/redux/slices/submit.ts
Normal file
@@ -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<number, MissionSubmit[]>; // ✅ добавлено
|
||||
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<number>) => {
|
||||
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<Submit>) => {
|
||||
state.status = "successful";
|
||||
state.submits.push(action.payload);
|
||||
});
|
||||
builder.addCase(submitMission.rejected, (state, action: PayloadAction<any>) => {
|
||||
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<Submit[]>) => {
|
||||
state.status = "successful";
|
||||
state.submits = action.payload;
|
||||
});
|
||||
builder.addCase(fetchMySubmits.rejected, (state, action: PayloadAction<any>) => {
|
||||
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<Submit>) => {
|
||||
state.status = "successful";
|
||||
state.currentSubmit = action.payload;
|
||||
});
|
||||
builder.addCase(fetchSubmitById.rejected, (state, action: PayloadAction<any>) => {
|
||||
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<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions;
|
||||
export const submitReducer = submitSlice.reducer;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
26
src/styles/latex-container.css
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
41
src/views/home/articles/ArticleItem.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cn } from "../../../lib/cn";
|
||||
|
||||
export interface ArticleItemProps {
|
||||
id: number;
|
||||
name: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const ArticleItem: React.FC<ArticleItemProps> = ({
|
||||
id, name, tags
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("w-full relative rounded-[10px] text-liquid-white mb-[20px]",
|
||||
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
||||
"gap-[20px] px-[20px] py-[10px] box-border ",
|
||||
"border-b-[1px] border-b-liquid-lighter",
|
||||
)}>
|
||||
<div className="h-[23px] flex ">
|
||||
|
||||
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
|
||||
#{id}
|
||||
</div>
|
||||
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
||||
{tags.map((v, i) =>
|
||||
<div key={i} className={cn(
|
||||
"rounded-full px-[16px] py-[8px] bg-liquid-lighter",
|
||||
v == "Sertificated" && "text-liquid-green")}>
|
||||
{v}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleItem;
|
||||
171
src/views/home/articles/Articles.tsx
Normal file
@@ -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 (
|
||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||
<div className="h-full box-border">
|
||||
|
||||
<div className="relative flex items-center mb-[20px]">
|
||||
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
||||
Статьи
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => { }}
|
||||
text="Создать статью"
|
||||
className="absolute right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
{articles.map((v, i) => (
|
||||
<ArticleItem key={i} {...v} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
pages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Articles;
|
||||
@@ -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<string>("");
|
||||
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
||||
|
||||
const { status, error, jwt } = useAppSelector((state) => state.auth);
|
||||
const { status, jwt } = useAppSelector((state) => state.auth);
|
||||
|
||||
|
||||
const [err, setErr] = useState<string>("");
|
||||
// const [err, setErr] = useState<string>("");
|
||||
|
||||
// После успешного логина
|
||||
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 = () => {
|
||||
</div>
|
||||
|
||||
|
||||
<Input name="login" autocomplete="login" className="mt-[10px]" type="text" label="Логин" onChange={(v) => {setUsername(v)}} placeholder="login"/>
|
||||
<Input name="password" autocomplete="password" className="mt-[10px]" type="password" label="Пароль" onChange={(v) => {setPassword(v)}} placeholder="abCD1234" />
|
||||
<Input name="login" autocomplete="login" className="mt-[10px]" type="text" label="Логин" onChange={(v) => { setUsername(v) }} placeholder="login" />
|
||||
<Input name="password" autocomplete="password" className="mt-[10px]" type="password" label="Пароль" onChange={(v) => { setPassword(v) }} placeholder="abCD1234" />
|
||||
|
||||
<div className="flex justify-end mt-[10px]">
|
||||
<Link
|
||||
@@ -79,7 +81,7 @@ const Login = () => {
|
||||
/>
|
||||
<SecondaryButton
|
||||
className="w-full"
|
||||
onClick={() => {}}
|
||||
onClick={() => { }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" />
|
||||
|
||||
@@ -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<string>("");
|
||||
const [submitClicked, setSubmitClicked] = useState<boolean>(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 = () => {
|
||||
|
||||
<div className=" flex items-center mt-[10px] h-[24px]">
|
||||
<Checkbox
|
||||
onChange={(value: boolean) => { console.log(value) }}
|
||||
onChange={(value: boolean) => { value; }}
|
||||
className="p-0 w-fit m-[2.75px]"
|
||||
size="md"
|
||||
color="secondary"
|
||||
|
||||
72
src/views/home/contests/ContestItem.tsx
Normal file
@@ -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<ContestItemProps> = ({
|
||||
id, name, authors, startAt, registerAt, duration, members, statusRegister, type
|
||||
}) => {
|
||||
const now = new Date();
|
||||
|
||||
const waitTime = new Date(startAt).getTime() - now.getTime();
|
||||
|
||||
return (
|
||||
<div className={cn("w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white",
|
||||
waitTime <= 0 ? "grid grid-cols-6" : "grid grid-cols-7",
|
||||
"items-center font-bold text-liquid-white",
|
||||
type == "first" ? " bg-liquid-lighter" : " bg-liquid-background"
|
||||
)}>
|
||||
<div className="text-left">
|
||||
{name}
|
||||
</div>
|
||||
<div className="text-center text-liquid-brightmain font-normal">
|
||||
{authors.map((v, i) => <p key={i}>{v}</p>)}
|
||||
</div>
|
||||
<div className="text-center text-nowrap">
|
||||
{formatDate(startAt)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{duration}
|
||||
</div>
|
||||
{
|
||||
waitTime > 0 &&
|
||||
<div className="text-center">
|
||||
{waitTime}
|
||||
</div>
|
||||
}
|
||||
<div className="text-center">
|
||||
{members}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{statusRegister}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestItem;
|
||||
131
src/views/home/contests/Contests.tsx
Normal file
@@ -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 (
|
||||
<div className=" h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
||||
<div className="h-full box-border">
|
||||
|
||||
<div className="relative flex items-center mb-[20px]">
|
||||
<div className={cn("h-[50px] text-[40px] font-bold text-liquid-white flex items-center")}>
|
||||
Контесты
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => { }}
|
||||
text="Создать группу"
|
||||
className="absolute right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<ContestsBlock className="mb-[20px]" title="Текущие" contests={contests.filter(contest => {
|
||||
const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000;
|
||||
return endTime >= now.getTime();
|
||||
})} />
|
||||
<ContestsBlock className="mb-[20px]" title="Прошедшие" contests={contests.filter(contest => {
|
||||
const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000;
|
||||
return endTime < now.getTime();
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contests;
|
||||
63
src/views/home/contests/ContestsBlock.tsx
Normal file
@@ -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<GroupsBlockProps> = ({ contests, title, className }) => {
|
||||
|
||||
|
||||
const [active, setActive] = useState<boolean>(title != "Скрытые");
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className={cn(" border-b-[1px] border-b-liquid-lighter rounded-[10px]",
|
||||
className
|
||||
)}>
|
||||
<div className={cn(" h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300",
|
||||
active && "border-b-liquid-lighter"
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active)
|
||||
}}>
|
||||
<span>{title}</span>
|
||||
<img src={ChevroneDown} className={cn("transition-all duration-300",
|
||||
active && "rotate-180"
|
||||
)} />
|
||||
</div>
|
||||
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300",
|
||||
active && "grid-rows-[1fr] opacity-100"
|
||||
)}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pb-[10px] pt-[20px]">
|
||||
{
|
||||
contests.map((v, i) => <ContestItem key={i} {...v} type={i % 2 ? "second" : "first"} />)
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsBlock;
|
||||
58
src/views/home/groups/GroupItem.tsx
Normal file
@@ -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<IconComponentProps> = ({
|
||||
src
|
||||
}) => {
|
||||
|
||||
return <img
|
||||
src={src}
|
||||
className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300"
|
||||
/>
|
||||
}
|
||||
|
||||
const GroupItem: React.FC<GroupItemProps> = ({
|
||||
id, name, visible, role
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("w-full h-[120px] box-border relative rounded-[10px] p-[10px] text-liquid-white bg-liquid-lighter",
|
||||
)}>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-[20px]">
|
||||
<img src={Book} className="bg-liquid-brightmain rounded-[10px]"/>
|
||||
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
||||
<div className="text-[18px] font-bold">
|
||||
{name}
|
||||
</div>
|
||||
<div className=" flex gap-[10px]">
|
||||
{
|
||||
(role == "menager" || role == "owner") && <IconComponent src={UserAdd}/>
|
||||
}
|
||||
{
|
||||
(role == "menager" || role == "owner") && <IconComponent src={Edit}/>
|
||||
}
|
||||
{
|
||||
visible == false && <IconComponent src={EyeOpen} />
|
||||
}
|
||||
{
|
||||
visible == true && <IconComponent src={EyeClosed} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupItem;
|
||||
71
src/views/home/groups/Groups.tsx
Normal file
@@ -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 (
|
||||
<div className=" h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
||||
<div className="h-full box-border">
|
||||
|
||||
<div className="relative flex items-center mb-[20px]">
|
||||
<div className={cn("h-[50px] text-[40px] font-bold text-liquid-white flex items-center")}>
|
||||
Группы
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => { }}
|
||||
text="Создать группу"
|
||||
className="absolute right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<GroupsBlock className="mb-[20px]" title="Управляемые" groups={groups.filter((v) => v.visible && (v.role == "owner" || v.role == "menager"))} />
|
||||
<GroupsBlock className="mb-[20px]" title="Текущие" groups={groups.filter((v) => v.visible && (v.role == "member" || v.role == "viewer"))} />
|
||||
<GroupsBlock className="mb-[20px]" title="Скрытые" groups={groups.filter((v) => v.visible == false)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Groups;
|
||||
59
src/views/home/groups/GroupsBlock.tsx
Normal file
@@ -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<GroupsBlockProps> = ({ groups, title, className }) => {
|
||||
|
||||
|
||||
const [active, setActive] = useState<boolean>(title != "Скрытые");
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className={cn(" border-b-[1px] border-b-liquid-lighter rounded-[10px]",
|
||||
className
|
||||
)}>
|
||||
<div className={cn(" h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300",
|
||||
active && " border-b-liquid-lighter"
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active)
|
||||
}}>
|
||||
<span>{title}</span>
|
||||
<img src={ChevroneDown} className={cn("transition-all duration-300",
|
||||
active && "rotate-180"
|
||||
)}/>
|
||||
</div>
|
||||
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300",
|
||||
active && "grid-rows-[1fr] opacity-100"
|
||||
)}>
|
||||
<div className="overflow-hidden">
|
||||
|
||||
<div className="grid grid-cols-3 gap-[20px] pt-[20px] pb-[20px] box-border">
|
||||
{
|
||||
groups.map((v, i) => <GroupItem key={i} {...v} />)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsBlock;
|
||||
@@ -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 (
|
||||
<div className="h-full w-full items-center box-border p-[20px] pt-[35px]">
|
||||
<img src={Logo} className="w-full" />
|
||||
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
|
||||
<img src={Logo} className="w-[173px]" />
|
||||
<div className="">
|
||||
{menuItems.map((v, i) => (
|
||||
<MenuItem key={i} icon={v.icon} text={v.text} href={v.href} active={v.page == activePage} page={v.page}/>
|
||||
|
||||
74
src/views/home/missions/MissionItem.tsx
Normal file
@@ -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<MissionItemProps> = ({
|
||||
id, name, difficulty, timeLimit, memoryLimit, type, status
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={cn("h-[44px] w-full relative rounded-[10px] text-liquid-white",
|
||||
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
||||
"grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center",
|
||||
status == "error" && "border-l-[11px] border-l-liquid-red pl-[9px]",
|
||||
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]",
|
||||
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300",
|
||||
)}
|
||||
onClick={() => {navigate(`/mission/${id}`)}}
|
||||
>
|
||||
<div className="text-[18px] font-bold">
|
||||
#{id}
|
||||
</div>
|
||||
<div className="text-[18px] font-bold">
|
||||
{name}
|
||||
</div>
|
||||
<div className="text-[12px] text-right">
|
||||
стандартный ввод/вывод {formatMilliseconds(timeLimit)}, {formatBytesToMB(memoryLimit)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-center text-[18px]",
|
||||
difficulty == "Hard" && "text-liquid-red",
|
||||
difficulty == "Medium" && "text-liquid-orange",
|
||||
difficulty == "Easy" && "text-liquid-green",
|
||||
)}>
|
||||
{difficulty}
|
||||
</div>
|
||||
<div className="h-[24px] w-[24px]">
|
||||
{
|
||||
status == "error" && <img src={IconError}/>
|
||||
}
|
||||
{
|
||||
status == "success" && <img src={IconSuccess}/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissionItem;
|
||||
82
src/views/home/missions/Missions.tsx
Normal file
@@ -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 (
|
||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||
<div className="h-full box-border">
|
||||
|
||||
<div className="relative flex items-center mb-[20px]">
|
||||
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
||||
Задачи
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => {naivgate("/upload")}}
|
||||
text="Создать задачу"
|
||||
className="absolute right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
{missions.map((v, i) => (
|
||||
<MissionItem
|
||||
key={i}
|
||||
id={v.id}
|
||||
authorId={v.authorId}
|
||||
name={v.name}
|
||||
difficulty={"Easy"}
|
||||
tags={v.tags}
|
||||
timeLimit={1000}
|
||||
memoryLimit={256 * 1024 * 1024}
|
||||
createdAt={v.createdAt}
|
||||
updatedAt={v.updatedAt}
|
||||
type={i % 2 == 0 ? "first" : "second"}
|
||||
status={i == 0 || i == 3 || i == 7 ? "success" : (i == 2 || i == 4 || i == 9 ? "error" : "empty")}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
pages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Missions;
|
||||
101
src/views/mission/UploadMissionForm.tsx
Normal file
@@ -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<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;
|
||||
@@ -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<string, string> = {
|
||||
csharp: "csharp"
|
||||
};
|
||||
|
||||
const CodeEditor: React.FC = () => {
|
||||
const [language, setLanguage] = useState<string>("cpp");
|
||||
export interface CodeEditorProps {
|
||||
onChange: (value: string) => void;
|
||||
onChangeLanguage: (value: string) => void;
|
||||
}
|
||||
|
||||
const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) => {
|
||||
const [language, setLanguage] = useState<string>("C++");
|
||||
const [code, setCode] = useState<string>("");
|
||||
const [isDragging, setIsDragging] = useState<boolean>(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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -76,7 +88,7 @@ const CodeEditor: React.FC = () => {
|
||||
{/* Панель выбора языка и загрузки файла */}
|
||||
<div className="flex items-center justify-between py-3 ">
|
||||
<div className="flex items-center gap-[20px]">
|
||||
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} />
|
||||
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/>
|
||||
|
||||
<label
|
||||
className={cn("h-[40px] w-[250px] rounded-[10px] px-[16px] relative flex items-center cursor-pointer transition-all bg-liquid-lighter outline-dashed outline-[2px] outline-transparent active:scale-[95%]",
|
||||
30
src/views/mission/statement/Header.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { chevroneLeft, chevroneRight, arrowLeft } from "../../../assets/icons/header";
|
||||
import { Logo } from "../../../assets/logos";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface HeaderProps {
|
||||
missionId: number;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
missionId
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
||||
<img src={Logo} alt="Logo" className="h-[28px] w-auto cursor-pointer" onClick={() => { navigate("/home") }} />
|
||||
|
||||
<img src={arrowLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate("/home/missions") }} />
|
||||
|
||||
<div className="flex gap-[10px]">
|
||||
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
|
||||
<span>{missionId}</span>
|
||||
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
|
||||
</div>
|
||||
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
113
src/views/mission/statement/LaTextContainer.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MathJax?: {
|
||||
startup?: { promise?: Promise<void> };
|
||||
typesetPromise?: (elements?: Element[]) => Promise<void>;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: number;
|
||||
fileName: string;
|
||||
mediaUrl: string;
|
||||
}
|
||||
|
||||
interface LaTextContainerProps {
|
||||
html: string;
|
||||
latex: string;
|
||||
mediaFiles?: MediaFile[];
|
||||
}
|
||||
|
||||
let mathJaxPromise: Promise<void> | null = null;
|
||||
|
||||
const loadMathJax = () => {
|
||||
if (mathJaxPromise) return mathJaxPromise;
|
||||
|
||||
mathJaxPromise = new Promise<void>((resolve, reject) => {
|
||||
if (window.MathJax?.typesetPromise) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
(window as any).MathJax = {
|
||||
tex: {
|
||||
inlineMath: [["$$$", "$$$"]],
|
||||
displayMath: [["$$$$$$", "$$$$$$"]],
|
||||
processEscapes: true,
|
||||
},
|
||||
options: {
|
||||
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
|
||||
},
|
||||
startup: { typeset: false },
|
||||
};
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = "mathjax-script";
|
||||
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
window.MathJax?.startup?.promise?.then(resolve).catch(reject);
|
||||
};
|
||||
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return mathJaxPromise;
|
||||
};
|
||||
|
||||
const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
const latexImageNames = Array.from(latex.matchAll(/\\includegraphics\{(.+?)\}/g)).map(
|
||||
(match) => match[1]
|
||||
);
|
||||
|
||||
const imgs = doc.querySelectorAll<HTMLImageElement>("img.tex-graphics");
|
||||
|
||||
imgs.forEach((img, idx) => {
|
||||
const imageName = latexImageNames[idx];
|
||||
if (!imageName || !mediaFiles) return;
|
||||
const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
|
||||
if (mediaFile) img.src = mediaFile.mediaUrl;
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
};
|
||||
|
||||
const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFiles }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [processedHtml, setProcessedHtml] = useState<string>(html);
|
||||
|
||||
// 1️⃣ Обновляем HTML при изменении входных данных
|
||||
useEffect(() => {
|
||||
setProcessedHtml(replaceImages(html, latex, mediaFiles));
|
||||
}, [html, latex, mediaFiles]);
|
||||
|
||||
// 2️⃣ После рендера обновленного HTML применяем MathJax
|
||||
useEffect(() => {
|
||||
const renderMath = () => {
|
||||
if (containerRef.current && window.MathJax?.typesetPromise) {
|
||||
window.MathJax.typesetPromise([containerRef.current]).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
loadMathJax().then(renderMath).catch(console.error);
|
||||
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
|
||||
|
||||
return (
|
||||
<div
|
||||
className="latex-container"
|
||||
ref={containerRef}
|
||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LaTextContainer;
|
||||
61
src/views/mission/statement/MissionSubmissions.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import SubmissionItem from "./SubmissionItem";
|
||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
||||
import { FC, 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;
|
||||
}
|
||||
|
||||
interface MissionSubmissionsProps{
|
||||
missionId: number;
|
||||
}
|
||||
|
||||
const MissionSubmissions: FC<MissionSubmissionsProps> = ({missionId}) => {
|
||||
const submissions = useAppSelector((state) => state.submin.submitsById[missionId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
const checkStatus = (status: string) => {
|
||||
if (status == "IncorrectAnswer")
|
||||
return "wronganswer";
|
||||
if (status == "TimeLimitError")
|
||||
return "timelimit";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
|
||||
|
||||
|
||||
{submissions && submissions.map((v, i) => (
|
||||
<SubmissionItem
|
||||
key={i}
|
||||
id={v.id}
|
||||
language={v.solution.language}
|
||||
time={v.solution.time}
|
||||
verdict={v.solution.testerMessage?.includes("Compilation failed") ? "Compilation failed" : v.solution.testerMessage}
|
||||
type={i % 2 ? "second" : "first"}
|
||||
status={v.solution.testerMessage == "All tests passed" ? "success" : checkStatus(v.solution.testerErrorCode)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissionSubmissions;
|
||||
93
src/views/mission/statement/Statement.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
// import { cn } from "../../../lib/cn";
|
||||
import LaTextContainer from "./LaTextContainer";
|
||||
// import FullLatexRenderer from "./FullLatexRenderer";
|
||||
|
||||
export interface StatementData {
|
||||
id?: number;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
timeLimit?: number;
|
||||
memoryLimit?: number;
|
||||
legend?: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
sampleTests?: { input: string; output: string }[];
|
||||
notes?: string;
|
||||
html?: string;
|
||||
mediaFiles?: { id: number; fileName: string; mediaUrl: string }[];
|
||||
}
|
||||
|
||||
function extractDivByClass(html: string, className: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const div = doc.querySelector(`div.${className}`);
|
||||
return div ? div.outerHTML : "";
|
||||
}
|
||||
|
||||
const Statement: React.FC<StatementData> = ({
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
timeLimit = 1000,
|
||||
memoryLimit = 256 * 1024 * 1024,
|
||||
legend = "",
|
||||
input = "",
|
||||
output = "",
|
||||
sampleTests = [],
|
||||
notes = "",
|
||||
html = "",
|
||||
mediaFiles,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full medium-scrollbar pl-[20px] pr-[12px] gap-[20px] text-liquid-white overflow-y-scroll thin-dark-scrollbar [scrollbar-gutter:stable]">
|
||||
<div>
|
||||
<p className="h-[50px] text-[40px] font-bold text-liquid-white">{name}</p>
|
||||
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #{id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[10px] w-full flex-wrap">
|
||||
{tags && tags.map((v, i) => <div key={i} className="px-[16px] py-[8px] rounded-full bg-liquid-lighter ">{v}</div>)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по времени на тест:</span> {timeLimit / 1000} секунда</p>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по памяти на тест:</span> {memoryLimit / 1024 / 1024} мегабайт</p>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ввод:</span> стандартный ввод</p>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">вывод:</span> стандартный вывод</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[10px] mt-[20px]">
|
||||
<LaTextContainer html={extractDivByClass(html, "legend")} latex={legend} mediaFiles={mediaFiles}/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<LaTextContainer html={extractDivByClass(html, "input-specification")} latex={input} mediaFiles={mediaFiles}/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<LaTextContainer html={extractDivByClass(html, "output-specification")} latex={output} mediaFiles={mediaFiles}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<div className="text-[18px] font-bold">{sampleTests.length == 1 ? "Пример" : "Примеры"}</div>
|
||||
|
||||
|
||||
{sampleTests.map((v, i) =>
|
||||
<div key={i} className="flex flex-col gap-[10px]">
|
||||
<div className="text-[14px] font-bold">Входные данные</div>
|
||||
<div className="p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line">{v.input}</div>
|
||||
<div className="text-[14px] font-bold">Выходные данные</div>
|
||||
<div className="p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line">{v.output}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<LaTextContainer html={extractDivByClass(html, "note")} latex={notes} mediaFiles={mediaFiles}/>
|
||||
<div>Автор: Jacks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default Statement;
|
||||
79
src/views/mission/statement/SubmissionItem.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface SubmissionItemProps {
|
||||
id: number;
|
||||
language: string;
|
||||
time: string;
|
||||
verdict: string;
|
||||
type: "first" | "second";
|
||||
status?: "success" | "wronganswer" | "timelimit";
|
||||
}
|
||||
|
||||
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} МБ`;
|
||||
}
|
||||
|
||||
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 SubmissionItem: React.FC<SubmissionItemProps> = ({
|
||||
id,
|
||||
language,
|
||||
time,
|
||||
verdict,
|
||||
type,
|
||||
status,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={cn(" w-full relative rounded-[10px] text-liquid-white",
|
||||
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
||||
"grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center",
|
||||
status == "wronganswer" && "border-l-[11px] border-l-liquid-red pl-[9px]",
|
||||
status == "timelimit" && "border-l-[11px] border-l-liquid-orange pl-[9px]",
|
||||
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]",
|
||||
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300",
|
||||
)}
|
||||
onClick={() => { }}
|
||||
>
|
||||
<div className="text-[18px] font-bold">
|
||||
#{id}
|
||||
</div>
|
||||
<div className="text-[18px] font-bold text-center">
|
||||
{formatDate(time)}
|
||||
</div>
|
||||
<div className="text-[18px] font-bold text-center">
|
||||
{language}
|
||||
</div>
|
||||
<div className={cn("text-[18px] font-bold text-center",
|
||||
status == "wronganswer" && "text-liquid-red",
|
||||
status == "timelimit" && "text-liquid-orange",
|
||||
status == "success" && "text-liquid-green",
|
||||
)} >
|
||||
{verdict}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmissionItem;
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "../../../lib/cn";
|
||||
|
||||
|
||||
const Statement: React.FC = () => {
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full bg-red-3001 overflow-y-scroll medium-scrollbar pl-[20px] pr-[12px] gap-[20px]">
|
||||
<div>
|
||||
<p className="h-[50px] text-[40px] font-bold text-liquid-white">Грод на 2700</p>
|
||||
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #1234</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
tags
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по времени на тест:</span> 1 секунда</p>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по памяти на тест:</span> 256 мегабайт</p>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ввод:</span> стандартный ввод</p>
|
||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">вывод:</span> стандартный вывод</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Три друга хотят встретиться друг с другом. Изначально первый друг находится в позиции x = a, второй друг находится в позиции x = b, а третий находится в позиции x = c на координатной оси Ox.
|
||||
За одну минуту каждый из друзей независимо от других друзей может изменить позицию x на 11 влево или на 11 вправо (то есть присвоить x := x − 1 или x := x + 1), или даже не менять ее.
|
||||
Введем понятие суммарной попарной дистанции — суммы дистанций между каждой парой друзей. Пусть a′, b′ и c′ — финальные позиции первого, второго и третьего друзей соответственно. Тогда суммарная попарная дистанция
равна |a′ − b′| + |a′ − c′| + |b′ − c′|, где |x| — абсолютная величина (модуль) значения x.
|
||||
Друзья интересуются, какой минимальной суммарной попарной дистанции они смогут достичь, если они будут двигаться оптимально. Каждый из друзей сдвинется не более одного раза. Таким образом, более формально, они хотят знать минимальную суммарную попарную дистанцию, которой они могут достичь спустя одну минуту.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default Statement;
|
||||