Merge remote-tracking branch 'origin/dev'
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s

This commit is contained in:
2025-11-05 20:56:37 +03:00
93 changed files with 7966 additions and 2579 deletions

1792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,23 +5,30 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@reduxjs/toolkit": "^2.9.2", "@reduxjs/toolkit": "^2.9.2",
"@tailwindcss/typography": "^0.5.19",
"@types/react-redux": "^7.1.33", "@types/react-redux": "^7.1.33",
"axios": "^1.12.2", "axios": "^1.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.9.0", "framer-motion": "^11.9.0",
"highlight.js": "^11.11.1",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"tailwind-cn": "^1.0.2", "tailwind-cn": "^1.0.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss": "^3.4.12" "tailwindcss": "^3.4.12"

View File

@@ -1,27 +1,31 @@
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from 'react-router-dom';
// import { PrimaryButton } from "./components/button/PrimaryButton"; // import { PrimaryButton } from "./components/button/PrimaryButton";
// import { SecondaryButton } from "./components/button/SecondaryButton"; // import { SecondaryButton } from "./components/button/SecondaryButton";
// import { Checkbox } from "./components/checkbox/Checkbox"; // import { Checkbox } from "./components/checkbox/Checkbox";
// import { Input } from "./components/input/Input"; // import { Input } from "./components/input/Input";
// import { Switch } from "./components/switch/Switch"; // import { Switch } from "./components/switch/Switch";
import Home from "./pages/Home"; import Home from './pages/Home';
import Mission from "./pages/Mission"; import Mission from './pages/Mission';
import UploadMissionForm from "./views/mission/UploadMissionForm"; import ArticleEditor from './pages/ArticleEditor';
import Article from './pages/Article';
function App() { function App() {
return ( return (
<div className="w-full h-full bg-liquid-background flex justify-center"> <div className="w-full h-full bg-liquid-background flex justify-center">
<div className="relative w-full max-w-[1600px] h-full "> <div className="relative w-full max-w-[1600px] h-full ">
<Routes> <Routes>
<Route path="/home/*" element={<Home />} /> <Route path="/home/*" element={<Home />} />
<Route path="/mission/:missionId" element={<Mission />} /> <Route path="/mission/:missionId" element={<Mission />} />
<Route path="/upload" element={<UploadMissionForm/>}/> <Route
<Route path="*" element={<Home />} /> path="/article/create/*"
</Routes> element={<ArticleEditor />}
/>
</div> <Route path="/article/:articleId" element={<Article />} />
</div> <Route path="*" element={<Home />} />
); </Routes>
</div>
</div>
);
} }
export default App; export default App;

View File

@@ -0,0 +1,3 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 21H13.5C13.1022 21 12.7206 21.158 12.4393 21.4393C12.158 21.7206 12 22.1022 12 22.5C12 22.8978 12.158 23.2794 12.4393 23.5607C12.7206 23.842 13.1022 24 13.5 24H19.5C19.8978 24 20.2794 23.842 20.5607 23.5607C20.842 23.2794 21 22.8978 21 22.5C21 22.1022 20.842 21.7206 20.5607 21.4393C20.2794 21.158 19.8978 21 19.5 21ZM25.5 6H23.73C23.4205 5.12468 22.8479 4.3665 22.0906 3.82941C21.3333 3.29232 20.4284 3.00261 19.5 3H16.5C15.5716 3.00261 14.6667 3.29232 13.9094 3.82941C13.1521 4.3665 12.5795 5.12468 12.27 6H10.5C9.30653 6 8.16193 6.47411 7.31802 7.31802C6.47411 8.16193 6 9.30653 6 10.5V28.5C6 29.6935 6.47411 30.8381 7.31802 31.682C8.16193 32.5259 9.30653 33 10.5 33H25.5C26.6935 33 27.8381 32.5259 28.682 31.682C29.5259 30.8381 30 29.6935 30 28.5V10.5C30 9.30653 29.5259 8.16193 28.682 7.31802C27.8381 6.47411 26.6935 6 25.5 6V6ZM15 7.5C15 7.10218 15.158 6.72064 15.4393 6.43934C15.7206 6.15804 16.1022 6 16.5 6H19.5C19.8978 6 20.2794 6.15804 20.5607 6.43934C20.842 6.72064 21 7.10218 21 7.5V9H15V7.5ZM27 28.5C27 28.8978 26.842 29.2794 26.5607 29.5607C26.2794 29.842 25.8978 30 25.5 30H10.5C10.1022 30 9.72064 29.842 9.43934 29.5607C9.15804 29.2794 9 28.8978 9 28.5V10.5C9 10.1022 9.15804 9.72064 9.43934 9.43934C9.72064 9.15804 10.1022 9 10.5 9H12V10.5C12 10.8978 12.158 11.2794 12.4393 11.5607C12.7206 11.842 13.1022 12 13.5 12H22.5C22.8978 12 23.2794 11.842 23.5607 11.5607C23.842 11.2794 24 10.8978 24 10.5V9H25.5C25.8978 9 26.2794 9.15804 26.5607 9.43934C26.842 9.72064 27 10.1022 27 10.5V28.5ZM22.5 15H13.5C13.1022 15 12.7206 15.158 12.4393 15.4393C12.158 15.7206 12 16.1022 12 16.5C12 16.8978 12.158 17.2794 12.4393 17.5607C12.7206 17.842 13.1022 18 13.5 18H22.5C22.8978 18 23.2794 17.842 23.5607 17.5607C23.842 17.2794 24 16.8978 24 16.5C24 16.1022 23.842 15.7206 23.5607 15.4393C23.2794 15.158 22.8978 15 22.5 15Z" fill="#00DBD9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.5 6H27V4.5C27 4.10218 26.842 3.72064 26.5607 3.43934C26.2794 3.15804 25.8978 3 25.5 3H10.5C10.1022 3 9.72064 3.15804 9.43934 3.43934C9.15804 3.72064 9 4.10218 9 4.5V6H4.5C4.10218 6 3.72064 6.15804 3.43934 6.43934C3.15804 6.72064 3 7.10218 3 7.5V12C3 13.5913 3.63214 15.1174 4.75736 16.2426C5.88258 17.3679 7.4087 18 9 18H11.31C12.6668 19.5137 14.4961 20.5235 16.5 20.865V24H15C13.8065 24 12.6619 24.4741 11.818 25.318C10.9741 26.1619 10.5 27.3065 10.5 28.5V31.5C10.5 31.8978 10.658 32.2794 10.9393 32.5607C11.2206 32.842 11.6022 33 12 33H24C24.3978 33 24.7794 32.842 25.0607 32.5607C25.342 32.2794 25.5 31.8978 25.5 31.5V28.5C25.5 27.3065 25.0259 26.1619 24.182 25.318C23.3381 24.4741 22.1935 24 21 24H19.5V20.865C21.5039 20.5235 23.3332 19.5137 24.69 18H27C28.5913 18 30.1174 17.3679 31.2426 16.2426C32.3679 15.1174 33 13.5913 33 12V7.5C33 7.10218 32.842 6.72064 32.5607 6.43934C32.2794 6.15804 31.8978 6 31.5 6ZM9 15C8.20435 15 7.44129 14.6839 6.87868 14.1213C6.31607 13.5587 6 12.7956 6 12V9H9V12C9.0033 13.0226 9.18084 14.0371 9.525 15H9ZM21 27C21.3978 27 21.7794 27.158 22.0607 27.4393C22.342 27.7206 22.5 28.1022 22.5 28.5V30H13.5V28.5C13.5 28.1022 13.658 27.7206 13.9393 27.4393C14.2206 27.158 14.6022 27 15 27H21ZM24 12C24 13.5913 23.3679 15.1174 22.2426 16.2426C21.1174 17.3679 19.5913 18 18 18C16.4087 18 14.8826 17.3679 13.7574 16.2426C12.6321 15.1174 12 13.5913 12 12V6H24V12ZM30 12C30 12.7956 29.6839 13.5587 29.1213 14.1213C28.5587 14.6839 27.7956 15 27 15H26.475C26.8192 14.0371 26.9967 13.0226 27 12V9H30V12Z" fill="#00DBD9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,5 @@
import Clipboard from './clipboard.svg';
import Cup from './cup.svg';
import OpenBook from './openbook.svg';
export { Clipboard, Cup, OpenBook };

View File

@@ -0,0 +1,3 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7554 3.09C30.6802 2.9048 29.5914 2.80947 28.5004 2.805C24.7779 2.80195 21.1331 3.86936 18.0004 5.88C14.8596 3.89576 11.2153 2.85455 7.50037 2.88C6.40934 2.88447 5.32058 2.9798 4.24537 3.165C3.89318 3.22572 3.57424 3.41019 3.34599 3.68519C3.11774 3.96019 2.99518 4.30765 3.00037 4.665V22.665C2.99716 22.8854 3.04256 23.1038 3.13335 23.3047C3.22413 23.5055 3.35807 23.6839 3.52563 23.8271C3.69319 23.9703 3.89026 24.0749 4.1028 24.1333C4.31535 24.1917 4.53816 24.2025 4.75537 24.165C6.9049 23.7928 9.10717 23.8546 11.2325 24.3466C13.3578 24.8387 15.3631 25.751 17.1304 27.03L17.3104 27.135H17.4754C17.6417 27.2043 17.8202 27.24 18.0004 27.24C18.1806 27.24 18.359 27.2043 18.5254 27.135H18.6904L18.8704 27.03C20.6253 25.7224 22.6249 24.7805 24.7506 24.26C26.8763 23.7396 29.0849 23.6511 31.2454 24C31.4626 24.0375 31.6854 24.0267 31.8979 23.9683C32.1105 23.9099 32.3075 23.8053 32.4751 23.6621C32.6427 23.5189 32.7766 23.3405 32.8674 23.1397C32.9582 22.9388 33.0036 22.7204 33.0004 22.5V4.5C32.9848 4.15823 32.8528 3.83207 32.6263 3.57561C32.3999 3.31915 32.0926 3.1478 31.7554 3.09ZM16.5004 23.025C13.7252 21.565 10.6361 20.8031 7.50037 20.805C7.00537 20.805 6.51037 20.805 6.00037 20.805V5.805C6.49996 5.77621 7.00079 5.77621 7.50037 5.805C10.7004 5.80146 13.8307 6.74054 16.5004 8.505V23.025ZM30.0004 20.865C29.4904 20.865 28.9954 20.865 28.5004 20.865C25.3646 20.8631 22.2755 21.625 19.5004 23.085V8.505C22.17 6.74054 25.3003 5.80146 28.5004 5.805C29 5.77621 29.5008 5.77621 30.0004 5.805V20.865ZM31.7554 27.09C30.6802 26.9048 29.5914 26.8095 28.5004 26.805C24.7779 26.8019 21.1331 27.8694 18.0004 29.88C14.8676 27.8694 11.2228 26.8019 7.50037 26.805C6.40934 26.8095 5.32058 26.9048 4.24537 27.09C4.05011 27.121 3.8629 27.1903 3.69451 27.2939C3.52612 27.3974 3.37987 27.5333 3.26417 27.6936C3.14847 27.8539 3.0656 28.0355 3.02033 28.228C2.97505 28.4204 2.96827 28.6199 3.00037 28.815C3.0766 29.2045 3.30411 29.548 3.63306 29.7701C3.96201 29.9922 4.36558 30.0749 4.75537 30C6.9049 29.6278 9.10717 29.6896 11.2325 30.1816C13.3578 30.6737 15.3631 31.586 17.1304 32.865C17.3844 33.0459 17.6885 33.1431 18.0004 33.1431C18.3122 33.1431 18.6163 33.0459 18.8704 32.865C20.6376 31.586 22.643 30.6737 24.7683 30.1816C26.8936 29.6896 29.0958 29.6278 31.2454 30C31.6352 30.0749 32.0387 29.9922 32.3677 29.7701C32.6966 29.548 32.9241 29.2045 33.0004 28.815C33.0325 28.6199 33.0257 28.4204 32.9804 28.228C32.9351 28.0355 32.8523 27.8539 32.7366 27.6936C32.6209 27.5333 32.4746 27.3974 32.3062 27.2939C32.1378 27.1903 31.9506 27.121 31.7554 27.09V27.09Z" fill="#00DBD9"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View 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.7105 12.71C16.6909 11.9387 17.4065 10.8809 17.7577 9.68394C18.109 8.48697 18.0784 7.21027 17.6703 6.03147C17.2621 4.85267 16.4967 3.83039 15.4806 3.10686C14.4644 2.38332 13.2479 1.99451 12.0005 1.99451C10.753 1.99451 9.5366 2.38332 8.52041 3.10686C7.50423 3.83039 6.73883 4.85267 6.3307 6.03147C5.92257 7.21027 5.892 8.48697 6.24325 9.68394C6.59449 10.8809 7.31009 11.9387 8.29048 12.71C6.61056 13.383 5.14477 14.4994 4.04938 15.9399C2.95398 17.3805 2.27005 19.0913 2.07048 20.89C2.05604 21.0213 2.0676 21.1542 2.10451 21.2811C2.14142 21.4079 2.20295 21.5263 2.2856 21.6293C2.4525 21.8375 2.69527 21.9708 2.96049 22C3.2257 22.0292 3.49164 21.9518 3.69981 21.7849C3.90798 21.618 4.04131 21.3752 4.07049 21.11C4.29007 19.1552 5.22217 17.3498 6.6887 16.0388C8.15524 14.7278 10.0534 14.003 12.0205 14.003C13.9876 14.003 15.8857 14.7278 17.3523 16.0388C18.8188 17.3498 19.7509 19.1552 19.9705 21.11C19.9977 21.3557 20.1149 21.5827 20.2996 21.747C20.4843 21.9114 20.7233 22.0015 20.9705 22H21.0805C21.3426 21.9698 21.5822 21.8373 21.747 21.6313C21.9119 21.4252 21.9886 21.1624 21.9605 20.9C21.76 19.0962 21.0724 17.381 19.9713 15.9382C18.8703 14.4954 17.3974 13.3795 15.7105 12.71ZM12.0005 12C11.2094 12 10.436 11.7654 9.7782 11.3259C9.12041 10.8864 8.60772 10.2616 8.30497 9.53074C8.00222 8.79983 7.923 7.99557 8.07734 7.21964C8.23168 6.44372 8.61265 5.73099 9.17206 5.17158C9.73147 4.61217 10.4442 4.2312 11.2201 4.07686C11.996 3.92252 12.8003 4.00173 13.5312 4.30448C14.2621 4.60724 14.8868 5.11993 15.3264 5.77772C15.7659 6.43552 16.0005 7.20888 16.0005 8C16.0005 9.06087 15.5791 10.0783 14.8289 10.8284C14.0788 11.5786 13.0614 12 12.0005 12Z" fill="#EDF6F7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,3 +1,4 @@
import Balloon from "./balloon.svg"; import Balloon from './balloon.svg';
import Account from './account.svg';
export {Balloon}; export { Balloon, Account };

View File

@@ -1,8 +1,8 @@
import Book from "./book.png" import Book from './book.png';
import EyeClosed from "./eye-closed.svg"; import EyeClosed from './eye-closed.svg';
import EyeOpen from "./eye-open.png"; import EyeOpen from './eye-open.png';
import Edit from "./edit.svg"; import Edit from './edit.svg';
import UserAdd from "./user-profile-add.svg"; import UserAdd from './user-profile-add.svg';
import ChevroneDown from "./chevron-down.svg" import ChevroneDown from './chevron-down.svg';
export {Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown} export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };

View File

@@ -1,5 +1,5 @@
import arrowLeft from "./arrow-left-sm.svg"; import arrowLeft from './arrow-left-sm.svg';
import chevroneLeft from "./chevron-left.svg" import chevroneLeft from './chevron-left.svg';
import chevroneRight from "./chevron-right.svg" import chevroneRight from './chevron-right.svg';
export {arrowLeft, chevroneLeft, chevroneRight} export { arrowLeft, chevroneLeft, chevroneRight };

View 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.6718C8.79698 18.6251 9.0098 18.511 9.17694 18.3438L18.9506 8.56474C19.4192 8.09588 19.4189 7.33589 18.9499 6.86743L16.8795 4.79936C16.4107 4.33109 15.6511 4.33141 15.1827 4.80007L5.40798 14.5801C5.24117 14.747 5.12727 14.9594 5.08052 15.1907L4.19922 19.5516Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -1,8 +1,17 @@
import eyeClosed from "./eye-closed.svg" import eyeClosed from './eye-closed.svg';
import eyeOpen from "./eye-open.png" import eyeOpen from './eye-open.png';
import googleLogo from "./google-logo.svg" import googleLogo from './google-logo.svg';
import upload from "./upload.svg" import upload from './upload.svg';
import chevroneDropDownList from "./chevron-drop-down.svg" import chevroneDropDownList from './chevron-drop-down.svg';
import checkMark from "./check-mark.svg" import checkMark from './check-mark.svg';
import Edit from './edit.svg';
export {eyeClosed, eyeOpen, googleLogo, upload, chevroneDropDownList, checkMark} export {
Edit,
eyeClosed,
eyeOpen,
googleLogo,
upload,
chevroneDropDownList,
checkMark,
};

View File

@@ -1,8 +1,8 @@
import Account from "./account.svg"; import Account from './account.svg';
import Clipboard from "./clipboard.svg"; import Clipboard from './clipboard.svg';
import Cup from "./cup.svg"; import Cup from './cup.svg';
import Home from "./home.svg"; import Home from './home.svg';
import Openbook from "./openbook.svg"; import Openbook from './openbook.svg';
import Users from "./users.svg"; import Users from './users.svg';
export {Account, Clipboard, Cup, Home, Openbook, Users}; export { Account, Clipboard, Cup, Home, Openbook, Users };

View File

@@ -0,0 +1,4 @@
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M565.632 471.936C565.632 523.683 523.683 565.632 471.936 565.632H237.696C185.949 565.632 144 523.683 144 471.936V237.696C144 185.949 185.949 144 237.696 144H471.936C523.683 144 565.632 185.949 565.632 237.696V471.936Z" stroke="white" stroke-width="48" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M328.064 656H562.304C614.051 656 656 614.048 656 562.304V328.062" stroke="white" stroke-width="48" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

View File

@@ -1,4 +1,5 @@
import IconSuccess from "./icon-success.svg" import IconSuccess from './icon-success.svg';
import IconError from "./icon-error.svg" import IconError from './icon-error.svg';
import CopyIcon from './copy-icon.svg';
export {IconError, IconSuccess} export { IconError, IconSuccess, CopyIcon };

View File

@@ -1,3 +1,3 @@
import Logo from "./Logo.svg" import Logo from './Logo.svg';
export {Logo} export { Logo };

View File

@@ -1,24 +1,25 @@
import axios from "axios"; import axios from 'axios';
const instance = axios.create({ const instance = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}, },
}); });
// Request interceptor: автоматически подставляет JWT, если есть // Request interceptor: автоматически подставляет JWT, если есть
instance.interceptors.request.use( instance.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState() const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState()
if (token) {
config.headers.Authorization = `Bearer ${token}`; if (token) {
} config.headers.Authorization = `Bearer ${token}`;
return config; }
}, return config;
(error) => { },
return Promise.reject(error); (error) => {
} return Promise.reject(error);
},
); );
export default instance; export default instance;

View File

@@ -1,70 +1,92 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
interface ButtonProps { interface ButtonProps {
disabled?: boolean; disabled?: boolean;
text?: string; text?: string;
className?: string; className?: string;
onClick: () => void; onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children?: React.ReactNode; children?: React.ReactNode;
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
} }
export const PrimaryButton: React.FC<ButtonProps> = ({ const ColorBgVariants = {
disabled = false, primary: 'bg-liquid-brightmain group-hover:ring-liquid-brightmain',
text = "", secondary: 'bg-liquid-darkmain group-hover:ring-liquid-darkmain',
className, error: 'bg-liquid-red group-hover:ring-liquid-red',
onClick, warning: 'bg-liquid-orange group-hover:ring-liquid-orange',
children, success: 'bg-liquid-green group-hover:ring-liquid-green',
}) => { };
return (
<label const ColorTextVariants = {
className={cn( primary: 'group-hover:text-liquid-brightmain ',
"grid relative cursor-pointer select-none group w-fit box-border", secondary: 'group-hover:text-liquid-brightmain ',
disabled && "pointer-events-none", error: 'group-hover:text-liquid-red ',
className warning: 'group-hover:text-liquid-orange ',
)} success: 'group-hover:text-liquid-green ',
> };
{/* Основной контейнер, */}
<div export const PrimaryButton: React.FC<ButtonProps> = ({
className={cn( disabled = false,
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300", text = '',
"rounded-[10px]", className,
"group-hover:bg-liquid-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset", onClick,
"px-[16px] py-[8px]", children,
"bg-liquid-darkmain", color = 'secondary',
disabled && "bg-liquid-lighter" }) => {
)} return (
> <label
{/* Скрытый button */} className={cn(
<button 'grid relative cursor-pointer select-none group w-fit box-border',
className={cn( disabled && 'pointer-events-none',
"absolute opacity-0 -z-10 h-0 w-0", className,
"[&:focus-visible+*]:outline-liquid-brightmain", )}
)} >
disabled={disabled} {/* Основной контейнер, */}
onClick={() => { onClick() }} <div
/> className={cn(
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
{/* Граница при выделении через tab */} 'rounded-[10px]',
<div 'group-hover:bg-liquid-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset',
className={cn( 'px-[16px] py-[8px]',
"absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]", ColorBgVariants[color],
"rounded-[10px]", disabled && 'bg-liquid-lighter',
"px-[16px] py-[8px]", )}
)} >
> {/* Скрытый button */}
{children || text} <button
</div> className={cn(
<div 'absolute opacity-0 -z-10 h-0 w-0',
className={cn( '[&:focus-visible+*]:outline-liquid-brightmain',
"transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]", )}
"group-hover:text-liquid-brightmain ", disabled={disabled}
disabled && "text-liquid-light" onClick={(
)} e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
> ) => {
{children || text} onClick(e);
</div> }}
</div> />
</label>
); {/* Граница при выделении через tab */}
<div
className={cn(
'absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]',
'rounded-[10px]',
'px-[16px] py-[8px]',
)}
>
{children || text}
</div>
<div
className={cn(
'transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]',
ColorTextVariants[color],
disabled && 'text-liquid-light',
)}
>
{children || text}
</div>
</div>
</label>
);
}; };

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '../../lib/cn';
interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children?: React.ReactNode;
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
}
const ColorBgVariants = {
primary: 'group-hover:bg-liquid-brightmain ring-liquid-brightmain',
secondary: 'group-hover:bg-liquid-darkmain ring-liquid-darkmain',
error: 'group-hover:bg-liquid-red ring-liquid-red',
warning: 'group-hover:bg-liquid-orange ring-liquid-orange',
success: 'group-hover:bg-liquid-green ring-liquid-green',
};
const ColorTextVariants = {
primary: 'text-liquid-brightmain ',
secondary: 'text-liquid-brightmain ',
error: 'text-liquid-red ',
warning: 'text-liquid-orange ',
success: 'text-liquid-green ',
};
export const ReverseButton: React.FC<ButtonProps> = ({
disabled = false,
text = '',
className,
onClick,
children,
color = 'secondary',
}) => {
return (
<label
className={cn(
'grid relative cursor-pointer select-none group w-fit box-border',
disabled && 'pointer-events-none',
className,
)}
>
{/* Основной контейнер, */}
<div
className={cn(
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
'rounded-[10px]',
'group-hover:bg-liquid-darkmain ',
'px-[16px] py-[8px]',
'bg-liquid-lighter ring-[1px] ring-liquid-darkmain ring-inset',
ColorBgVariants[color],
disabled && 'bg-liquid-lighter',
)}
>
{/* Скрытый button */}
<button
className={cn(
'absolute opacity-0 -z-10 h-0 w-0',
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
onClick(e);
}}
/>
{/* Граница при выделении через tab */}
<div
className={cn(
'absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]',
'rounded-[10px]',
'px-[16px] py-[8px]',
)}
>
{children || text}
</div>
<div
className={cn(
'transition-all duration-300 text-[18px] font-bold p-0 m-0 leading-[23px]',
'group-hover:text-liquid-white ',
ColorTextVariants[color],
disabled && 'text-liquid-light',
)}
>
{children || text}
</div>
</div>
</label>
);
};

View File

@@ -1,69 +1,70 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
interface ButtonProps { interface ButtonProps {
disabled?: boolean; disabled?: boolean;
text?: string; text?: string;
className?: string; className?: string;
onClick: () => void; onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children?: React.ReactNode; children?: React.ReactNode;
} }
export const SecondaryButton: React.FC<ButtonProps> = ({ export const SecondaryButton: React.FC<ButtonProps> = ({
disabled = false, disabled = false,
text = "", text = '',
className, className,
onClick, onClick,
children, children,
}) => { }) => {
return ( return (
<label <label
className={cn( className={cn(
"grid relative cursor-pointer select-none group w-fit box-border", 'grid relative cursor-pointer select-none group w-fit box-border',
disabled && "pointer-events-none", disabled && 'pointer-events-none',
className className,
)} )}
> >
{/* Основной контейнер, */} {/* Основной контейнер, */}
<div <div
className={cn( className={cn(
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300", 'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
"rounded-[10px]", 'rounded-[10px]',
"group-hover:bg-liquid-background", 'group-hover:bg-liquid-background',
"px-[16px] py-[8px]", 'px-[16px] py-[8px]',
"bg-liquid-lighter" 'bg-liquid-lighter',
)} )}
> >
{/* Скрытый button */} {/* Скрытый button */}
<button <button
className={cn( className={cn(
"absolute opacity-0 -z-10 h-0 w-0", 'absolute opacity-0 -z-10 h-0 w-0',
"[&:focus-visible+*]:outline-liquid-brightmain", '[&:focus-visible+*]:outline-liquid-brightmain',
)} )}
disabled={disabled} disabled={disabled}
onClick={() => { onClick() }} onClick={(e) => {
/> onClick(e);
}}
/>
{/* Граница при выделении через tab */} {/* Граница при выделении через tab */}
<div <div
className={cn( className={cn(
"absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]", 'absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]',
"rounded-[10px]", 'rounded-[10px]',
"px-[16px] py-[8px]", 'px-[16px] py-[8px]',
)} )}
> >
{children || text} {children || text}
</div> </div>
<div <div
className={cn( className={cn(
"transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]", 'transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]',
disabled && "text-liquid-light" disabled && 'text-liquid-light',
)} )}
> >
{children || text} {children || text}
</div> </div>
</div> </div>
</label> </label>
); );
}; };

View File

@@ -1,168 +1,167 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
import { motion } from "framer-motion"; import { motion } from 'framer-motion';
const pathVariants = { const pathVariants = {
hidden: { hidden: {
opacity: 0, opacity: 0,
pathLength: 0, pathLength: 0,
}, },
visible: { visible: {
opacity: 1, opacity: 1,
pathLength: 1, pathLength: 1,
transition: { transition: {
delay: 0.15, delay: 0.15,
duration: 0.4, duration: 0.4,
ease: "easeInOut", ease: 'easeInOut',
},
}, },
},
}; };
const sizeVariants = { const sizeVariants = {
sm: "h-4 w-4", sm: 'h-4 w-4',
md: "h-5 w-5", md: 'h-5 w-5',
lg: "h-6 w-6", lg: 'h-6 w-6',
}; };
const colorsVariants = { const colorsVariants = {
default: "bg-default", default: 'bg-default',
primary: "bg-liquid-brightmain", primary: 'bg-liquid-brightmain',
secondary: "bg-liquid-darkmain", secondary: 'bg-liquid-darkmain',
success: "bg-liquid-green", success: 'bg-liquid-green',
warning: "bg-liquid-orange", warning: 'bg-liquid-orange',
danger: "bg-liquid-red", danger: 'bg-liquid-red',
}; };
const borderColorsVariants = { const borderColorsVariants = {
default: "border-default", default: 'border-default',
primary: "border-liquid-brightmain", primary: 'border-liquid-brightmain',
secondary: "border-liquid-darkmain", secondary: 'border-liquid-darkmain',
success: "border-liquid-green", success: 'border-liquid-green',
warning: "border-liquid-orange", warning: 'border-liquid-orange',
danger: "border-liquid-red", danger: 'border-liquid-red',
}; };
const focuseOutlineVariants = { const focuseOutlineVariants = {
default: "[&:focus-visible+*]:outline-default", default: '[&:focus-visible+*]:outline-default',
primary: "[&:focus-visible+*]:outline-liquid-brightmain", primary: '[&:focus-visible+*]:outline-liquid-brightmain',
secondary: "[&:focus-visible+*]:outline-liquid-darkmain", secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
success: "[&:focus-visible+*]:outline-liquid-green", success: '[&:focus-visible+*]:outline-liquid-green',
warning: "[&:focus-visible+*]:outline-liquid-orange", warning: '[&:focus-visible+*]:outline-liquid-orange',
danger: "[&:focus-visible+*]:outline-liquid-red", danger: '[&:focus-visible+*]:outline-liquid-red',
}; };
const radiusVraiants = { const radiusVraiants = {
none: "", none: '',
sm: "rounded-[3.5px]", sm: 'rounded-[3.5px]',
md: "rounded-[5px]", md: 'rounded-[5px]',
lg: "rounded-[7px]", lg: 'rounded-[7px]',
full: "rounded-full", full: 'rounded-full',
}; };
interface CheckboxProps { interface CheckboxProps {
size?: "sm" | "md" | "lg"; size?: 'sm' | 'md' | 'lg';
radius?: "none" | "sm" | "md" | "lg" | "full"; radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
disabled?: boolean; disabled?: boolean;
color?: color?:
| "default" | 'default'
| "primary" | 'primary'
| "secondary" | 'secondary'
| "success" | 'success'
| "warning" | 'warning'
| "danger"; | 'danger';
label?: string; label?: string;
variant?: "default" | "label"; variant?: 'default' | 'label';
className?: string; className?: string;
defaultState?: boolean; defaultState?: boolean;
onChange: (state: boolean) => void; onChange: (state: boolean) => void;
} }
export const Checkbox: React.FC<CheckboxProps> = ({ export const Checkbox: React.FC<CheckboxProps> = ({
size = "md", size = 'md',
radius = "md", radius = 'md',
disabled = false, disabled = false,
color = "primary", color = 'primary',
label = "", label = '',
variant = "label", variant = 'label',
className, className,
onChange, onChange,
defaultState = false, defaultState = false,
}) => { }) => {
const [active, setActive] = React.useState<boolean>(defaultState); const [active, setActive] = React.useState<boolean>(defaultState);
React.useEffect(() => onChange(active), [active]); React.useEffect(() => onChange(active), [active]);
return ( return (
<motion.label <motion.label
className={cn( className={cn(
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2", variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
"grid relative cursor-pointer p-2 select-none group ", 'grid relative cursor-pointer p-2 select-none group ',
className, className,
disabled && "pointer-events-none opacity-50", disabled && 'pointer-events-none opacity-50',
variant == "default" && "" variant == 'default' && '',
)}
>
<div
className={cn(
"group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300",
sizeVariants[size],
radiusVraiants[radius],
active && borderColorsVariants[color]
)}
>
<input
className={cn(
"absolute opacity-0 -z-10 h-0 w-0",
focuseOutlineVariants[color]
)}
disabled={disabled}
type="checkbox"
onChange={() => {
setActive(!active);
}}
/>
<div
className={cn(
"absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-200",
sizeVariants[size],
radiusVraiants[radius]
)}
></div>
<span
className={cn(
"absolute transition-all duration-300",
sizeVariants[size],
colorsVariants[color],
radiusVraiants[radius],
active && "opacity-100 scale-100",
!active && "opacity-0 scale-0"
)}
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{active && (
<motion.path
strokeWidth="1.5"
d="M5 8.22L7.66571 10.44L11.22 6"
stroke="white"
strokeLinecap="round"
variants={pathVariants}
initial="hidden"
animate="visible"
/>
)} )}
</svg> >
</span> <div
</div> className={cn(
{variant == "label" && ( 'group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300',
<div className="select-none text-layout-foeground transition-all duration-200"> sizeVariants[size],
{label} radiusVraiants[radius],
</div> active && borderColorsVariants[color],
)} )}
</motion.label> >
); <input
className={cn(
'absolute opacity-0 -z-10 h-0 w-0',
focuseOutlineVariants[color],
)}
disabled={disabled}
type="checkbox"
onChange={() => {
setActive(!active);
}}
/>
<div
className={cn(
'absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-200',
sizeVariants[size],
radiusVraiants[radius],
)}
></div>
<span
className={cn(
'absolute transition-all duration-300',
sizeVariants[size],
colorsVariants[color],
radiusVraiants[radius],
active && 'opacity-100 scale-100',
!active && 'opacity-0 scale-0',
)}
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{active && (
<motion.path
strokeWidth="1.5"
d="M5 8.22L7.66571 10.44L11.22 6"
stroke="white"
strokeLinecap="round"
variants={pathVariants}
initial="hidden"
animate="visible"
/>
)}
</svg>
</span>
</div>
{variant == 'label' && (
<div className="select-none text-layout-foeground transition-all duration-200">
{label}
</div>
)}
</motion.label>
);
}; };

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
import { checkMark, chevroneDropDownList } from "../../assets/icons/input"; import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
import { useClickOutside } from "../../hooks/useClickOutside"; import { useClickOutside } from '../../hooks/useClickOutside';
export interface DropDownListItem { export interface DropDownListItem {
text: string; text: string;
@@ -18,15 +18,16 @@ interface DropDownListProps {
export const DropDownList: React.FC<DropDownListProps> = ({ export const DropDownList: React.FC<DropDownListProps> = ({
// disabled = false, // disabled = false,
className = "", className = '',
onChange, onChange,
defaultState, defaultState,
items = [{ text: "", value: "" }], items = [{ text: '', value: '' }],
}) => { }) => {
if (items.length == 0) if (items.length == 0) items.push({ text: '', value: '' });
items.push({ text: "", value: "" });
const [value, setValue] = React.useState<DropDownListItem>(defaultState != undefined ? defaultState : items[0]); const [value, setValue] = React.useState<DropDownListItem>(
defaultState != undefined ? defaultState : items[0],
);
const [active, setActive] = React.useState<boolean>(false); const [active, setActive] = React.useState<boolean>(false);
React.useEffect(() => onChange(value.value), [value]); React.useEffect(() => onChange(value.value), [value]);
@@ -37,67 +38,73 @@ export const DropDownList: React.FC<DropDownListProps> = ({
setActive(false); setActive(false);
}); });
return ( return (
<div className={cn( <div className={cn('relative', className)} ref={ref}>
"relative", <div
className className={cn(
)} ' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]',
ref={ref} 'text-[18px] font-bold cursor-pointer select-none',
> 'transitin-all active:scale-95 duration-300',
<div className={cn(" flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]", )}
"text-[18px] font-bold cursor-pointer select-none",
"transitin-all active:scale-95 duration-300"
)}
onClick={() => { onClick={() => {
setActive(!active); setActive(!active);
} }}
}> >
{value.text} {value.text}
</div> </div>
<img src={chevroneDropDownList} <img
className={cn(" absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none", src={chevroneDropDownList}
active && " rotate-180" className={cn(
)} /> ' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none',
active && ' rotate-180',
)}
/>
<div <div
className={cn(" absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300", className={cn(
"grid overflow-hidden", ' absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300',
active ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", 'grid overflow-hidden',
)}> active
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
>
<div className=" overflow-hidden p-[8px]"> <div className=" overflow-hidden p-[8px]">
<div className={cn( <div
" overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ", className={cn(
)}> ' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
)}
{items.map((v, i) => >
{items.map((v, i) => (
<div <div
key={i} key={i}
className={cn( className={cn(
"cursor-pointer h-[36px] relative transition-all duration-300", 'cursor-pointer h-[36px] relative transition-all duration-300',
i + 1 != items.length && "border-b-liquid-light border-b-[1px]", i + 1 != items.length &&
"text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]", 'border-b-liquid-light border-b-[1px]',
"hover:bg-liquid-background", 'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
"first:rounded-t-[6px] last:rounded-b-[6px]" 'hover:bg-liquid-background',
'first:rounded-t-[6px] last:rounded-b-[6px]',
)} )}
onClick={() => { onClick={() => {
setValue(v); setValue(v);
setActive(false); setActive(false);
}}> }}
>
{v.text} {v.text}
{v.text == value.text && {v.text == value.text && (
<img src={checkMark} className=" absolute right-[8px]" /> <img
} src={checkMark}
className=" absolute right-[8px]"
/>
)}
</div> </div>
)} ))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,48 @@
import React from 'react';
interface DateRangeInputProps {
startLabel?: string;
endLabel?: string;
startValue?: string;
endValue?: string;
onChange: (field: 'startsAt' | 'endsAt', value: string) => void;
className?: string;
}
const DateRangeInput: React.FC<DateRangeInputProps> = ({
startLabel = 'Дата начала',
endLabel = 'Дата окончания',
startValue,
endValue,
onChange,
className = '',
}) => {
return (
<div className={`flex flex-col gap-2 ${className}`}>
<div>
<label className="block text-sm font-medium text-gray-700">
{startLabel}
</label>
<input
type="datetime-local"
value={startValue}
onChange={(e) => onChange('startsAt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{endLabel}
</label>
<input
type="datetime-local"
value={endValue}
onChange={(e) => onChange('endsAt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
);
};
export default DateRangeInput;

View File

@@ -1,78 +1,95 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
import { eyeClosed, eyeOpen } from "../../assets/icons/input"; import { eyeClosed, eyeOpen } from '../../assets/icons/input';
interface inputProps { interface inputProps {
name?: string; name?: string;
type: "text" | "email" | "password" | "first_name"; type: 'text' | 'email' | 'password' | 'first_name' | 'number';
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
onChange: (state: string) => void; onChange: (state: string) => void;
defaultState?: string; defaultState?: string;
autocomplete?: string; autocomplete?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
} }
export const Input: React.FC<inputProps> = ({ export const Input: React.FC<inputProps> = ({
type = "text", type = 'text',
error = "", error = '',
// disabled = false, // disabled = false,
// required = false, // required = false,
label = "", label = '',
placeholder = "", placeholder = '',
className = "", className = '',
onChange, onChange,
defaultState = "", defaultState = '',
name = "", name = '',
autocomplete="", autocomplete = '',
onKeyDown,
}) => { }) => {
const [value, setValue] = React.useState<string>(defaultState); const [value, setValue] = React.useState<string>(defaultState);
const [visible, setVIsible] = React.useState<boolean>(type != "password"); const [visible, setVIsible] = React.useState<boolean>(type != 'password');
React.useEffect(() => onChange(value), [value]); React.useEffect(() => onChange(value), [value]);
React.useEffect(() => setValue(defaultState), [defaultState]);
return (
<div className={cn('relative', className)}>
<div
className={cn(
'text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all',
label == '' && 'h-0 mb-0',
)}
>
{label}
</div>
<div className="relative">
<input
className={cn(
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
type == 'password' ? 'h-[40px]' : 'h-[36px]',
)}
value={value}
name={name}
autoComplete={autocomplete}
type={
type == 'password'
? visible
? 'text'
: 'password'
: type
}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) onKeyDown(e);
}}
/>
{type == 'password' && (
<img
src={visible ? eyeOpen : eyeClosed}
className="w-[24px] h-[24px] cursor-pointer right-[16px] top-[8px] absolute"
onClick={() => {
setVIsible(!visible);
}}
/>
)}
</div>
return ( <div
<div className={cn( className={cn(
"relative", 'text-liquid-red text-[14px] h-[18px] text-right mt-[5px]',
className error == '' && 'h-0 mt-0',
)}> )}
<div className={cn("text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all", >
label == "" && "h-0 mb-0" {error}
)}> </div>
{label} </div>
</div> );
<div className="relative">
<input
className={cn(
"bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light",
type == "password" ? "h-[40px]" : "h-[36px]"
)}
name={name}
autoComplete={autocomplete}
type={type == "password" ? (visible ? "text" : "password") : type}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
}} />
{
type == "password" &&
<img src={visible ? eyeOpen : eyeClosed} className="w-[24px] h-[24px] cursor-pointer right-[16px] top-[8px] absolute" onClick={() => {
setVIsible(!visible);
}}/>
}
</div>
<div className={cn("text-liquid-red text-[14px] h-[18px] text-right mt-[5px]",
error == "" && "h-0 mt-0"
)}>
{error}
</div>
</div>
);
}; };

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../../lib/cn';
import { useClickOutside } from '../../hooks/useClickOutside';
type ModalBackdrop = 'opaque' | 'blur';
interface ModalProps {
className?: string;
children?: React.ReactNode;
backdrop?: ModalBackdrop;
open: boolean;
defaultOpen?: boolean;
onOpenChange: (state: boolean) => void;
}
const modalbgVariants = {
closed: { opacity: 0 },
open: { opacity: 1 },
};
const modalVariants = {
closed: { opacity: 0, scale: 0.9 },
open: { opacity: 1, scale: 1 },
};
export const Modal: React.FC<ModalProps> = ({
children,
open,
backdrop,
className,
onOpenChange,
}) => {
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => {
onOpenChange(false);
});
return (
<div>
<AnimatePresence>
{open && (
<motion.div
initial={modalbgVariants.closed}
animate={modalbgVariants.open}
exit={modalbgVariants.closed}
transition={{ duration: 0.15 }}
className={cn(
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50',
backdrop == 'blur' && open && 'backdrop-blur-sm',
backdrop == 'opaque' &&
open &&
'bg-[#00000055] pointer-events-none',
)}
></motion.div>
)}
</AnimatePresence>
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
<AnimatePresence>
{open && (
<motion.div
ref={ref}
className={cn(
'h-fit w-fit rounded-md pointer-events-auto',
className,
)}
initial={modalVariants.closed}
animate={modalVariants.open}
exit={modalVariants.closed}
transition={{ duration: 0.15 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};

View File

@@ -0,0 +1,12 @@
// src/routes/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAppSelector } from '../../redux/hooks';
export default function ProtectedRoute() {
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
if (!isAuthenticated) {
return <Navigate to="/home/login" replace />;
}
return <Outlet />;
}

View File

@@ -1,187 +1,191 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
/* Варианты размера контейнера */ /* Варианты размера контейнера */
const sizeVariants = { const sizeVariants = {
sm: "h-6 w-10", sm: 'h-6 w-10',
md: "h-7 w-12", md: 'h-7 w-12',
lg: "h-8 w-14", lg: 'h-8 w-14',
}; };
/* Варианты для скользящего шарика */ /* Варианты для скользящего шарика */
const switchVariants = { const switchVariants = {
size: { size: {
sm: "h-4 w-4", sm: 'h-4 w-4',
md: "h-5 w-5", md: 'h-5 w-5',
lg: "h-6 w-6", lg: 'h-6 w-6',
}, },
activeSize: { activeSize: {
sm: "group-active:w-5", sm: 'group-active:w-5',
md: "group-active:w-6", md: 'group-active:w-6',
lg: "group-active:w-7", lg: 'group-active:w-7',
}, },
iconSize: { iconSize: {
sm: "h-3 w-3", sm: 'h-3 w-3',
md: "h-[0.875rem] w-[0.875rem]", md: 'h-[0.875rem] w-[0.875rem]',
lg: "h-4 w-4", lg: 'h-4 w-4',
}, },
}; };
const colorsVariants = { const colorsVariants = {
default: "bg-default", default: 'bg-default',
primary: "bg-liquid-brightmain", primary: 'bg-liquid-brightmain',
secondary: "bg-liquid-darkmain", secondary: 'bg-liquid-darkmain',
success: "bg-liquid-green", success: 'bg-liquid-green',
warning: "bg-liquid-orange", warning: 'bg-liquid-orange',
danger: "bg-liquid-red", danger: 'bg-liquid-red',
}; };
const focuseOutlineVariants = { const focuseOutlineVariants = {
default: "[&:focus-visible+*]:outline-default", default: '[&:focus-visible+*]:outline-default',
primary: "[&:focus-visible+*]:outline-liquid-brightmain", primary: '[&:focus-visible+*]:outline-liquid-brightmain',
secondary: "[&:focus-visible+*]:outline-liquid-darkmain", secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
success: "[&:focus-visible+*]:outline-liquid-green", success: '[&:focus-visible+*]:outline-liquid-green',
warning: "[&:focus-visible+*]:outline-liquid-orange", warning: '[&:focus-visible+*]:outline-liquid-orange',
danger: "[&:focus-visible+*]:outline-liquid-red", danger: '[&:focus-visible+*]:outline-liquid-red',
}; };
/** /**
* Иконка солнца * Иконка солнца
*/ */
const sun = ( const sun = (
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M6 9.5C7.933 9.5 9.5 7.933 9.5 6C9.5 4.067 7.933 2.5 6 2.5C4.067 2.5 2.5 4.067 2.5 6C2.5 7.933 4.067 9.5 6 9.5Z" d="M6 9.5C7.933 9.5 9.5 7.933 9.5 6C9.5 4.067 7.933 2.5 6 2.5C4.067 2.5 2.5 4.067 2.5 6C2.5 7.933 4.067 9.5 6 9.5Z"
fill="#292D32" fill="#292D32"
/> />
<path <path
d="M6 11.48C5.725 11.48 5.5 11.275 5.5 11V10.96C5.5 10.685 5.725 10.46 6 10.46C6.275 10.46 6.5 10.685 6.5 10.96C6.5 11.235 6.275 11.48 6 11.48ZM9.57 10.07C9.44 10.07 9.315 10.02 9.215 9.925L9.15 9.86C8.955 9.665 8.955 9.35 9.15 9.155C9.345 8.96 9.66 8.96 9.855 9.155L9.92 9.22C10.115 9.415 10.115 9.73 9.92 9.925C9.825 10.02 9.7 10.07 9.57 10.07ZM2.43 10.07C2.3 10.07 2.175 10.02 2.075 9.925C1.88 9.73 1.88 9.415 2.075 9.22L2.14 9.155C2.335 8.96 2.65 8.96 2.845 9.155C3.04 9.35 3.04 9.665 2.845 9.86L2.78 9.925C2.685 10.02 2.555 10.07 2.43 10.07ZM11 6.5H10.96C10.685 6.5 10.46 6.275 10.46 6C10.46 5.725 10.685 5.5 10.96 5.5C11.235 5.5 11.48 5.725 11.48 6C11.48 6.275 11.275 6.5 11 6.5ZM1.04 6.5H1C0.725 6.5 0.5 6.275 0.5 6C0.5 5.725 0.725 5.5 1 5.5C1.275 5.5 1.52 5.725 1.52 6C1.52 6.275 1.315 6.5 1.04 6.5ZM9.505 2.995C9.375 2.995 9.25 2.945 9.15 2.85C8.955 2.655 8.955 2.34 9.15 2.145L9.215 2.08C9.41 1.885 9.725 1.885 9.92 2.08C10.115 2.275 10.115 2.59 9.92 2.785L9.855 2.85C9.76 2.945 9.635 2.995 9.505 2.995ZM2.495 2.995C2.365 2.995 2.24 2.945 2.14 2.85L2.075 2.78C1.88 2.585 1.88 2.27 2.075 2.075C2.27 1.88 2.585 1.88 2.78 2.075L2.845 2.14C3.04 2.335 3.04 2.65 2.845 2.845C2.75 2.945 2.62 2.995 2.495 2.995ZM6 1.52C5.725 1.52 5.5 1.315 5.5 1.04V1C5.5 0.725 5.725 0.5 6 0.5C6.275 0.5 6.5 0.725 6.5 1C6.5 1.275 6.275 1.52 6 1.52Z" d="M6 11.48C5.725 11.48 5.5 11.275 5.5 11V10.96C5.5 10.685 5.725 10.46 6 10.46C6.275 10.46 6.5 10.685 6.5 10.96C6.5 11.235 6.275 11.48 6 11.48ZM9.57 10.07C9.44 10.07 9.315 10.02 9.215 9.925L9.15 9.86C8.955 9.665 8.955 9.35 9.15 9.155C9.345 8.96 9.66 8.96 9.855 9.155L9.92 9.22C10.115 9.415 10.115 9.73 9.92 9.925C9.825 10.02 9.7 10.07 9.57 10.07ZM2.43 10.07C2.3 10.07 2.175 10.02 2.075 9.925C1.88 9.73 1.88 9.415 2.075 9.22L2.14 9.155C2.335 8.96 2.65 8.96 2.845 9.155C3.04 9.35 3.04 9.665 2.845 9.86L2.78 9.925C2.685 10.02 2.555 10.07 2.43 10.07ZM11 6.5H10.96C10.685 6.5 10.46 6.275 10.46 6C10.46 5.725 10.685 5.5 10.96 5.5C11.235 5.5 11.48 5.725 11.48 6C11.48 6.275 11.275 6.5 11 6.5ZM1.04 6.5H1C0.725 6.5 0.5 6.275 0.5 6C0.5 5.725 0.725 5.5 1 5.5C1.275 5.5 1.52 5.725 1.52 6C1.52 6.275 1.315 6.5 1.04 6.5ZM9.505 2.995C9.375 2.995 9.25 2.945 9.15 2.85C8.955 2.655 8.955 2.34 9.15 2.145L9.215 2.08C9.41 1.885 9.725 1.885 9.92 2.08C10.115 2.275 10.115 2.59 9.92 2.785L9.855 2.85C9.76 2.945 9.635 2.995 9.505 2.995ZM2.495 2.995C2.365 2.995 2.24 2.945 2.14 2.85L2.075 2.78C1.88 2.585 1.88 2.27 2.075 2.075C2.27 1.88 2.585 1.88 2.78 2.075L2.845 2.14C3.04 2.335 3.04 2.65 2.845 2.845C2.75 2.945 2.62 2.995 2.495 2.995ZM6 1.52C5.725 1.52 5.5 1.315 5.5 1.04V1C5.5 0.725 5.725 0.5 6 0.5C6.275 0.5 6.5 0.725 6.5 1C6.5 1.275 6.275 1.52 6 1.52Z"
fill="#292D32" fill="#292D32"
/> />
</svg> </svg>
); );
/** /**
* Иконка луны * Иконка луны
*/ */
const moon = ( const moon = (
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M10.765 7.965C10.685 7.83 10.46 7.62 9.89999 7.72C9.58999 7.775 9.27499 7.8 8.95999 7.785C7.79499 7.735 6.73999 7.2 6.00499 6.375C5.35499 5.65 4.95499 4.705 4.94999 3.685C4.94999 3.115 5.05999 2.565 5.28499 2.045C5.50499 1.54 5.34999 1.275 5.23999 1.165C5.12499 1.05 4.85499 0.890001 4.32499 1.11C2.27999 1.97 1.01499 4.02 1.16499 6.215C1.31499 8.28 2.76499 10.045 4.68499 10.71C5.14499 10.87 5.62999 10.965 6.12999 10.985C6.20999 10.99 6.28999 10.995 6.36999 10.995C8.04499 10.995 9.61499 10.205 10.605 8.86C10.94 8.395 10.85 8.1 10.765 7.965Z" d="M10.765 7.965C10.685 7.83 10.46 7.62 9.89999 7.72C9.58999 7.775 9.27499 7.8 8.95999 7.785C7.79499 7.735 6.73999 7.2 6.00499 6.375C5.35499 5.65 4.95499 4.705 4.94999 3.685C4.94999 3.115 5.05999 2.565 5.28499 2.045C5.50499 1.54 5.34999 1.275 5.23999 1.165C5.12499 1.05 4.85499 0.890001 4.32499 1.11C2.27999 1.97 1.01499 4.02 1.16499 6.215C1.31499 8.28 2.76499 10.045 4.68499 10.71C5.14499 10.87 5.62999 10.965 6.12999 10.985C6.20999 10.99 6.28999 10.995 6.36999 10.995C8.04499 10.995 9.61499 10.205 10.605 8.86C10.94 8.395 10.85 8.1 10.765 7.965Z"
fill="#292D32" fill="#292D32"
/> />
</svg> </svg>
); );
interface SwitchProps { interface SwitchProps {
size?: "sm" | "md" | "lg"; size?: 'sm' | 'md' | 'lg';
disabled?: boolean; disabled?: boolean;
color?: color?:
| "default" | 'default'
| "primary" | 'primary'
| "secondary" | 'secondary'
| "success" | 'success'
| "warning" | 'warning'
| "danger"; | 'danger';
label?: string; label?: string;
variant?: "default" | "label" | "icon" | "theme"; variant?: 'default' | 'label' | 'icon' | 'theme';
className?: string; className?: string;
defaultState?: boolean; defaultState?: boolean;
onChange: (state: boolean) => void; onChange: (state: boolean) => void;
} }
export const Switch: React.FC<SwitchProps> = ({ export const Switch: React.FC<SwitchProps> = ({
size = "sm", size = 'sm',
disabled = false, disabled = false,
color = "primary", color = 'primary',
label = "", label = '',
variant = "default", variant = 'default',
className, className,
onChange, onChange,
defaultState = false, defaultState = false,
}) => { }) => {
const [active, setActive] = React.useState<boolean>(defaultState); const [active, setActive] = React.useState<boolean>(defaultState);
React.useEffect(() => onChange(active), [active]); React.useEffect(() => onChange(active), [active]);
return ( return (
<label <label
className={cn( className={cn(
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2", variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
"grid relative cursor-pointer p-2 select-none group", 'grid relative cursor-pointer p-2 select-none group',
disabled && "pointer-events-none opacity-50", disabled && 'pointer-events-none opacity-50',
className className,
)} )}
>
{/* Основной контейнер, */}
<div
className={cn(
" flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full",
sizeVariants[size],
active ? colorsVariants[color] : "bg-default-200"
)}
>
{/* Скрытый checkbox */}
<input
className={cn(
"absolute opacity-0 -z-10 h-0 w-0",
focuseOutlineVariants[color]
)}
disabled={disabled}
type="checkbox"
onChange={() => {
setActive(!active);
}}
/>
<div
className={cn(
"absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-300 rounded-full",
sizeVariants[size]
)}
></div>
{/* Шарик */}
<span
className={cn(
"bg-white rounded-full absolute transition-all duration-300 m-1 flex items-center justify-center",
switchVariants.size[size],
switchVariants.activeSize[size],
active
? "right-[0%]"
: "right-[calc(50%-0.25rem)] group-active:right-[calc(50%-0.5rem)]"
)}
> >
{variant == "theme" && ( {/* Основной контейнер, */}
<> <div
<div
className={cn( className={cn(
"absolute transition-all duration-300", ' flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full',
switchVariants.iconSize[size], sizeVariants[size],
active ? "opacity-100 scale-100" : "opacity-0 scale-50" active ? colorsVariants[color] : 'bg-default-200',
)} )}
> >
{moon} {/* Скрытый checkbox */}
</div> <input
<div className={cn(
className={cn( 'absolute opacity-0 -z-10 h-0 w-0',
"absolute transition-all duration-300", focuseOutlineVariants[color],
switchVariants.iconSize[size], )}
active ? "opacity-0 scale-50" : "opacity-100 scale-100" disabled={disabled}
)} type="checkbox"
> onChange={() => {
{sun} setActive(!active);
</div> }}
</> />
)}
</span>
</div>
{variant == "label" && ( <div
<div className="select-none text-layout-foreground transition-all duration-200"> className={cn(
{label} 'absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-300 rounded-full',
</div> sizeVariants[size],
)} )}
</label> ></div>
);
{/* Шарик */}
<span
className={cn(
'bg-white rounded-full absolute transition-all duration-300 m-1 flex items-center justify-center',
switchVariants.size[size],
switchVariants.activeSize[size],
active
? 'right-[0%]'
: 'right-[calc(50%-0.25rem)] group-active:right-[calc(50%-0.5rem)]',
)}
>
{variant == 'theme' && (
<>
<div
className={cn(
'absolute transition-all duration-300',
switchVariants.iconSize[size],
active
? 'opacity-100 scale-100'
: 'opacity-0 scale-50',
)}
>
{moon}
</div>
<div
className={cn(
'absolute transition-all duration-300',
switchVariants.iconSize[size],
active
? 'opacity-0 scale-50'
: 'opacity-100 scale-100',
)}
>
{sun}
</div>
</>
)}
</span>
</div>
{variant == 'label' && (
<div className="select-none text-layout-foreground transition-all duration-200">
{label}
</div>
)}
</label>
);
}; };

View File

@@ -1,14 +1,14 @@
export default { export default {
liquid: { liquid: {
brightmain: "var(--color-liquid-brightmain)", brightmain: 'var(--color-liquid-brightmain)',
darkmain: "var(--color-liquid-darkmain)", darkmain: 'var(--color-liquid-darkmain)',
darker: "var(--color-liquid-darker)", darker: 'var(--color-liquid-darker)',
background: "var(--color-liquid-background)", background: 'var(--color-liquid-background)',
lighter: "var(--color-liquid-lighter)", lighter: 'var(--color-liquid-lighter)',
white: "var(--color-liquid-white)", white: 'var(--color-liquid-white)',
red: "var(--color-liquid-red)", red: 'var(--color-liquid-red)',
green: "var(--color-liquid-green)", green: 'var(--color-liquid-green)',
light: "var(--color-liquid-light)", light: 'var(--color-liquid-light)',
orange: "var(--color-liquid-orange)", orange: 'var(--color-liquid-orange)',
} },
}; };

View File

@@ -1,18 +1,21 @@
import React from "react"; import React from 'react';
export const useClickOutside = (ref: React.RefObject<any>, onClickOutside: () => void) => { export const useClickOutside = (
React.useEffect(() => { ref: React.RefObject<any>,
const handleClickOutside = (event: MouseEvent | TouchEvent) => { onClickOutside: () => void,
if (ref.current && !ref.current.contains(event.target)) { ) => {
onClickOutside(); React.useEffect(() => {
} const handleClickOutside = (event: MouseEvent | TouchEvent) => {
} if (ref.current && !ref.current.contains(event.target)) {
onClickOutside();
}
};
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
document.addEventListener("touchstart", handleClickOutside); document.addEventListener('touchstart', handleClickOutside);
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside); document.removeEventListener('touchstart', handleClickOutside);
} };
}, [ref, onClickOutside]); }, [ref, onClickOutside]);
} };

7
src/hooks/useQuery.ts Normal file
View File

@@ -0,0 +1,7 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
export function useQuery() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}

View File

@@ -1,6 +1,6 @@
import { ClassValue, clsx } from "clsx"; import { ClassValue, clsx } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }

View File

@@ -1,16 +1,16 @@
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client';
import App from "./App.tsx"; import App from './App.tsx';
import "./styles/index.css"; import './styles/index.css';
import "./styles/palette/theme-dark.css"; import './styles/palette/theme-dark.css';
import "./styles/palette/theme-light.css"; import './styles/palette/theme-light.css';
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from 'react-router-dom';
import { Provider } from "react-redux"; import { Provider } from 'react-redux';
import { store } from "./redux/store"; import { store } from './redux/store';
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById('root')!).render(
<BrowserRouter> <BrowserRouter>
<Provider store={store}> <Provider store={store}>
<App /> <App />
</Provider> </Provider>
</BrowserRouter> </BrowserRouter>,
); );

73
src/pages/Article.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { useParams, Navigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import Header from '../views/article/Header';
import { useEffect } from 'react';
import { fetchArticleById } from '../redux/slices/articles';
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
import { useQuery } from '../hooks/useQuery';
const Article = () => {
// Получаем параметры из URL
const { articleId } = useParams<{ articleId: string }>();
const articleIdNumber = Number(articleId);
const query = useQuery();
const back = query.get('back') ?? undefined;
if (!articleId || isNaN(articleIdNumber)) {
if (back) return <Navigate to={back} replace />;
return <Navigate to="/home" replace />;
}
const dispatch = useAppDispatch();
const article = useAppSelector((state) => state.articles.currentArticle);
const status = useAppSelector((state) => state.articles.statuses.fetchById);
useEffect(() => {
dispatch(fetchArticleById(articleIdNumber));
}, [articleIdNumber]);
return (
<div className="grid grid-cols-[1fr,250px] divide-x-[1px] divide-liquid-lighter">
<div className="h-screen grid grid-rows-[60px,1fr] relative">
<div className="">
<Header articleId={articleIdNumber} back={back} />
</div>
{status == 'loading' || !article ? (
<div>Загрузка...</div>
) : (
<div className="h-full min-h-0 gap-[20px] overflow-y-scroll flex flex-col medium-scrollbar ">
<div>
<div className="text-[40px] font-bold leading-[50px] text-liquid-white">
{article.name}
</div>
<div className="text-[18px] font-bold leading-[23px] text-liquid-light">
#{article.id}
</div>
</div>
{article.tags.length && (
<div className="flex gap-[10px]">
{article.tags.map((v, i) => (
<div
key={i}
className="py-[8px] px-[16px] rounded-[20px] text-liquid-light bg-liquid-lighter text-[14px] font-normal w-fit"
>
{v}
</div>
))}
</div>
)}
<MarkdownPreview
content={article!.content}
className="bg-transparent"
/>
</div>
)}
</div>
<div className=""></div>
</div>
);
};
export default Article;

233
src/pages/ArticleEditor.tsx Normal file
View File

@@ -0,0 +1,233 @@
import { useNavigate } from 'react-router-dom';
import Header from '../views/articleeditor/Header';
import MarkdownEditor from '../views/articleeditor/Editor';
import { useEffect, useState } from 'react';
import { PrimaryButton } from '../components/button/PrimaryButton';
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
import { Input } from '../components/input/Input';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import {
createArticle,
deleteArticle,
fetchArticleById,
setArticlesStatus,
updateArticle,
} from '../redux/slices/articles';
import { useQuery } from '../hooks/useQuery';
import { ReverseButton } from '../components/button/ReverseButton';
const ArticleEditor = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const query = useQuery();
const back = query.get('back') ?? undefined;
const articleId = Number(query.get('articleId') ?? undefined);
const article = useAppSelector((state) => state.articles.currentArticle);
const refactor = articleId != undefined && !isNaN(articleId);
const [code, setCode] = useState<string>(article?.content || '');
const [name, setName] = useState<string>(article?.name || '');
const [tagInput, setTagInput] = useState<string>('');
const [tags, setTags] = useState<string[]>(article?.tags || []);
const [activeEditor, setActiveEditor] = useState<boolean>(false);
const statusCreate = useAppSelector(
(state) => state.articles.statuses.create,
);
const statusUpdate = useAppSelector(
(state) => state.articles.statuses.update,
);
const statusDelete = useAppSelector(
(state) => state.articles.statuses.delete,
);
const addTag = () => {
const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]);
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove));
};
useEffect(() => {
if (statusCreate == 'successful') {
dispatch(setArticlesStatus({ key: 'create', status: 'idle' }));
navigate(back ? back : '/home/articles');
}
}, [statusCreate]);
useEffect(() => {
if (statusDelete == 'successful') {
dispatch(setArticlesStatus({ key: 'delete', status: 'idle' }));
navigate(back ? back : '/home/articles');
}
}, [statusDelete]);
useEffect(() => {
if (statusUpdate == 'successful') {
dispatch(setArticlesStatus({ key: 'update', status: 'idle' }));
navigate(back ? back : '/home/articles');
}
}, [statusUpdate]);
useEffect(() => {
if (articleId) {
dispatch(fetchArticleById(articleId));
}
}, [articleId]);
useEffect(() => {
if (article && refactor) {
setCode(article?.content || '');
setName(article?.name || '');
setTags(article?.tags || []);
}
}, [article]);
return (
<div className="h-screen grid grid-rows-[60px,1fr]">
{activeEditor ? (
<Header
backClick={() => {
setActiveEditor(false);
}}
/>
) : (
<Header
backClick={() => navigate(back ? back : '/home/articles')}
/>
)}
{activeEditor ? (
<MarkdownEditor onChange={setCode} defaultValue={code} />
) : (
<div className="text-liquid-white">
<div className="text-[40px] font-bold">
{refactor
? `Редактирование статьи: \"${article?.name}\"`
: 'Создание статьи'}
</div>
<div>
{refactor ? (
<div className="flex gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(
updateArticle({
articleId,
name,
tags,
content: code,
}),
);
}}
text="Обновить"
className="mt-[20px]"
disabled={statusUpdate == 'loading'}
/>
<ReverseButton
onClick={() => {
dispatch(deleteArticle(articleId));
}}
color="error"
text="Удалить"
className="mt-[20px]"
disabled={statusDelete == 'loading'}
/>
</div>
) : (
<PrimaryButton
onClick={() => {
dispatch(
createArticle({
name,
tags,
content: code,
}),
);
}}
text="Опубликовать"
className="mt-[20px]"
disabled={statusCreate == 'loading'}
/>
)}
</div>
<Input
defaultState={name}
name="articleName"
autocomplete="articleName"
className="mt-[20px] max-w-[600px]"
type="text"
label="Название"
onChange={(v) => {
setName(v);
}}
placeholder="Новая статья"
/>
{/* Блок для тегов */}
<div className="mt-[20px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
name="articleTag"
autocomplete="articleTag"
className="mt-[20px] max-w-[600px]"
type="text"
label="Теги"
onChange={(v) => {
setTagInput(v);
}}
defaultState={tagInput}
placeholder="arrays"
onKeyDown={(e) => {
console.log(e.key);
if (e.key == 'Enter') addTag();
}}
/>
<PrimaryButton
onClick={addTag}
text="Добавить"
className="h-[40px] w-[140px]"
/>
</div>
<div className="flex flex-wrap gap-[10px] mt-2">
{tags.map((tag) => (
<div
key={tag}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
>
<span>{tag}</span>
<button
onClick={() => removeTag(tag)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div>
))}
</div>
</div>
<PrimaryButton
onClick={() => setActiveEditor(true)}
text="Редактировать текст"
className="mt-[20px]"
/>
<MarkdownPreview
content={code}
className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]"
/>
</div>
)}
</div>
);
};
export default ArticleEditor;

View File

@@ -1,51 +1,82 @@
// import React from "react"; // import React from "react";
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from 'react-router-dom';
import Login from "../views/home/auth/Login"; import Login from '../views/home/auth/Login';
import Register from "../views/home/auth/Register"; import Register from '../views/home/auth/Register';
import Menu from "../views/home/menu/Menu"; import Menu from '../views/home/menu/Menu';
import { useAppDispatch, useAppSelector } from "../redux/hooks"; import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { useEffect } from "react"; import { useEffect } from 'react';
import { fetchWhoAmI, logout } from "../redux/slices/auth"; import { fetchWhoAmI, logout } from '../redux/slices/auth';
import Missions from "../views/home/missions/Missions"; import Missions from '../views/home/missions/Missions';
import Articles from "../views/home/articles/Articles"; import Articles from '../views/home/articles/Articles';
import Groups from "../views/home/groups/Groups"; import Groups from '../views/home/groups/Groups';
import Contests from "../views/home/contests/Contests"; import Contests from '../views/home/contests/Contests';
import { PrimaryButton } from "../components/button/PrimaryButton"; import { PrimaryButton } from '../components/button/PrimaryButton';
import Group from '../views/home/groups/Group';
import Contest from '../views/home/contest/Contest';
import Account from '../views/home/account/Account';
import ProtectedRoute from '../components/router/ProtectedRoute';
const Home = () => { const Home = () => {
const name = useAppSelector((state) => state.auth.username); const name = useAppSelector((state) => state.auth.username);
const jwt = useAppSelector((state) => state.auth.jwt); const jwt = useAppSelector((state) => state.auth.jwt);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
dispatch(fetchWhoAmI()); dispatch(fetchWhoAmI());
}, [jwt]) }, [jwt]);
return (
<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="h-screen">
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="account/*" element={<Account />} />
</Route>
return ( <Route path="login" element={<Login />} />
<div className="w-full bg-liquid-background grid grid-cols-[250px,1fr,250px] divide-x-[1px] divide-liquid-lighter"> <Route path="register" element={<Register />} />
<div className="min-h-screen"> <Route path="missions/*" element={<Missions />} />
<Menu /> <Route path="articles/*" element={<Articles />} />
</div> <Route path="group/:groupId" element={<Group />} />
<div className=""> <Route path="groups/*" element={<Groups />} />
<Routes> <Route path="contests/*" element={<Contests />} />
<Route path="login" element={<Login />} /> <Route path="contest/:contestId/*" element={<Contest />} />
<Route path="account" element={<Login />} /> <Route
<Route path="register" element={<Register />} /> path="*"
<Route path="missions/*" element={<Missions/>} /> element={
<Route path="articles/*" element={<Articles/>} /> <>
<Route path="groups/*" element={<Groups/>} /> <p>{jwt}</p>
<Route path="contests/*" element={<Contests/>} /> <PrimaryButton
<Route path="*" element={<>{name}<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton></>} /> onClick={() => {
</Routes> if (jwt)
</div> navigator.clipboard.writeText(jwt);
{ }}
<Routes> text="скопировать токен"
<Route path="articles/*" element={<div></div>} /> className="pt-[20px]"
</Routes> />
} <p className="py-[20px]">{name}</p>
</div> <PrimaryButton
); onClick={() => {
dispatch(logout());
}}
>
выйти
</PrimaryButton>
</>
}
/>
</Routes>
</div>
{
<Routes>
<Route path="articles/*" element={<div></div>} />
</Routes>
}
</div>
);
}; };
export default Home; export default Home;

View File

@@ -1,6 +1,6 @@
import { useParams, Navigate } from 'react-router-dom'; import { useParams, Navigate } from 'react-router-dom';
import CodeEditor from '../views/mission/codeeditor/CodeEditor'; import CodeEditor from '../views/mission/codeeditor/CodeEditor';
import Statement, { StatementData } from '../views/mission/statement/Statement'; import Statement from '../views/mission/statement/Statement';
import { PrimaryButton } from '../components/button/PrimaryButton'; import { PrimaryButton } from '../components/button/PrimaryButton';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../redux/hooks'; import { useAppDispatch, useAppSelector } from '../redux/hooks';
@@ -8,181 +8,203 @@ import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
import { fetchMissionById } from '../redux/slices/missions'; import { fetchMissionById } from '../redux/slices/missions';
import Header from '../views/mission/statement/Header'; import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions'; import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
import { useQuery } from '../hooks/useQuery';
const Mission = () => { const Mission = () => {
const dispatch = useAppDispatch();
const dispatch = useAppDispatch(); // Получаем параметры из URL
const { missionId } = useParams<{ missionId: string }>();
const mission = useAppSelector((state) => state.missions.currentMission);
const missionIdNumber = Number(missionId);
// Получаем параметры из URL const query = useQuery();
const { missionId } = useParams<{ missionId: string }>(); const back = query.get('back') ?? undefined;
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>(""); if (!missionId || isNaN(missionIdNumber)) {
const [language, setLanguage] = useState<string>(""); if (back) return <Navigate to={back} replace />;
return <Navigate to="/home" replace />;
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]);
const [code, setCode] = useState<string>('');
const [language, setLanguage] = useState<string>('');
if (!mission || !mission.statements || mission.statements.length === 0) { const pollingRef = useRef<number | null>(null);
return <div>Загрузка...</div>; const submissions = useAppSelector(
} (state) => state.submin.submitsById[missionIdNumber] || [],
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"
); );
const submissionsRef = useRef(submissions);
// 2. Берём первый statement с форматом Html и языком russian const startPolling = () => {
const htmlStatement = mission.statements.find( if (pollingRef.current) return;
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html"
);
if (!latexStatement) throw new Error("Не найден блок Latex на русском"); pollingRef.current = setInterval(async () => {
if (!htmlStatement) throw new Error("Не найден блок Html на русском"); dispatch(fetchMySubmitsByMission(missionIdNumber));
// 3. Парсим данные из problem-properties.json const hasWaiting = submissionsRef.current.some(
const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]); (s: any) =>
s.solution.status == 'Waiting' ||
statementData = { s.solution.testerState === 'Waiting' ||
id: missionIdNumber, s.solution.status === 'Compiling' ||
legend: statementTexts.legend, s.solution.testerState === 'Compiling',
timeLimit: statementTexts.timeLimit, );
output: statementTexts.output, if (!hasWaiting) {
input: statementTexts.input, // Всё проверено — стоп
sampleTests: statementTexts.sampleTests, if (pollingRef.current) {
name: statementTexts.name, clearInterval(pollingRef.current);
memoryLimit: statementTexts.memoryLimit, pollingRef.current = null;
tags: mission.tags, }
notes: statementTexts.notes, }
html: htmlStatement.statementTexts["problem.html"], }, 5000); // 10 секунд
mediaFiles: latexStatement.mediaFiles
}; };
} catch (err) {
console.error("Ошибка парсинга statementTexts:", err);
}
useEffect(() => {
dispatch(fetchMissionById(missionIdNumber));
dispatch(fetchMySubmitsByMission(missionIdNumber));
}, [missionIdNumber]);
useEffect(() => {}, [submissions]);
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, []);
const startPolling = () => { useEffect(() => {
if (pollingRef.current) submissionsRef.current = submissions;
return;
pollingRef.current = setInterval(async () => { if (submissions.length) {
dispatch(fetchMySubmitsByMission(missionIdNumber)); const hasWaiting = submissions.some(
(s) =>
s.solution.status === 'Waiting' ||
s.solution.testerState === 'Waiting' ||
s.solution.status === 'Compiling' ||
s.solution.testerState === 'Compiling',
);
const hasWaiting = submissions.some( if (hasWaiting) {
(s: any) => s.solution.status == "Waiting" || s.solution.testerState === "Waiting" startPolling();
); }
if (!hasWaiting) {
// Всё проверено — стоп
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
} }
} }, [submissions]);
}, 5000); // 10 секунд
};
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[];
}
return ( let statementData: StatementData = { id: mission.id };
<div className="h-screen grid grid-rows-[60px,1fr]"> try {
<div className=""> // 1. Берём первый statement с форматом Latex и языком russian
<Header missionId={missionIdNumber} /> const latexStatement = mission.statements.find(
</div> (stmt: any) =>
stmt && stmt.language === 'russian' && stmt.format === 'Latex',
);
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]"> // 2. Берём первый statement с форматом Html и языком russian
<div className="overflow-y-auto min-h-0 overflow-hidden"> const htmlStatement = mission.statements.find(
<Statement (stmt: any) =>
{...statementData} 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);
}
return (
<div className="h-screen grid grid-rows-[60px,1fr]">
<div className="">
<Header missionId={missionIdNumber} back={back} />
</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> </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; export default Mission;

View File

View File

@@ -0,0 +1,298 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
type Status = 'idle' | 'loading' | 'successful' | 'failed';
export interface Article {
id: number;
authorId: number;
name: string;
content: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
interface ArticlesState {
articles: Article[];
currentArticle?: Article;
hasNextPage: boolean;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchAll: Status;
fetchById: Status;
};
error: string | null;
}
const initialState: ArticlesState = {
articles: [],
currentArticle: undefined,
hasNextPage: false,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchAll: 'idle',
fetchById: 'idle',
},
error: null,
};
// ─── Async Thunks ─────────────────────────────────────
// POST /articles
export const createArticle = createAsyncThunk(
'articles/createArticle',
async (
{
name,
content,
tags,
}: { name: string; content: string; tags: string[] },
{ rejectWithValue },
) => {
try {
const response = await axios.post('/articles', {
name,
content,
tags,
});
return response.data as Article;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при создании статьи',
);
}
},
);
// PUT /articles/{articleId}
export const updateArticle = createAsyncThunk(
'articles/updateArticle',
async (
{
articleId,
name,
content,
tags,
}: { articleId: number; name: string; content: string; tags: string[] },
{ rejectWithValue },
) => {
try {
const response = await axios.put(`/articles/${articleId}`, {
name,
content,
tags,
});
return response.data as Article;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при обновлении статьи',
);
}
},
);
// DELETE /articles/{articleId}
export const deleteArticle = createAsyncThunk(
'articles/deleteArticle',
async (articleId: number, { rejectWithValue }) => {
try {
await axios.delete(`/articles/${articleId}`);
return articleId;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении статьи',
);
}
},
);
// GET /articles
export const fetchArticles = createAsyncThunk(
'articles/fetchArticles',
async (
{
page = 0,
pageSize = 10,
tags,
}: { page?: number; pageSize?: number; tags?: string[] },
{ rejectWithValue },
) => {
try {
const params: any = { page, pageSize };
if (tags && tags.length > 0) params.tags = tags;
const response = await axios.get('/articles', { params });
return response.data as {
hasNextPage: boolean;
articles: Article[];
};
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статей',
);
}
},
);
// GET /articles/{articleId}
export const fetchArticleById = createAsyncThunk(
'articles/fetchArticleById',
async (articleId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/articles/${articleId}`);
return response.data as Article;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статьи',
);
}
},
);
// ─── Slice ────────────────────────────────────────────
const articlesSlice = createSlice({
name: 'articles',
initialState,
reducers: {
clearCurrentArticle: (state) => {
state.currentArticle = undefined;
},
setArticlesStatus: (
state,
action: PayloadAction<{
key: keyof ArticlesState['statuses'];
status: Status;
}>,
) => {
const { key, status } = action.payload;
state.statuses[key] = status;
},
},
extraReducers: (builder) => {
// ─── CREATE ARTICLE ───
builder.addCase(createArticle.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.create = 'successful';
state.articles.push(action.payload);
},
);
builder.addCase(
createArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
},
);
// ─── UPDATE ARTICLE ───
builder.addCase(updateArticle.pending, (state) => {
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.update = 'successful';
const index = state.articles.findIndex(
(a) => a.id === action.payload.id,
);
if (index !== -1) state.articles[index] = action.payload;
if (state.currentArticle?.id === action.payload.id)
state.currentArticle = action.payload;
},
);
builder.addCase(
updateArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.update = 'failed';
state.error = action.payload;
},
);
// ─── DELETE ARTICLE ───
builder.addCase(deleteArticle.pending, (state) => {
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteArticle.fulfilled,
(state, action: PayloadAction<number>) => {
state.statuses.delete = 'successful';
state.articles = state.articles.filter(
(a) => a.id !== action.payload,
);
if (state.currentArticle?.id === action.payload)
state.currentArticle = undefined;
},
);
builder.addCase(
deleteArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH ARTICLES ───
builder.addCase(fetchArticles.pending, (state) => {
state.statuses.fetchAll = 'loading';
state.error = null;
});
builder.addCase(
fetchArticles.fulfilled,
(
state,
action: PayloadAction<{
hasNextPage: boolean;
articles: Article[];
}>,
) => {
state.statuses.fetchAll = 'successful';
state.articles = action.payload.articles;
state.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(
fetchArticles.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchAll = 'failed';
state.error = action.payload;
},
);
// ─── FETCH ARTICLE BY ID ───
builder.addCase(fetchArticleById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
});
builder.addCase(
fetchArticleById.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.fetchById = 'successful';
state.currentArticle = action.payload;
},
);
builder.addCase(
fetchArticleById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
},
);
},
});
export const { clearCurrentArticle, setArticlesStatus } = articlesSlice.actions;
export const articlesReducer = articlesSlice.reducer;

View File

@@ -1,188 +1,276 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from "../../axios"; import axios from '../../axios';
// Типы данных // 🔹 Декодирование JWT
interface AuthState { function decodeJwt(token: string) {
jwt: string | null; const [, payload] = token.split('.');
refreshToken: string | null; const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
username: string | null; return JSON.parse(decodeURIComponent(escape(json)));
status: "idle" | "loading" | "successful" | "failed";
error: string | null;
} }
// Инициализация состояния // 🔹 Типы
interface AuthState {
jwt: string | null;
refreshToken: string | null;
username: string | null;
email: string | null;
id: string | null;
status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null;
}
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
const jwtFromStorage = localStorage.getItem('jwt');
const refreshTokenFromStorage = localStorage.getItem('refreshToken');
const initialState: AuthState = { const initialState: AuthState = {
jwt: null, jwt: jwtFromStorage || null,
refreshToken: null, refreshToken: refreshTokenFromStorage || null,
username: null, username: null,
status: "idle", email: null,
error: null, id: null,
status: 'idle',
error: null,
}; };
// AsyncThunk: Регистрация // Если токен есть, подставляем в axios и декодируем
if (jwtFromStorage) {
axios.defaults.headers.common['Authorization'] = `Bearer ${jwtFromStorage}`;
try {
const decoded = decodeJwt(jwtFromStorage);
initialState.username =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
] || null;
initialState.email =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
] || null;
initialState.id =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
] || null;
} catch {
localStorage.removeItem('jwt');
localStorage.removeItem('refreshToken');
delete axios.defaults.headers.common['Authorization'];
}
}
// 🔹 AsyncThunk-ы (login/register/refresh/whoami) остаются как были
export const registerUser = createAsyncThunk( export const registerUser = createAsyncThunk(
"auth/register", 'auth/register',
async ( async (
{ username, email, password }: { username: string; email: string; password: string }, {
{ rejectWithValue } username,
) => { email,
try { password,
const response = await axios.post("/authentication/register", { username, email, password }); }: { username: string; email: string; password: string },
return response.data; // { jwt, refreshToken } { rejectWithValue },
} catch (err: any) { ) => {
return rejectWithValue(err.response?.data?.message || "Registration failed"); try {
} const response = await axios.post('/authentication/register', {
} username,
); email,
password,
// AsyncThunk: Логин });
export const loginUser = createAsyncThunk( return response.data;
"auth/login", } catch (err: any) {
async ( return rejectWithValue(
{ username, password }: { username: string; password: string }, err.response?.data?.message || 'Registration failed',
{ rejectWithValue } );
) => { }
try {
const response = await axios.post("/authentication/login", { username, password });
return response.data; // { jwt, refreshToken }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Login failed");
}
}
);
// AsyncThunk: Обновление токена
export const refreshToken = createAsyncThunk(
"auth/refresh",
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
try {
const response = await axios.post("/authentication/refresh", { refreshToken });
return response.data; // { username }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Refresh token failed");
}
}
);
// AsyncThunk: Получение информации о пользователе
export const fetchWhoAmI = createAsyncThunk(
"auth/whoami",
async (_, { rejectWithValue }) => {
try {
const response = await axios.get("/authentication/whoami");
return response.data; // { username }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch user info");
}
}
);
// 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",
initialState,
reducers: {
logout: (state) => {
state.jwt = null;
state.refreshToken = null;
state.username = null;
state.status = "idle";
state.error = null;
localStorage.removeItem("jwt");
localStorage.removeItem("refreshToken");
delete axios.defaults.headers.common['Authorization'];
}, },
}, );
extraReducers: (builder) => {
// Регистрация
builder.addCase(registerUser.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
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";
state.error = action.payload;
});
// Логин export const loginUser = createAsyncThunk(
builder.addCase(loginUser.pending, (state) => { 'auth/login',
state.status = "loading"; async (
state.error = null; { username, password }: { username: string; password: string },
}); { rejectWithValue },
builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { ) => {
state.status = "successful"; try {
state.jwt = action.payload.jwt; const response = await axios.post('/authentication/login', {
state.refreshToken = action.payload.refreshToken; username,
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; password,
localStorage.setItem("jwt", action.payload.jwt); });
localStorage.setItem("refreshToken", action.payload.refreshToken); return response.data;
}); } catch (err: any) {
builder.addCase(loginUser.rejected, (state, action: PayloadAction<any>) => { return rejectWithValue(
state.status = "failed"; err.response?.data?.message || 'Login failed',
state.error = action.payload; );
}); }
},
);
// Обновление токена export const refreshToken = createAsyncThunk(
builder.addCase(refreshToken.pending, (state) => { 'auth/refresh',
state.status = "loading"; async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
state.error = null; try {
}); const response = await axios.post('/authentication/refresh', {
builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => { refreshToken,
state.status = "successful"; });
state.username = action.payload.username; return response.data;
}); } catch (err: any) {
builder.addCase(refreshToken.rejected, (state, action: PayloadAction<any>) => { return rejectWithValue(
state.status = "failed"; err.response?.data?.message || 'Refresh token failed',
state.error = action.payload; );
}); }
},
);
// Получение информации о пользователе export const fetchWhoAmI = createAsyncThunk(
builder.addCase(fetchWhoAmI.pending, (state) => { 'auth/whoami',
state.status = "loading"; async (_, { rejectWithValue }) => {
state.error = null; try {
}); const response = await axios.get('/authentication/whoami');
builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => { return response.data;
state.status = "successful"; } catch (err: any) {
state.username = action.payload.username; return rejectWithValue(
}); err.response?.data?.message || 'Failed to fetch user info',
builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction<any>) => { );
state.status = "failed"; }
state.error = action.payload; },
}); );
// Загрузка токенов из localStorage // 🔹 Slice
builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => { const authSlice = createSlice({
state.jwt = action.payload.jwt; name: 'auth',
state.refreshToken = action.payload.refreshToken; initialState,
if (action.payload.jwt) { reducers: {
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; logout: (state) => {
} state.jwt = null;
}); state.refreshToken = null;
}, state.username = null;
state.email = null;
state.id = null;
state.status = 'idle';
state.error = null;
localStorage.removeItem('jwt');
localStorage.removeItem('refreshToken');
delete axios.defaults.headers.common['Authorization'];
},
},
extraReducers: (builder) => {
// ----------------- Register -----------------
builder.addCase(registerUser.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(registerUser.fulfilled, (state, action) => {
state.status = 'successful';
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
const decoded = decodeJwt(action.payload.jwt);
state.username =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
] || null;
state.email =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
] || null;
state.id =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
] || null;
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) => {
state.status = 'failed';
state.error = action.payload as string;
});
// ----------------- Login -----------------
builder.addCase(loginUser.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(loginUser.fulfilled, (state, action) => {
state.status = 'successful';
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
const decoded = decodeJwt(action.payload.jwt);
state.username =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
] || null;
state.email =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
] || null;
state.id =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
] || null;
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) => {
state.status = 'failed';
state.error = action.payload as string;
});
// ----------------- Refresh -----------------
builder.addCase(refreshToken.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(refreshToken.fulfilled, (state, action) => {
state.status = 'successful';
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
const decoded = decodeJwt(action.payload.jwt);
state.username =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
] || null;
state.email =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
] || null;
state.id =
decoded[
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
] || null;
axios.defaults.headers.common[
'Authorization'
] = `Bearer ${action.payload.jwt}`;
localStorage.setItem('jwt', action.payload.jwt);
localStorage.setItem('refreshToken', action.payload.refreshToken);
});
builder.addCase(refreshToken.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
// ----------------- WhoAmI -----------------
builder.addCase(fetchWhoAmI.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(fetchWhoAmI.fulfilled, (state, action) => {
state.status = 'successful';
state.username = action.payload.username;
});
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
}); });
export const { logout } = authSlice.actions; export const { logout } = authSlice.actions;

View File

@@ -0,0 +1,245 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// =====================
// Типы
// =====================
export interface Mission {
id: number;
authorId: number;
name: string;
difficulty: number;
tags: string[];
createdAt: string;
updatedAt: string;
timeLimitMilliseconds: number;
memoryLimitBytes: number;
statements: null;
}
export interface Member {
userId: number;
username: string;
role: string;
}
export interface Contest {
id: number;
name: string;
description: string;
scheduleType: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number | null;
maxAttempts: number | null;
allowEarlyFinish: boolean | null;
groupId: number | null;
groupName: string | null;
missions: Mission[];
articles: any[];
members: Member[];
}
interface ContestsResponse {
hasNextPage: boolean;
contests: Contest[];
}
export interface CreateContestBody {
name?: string | null;
description?: string | null;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: 'Public' | 'GroupPrivate';
startsAt?: string | null;
endsAt?: string | null;
attemptDurationMinutes?: number | null;
maxAttempts?: number | null;
allowEarlyFinish?: boolean | null;
groupId?: number | null;
missionIds?: number[] | null;
articleIds?: number[] | null;
participantIds?: number[] | null;
organizerIds?: number[] | null;
}
// =====================
// Состояние
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
interface ContestsState {
contests: Contest[];
selectedContest: Contest | null;
hasNextPage: boolean;
statuses: {
fetchList: Status;
fetchById: Status;
create: Status;
};
error: string | null;
}
const initialState: ContestsState = {
contests: [],
selectedContest: null,
hasNextPage: false,
statuses: {
fetchList: 'idle',
fetchById: 'idle',
create: 'idle',
},
error: null,
};
// =====================
// Async Thunks
// =====================
export const fetchContests = createAsyncThunk(
'contests/fetchAll',
async (
params: {
page?: number;
pageSize?: number;
groupId?: number | null;
} = {},
{ rejectWithValue },
) => {
try {
const { page = 0, pageSize = 10, groupId } = params;
const response = await axios.get<ContestsResponse>('/contests', {
params: { page, pageSize, groupId },
});
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch contests',
);
}
},
);
export const fetchContestById = createAsyncThunk(
'contests/fetchById',
async (id: number, { rejectWithValue }) => {
try {
const response = await axios.get<Contest>(`/contests/${id}`);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch contest',
);
}
},
);
export const createContest = createAsyncThunk(
'contests/create',
async (contestData: CreateContestBody, { rejectWithValue }) => {
try {
const response = await axios.post<Contest>(
'/contests',
contestData,
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to create contest',
);
}
},
);
// =====================
// Slice
// =====================
const contestsSlice = createSlice({
name: 'contests',
initialState,
reducers: {
clearSelectedContest: (state) => {
state.selectedContest = null;
},
setContestStatus: (
state,
action: PayloadAction<{
key: keyof ContestsState['statuses'];
status: Status;
}>,
) => {
state.statuses[action.payload.key] = action.payload.status;
},
},
extraReducers: (builder) => {
// fetchContests
builder.addCase(fetchContests.pending, (state) => {
state.statuses.fetchList = 'loading';
state.error = null;
});
builder.addCase(
fetchContests.fulfilled,
(state, action: PayloadAction<ContestsResponse>) => {
state.statuses.fetchList = 'successful';
state.contests = action.payload.contests;
state.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(
fetchContests.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchList = 'failed';
state.error = action.payload;
},
);
// fetchContestById
builder.addCase(fetchContestById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
});
builder.addCase(
fetchContestById.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.statuses.fetchById = 'successful';
state.selectedContest = action.payload;
},
);
builder.addCase(
fetchContestById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
},
);
// createContest
builder.addCase(createContest.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createContest.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.statuses.create = 'successful';
state.contests.unshift(action.payload);
},
);
builder.addCase(
createContest.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
},
);
},
});
// =====================
// Экспорты
// =====================
export const { clearSelectedContest, setContestStatus } = contestsSlice.actions;
export const contestsReducer = contestsSlice.reducer;

350
src/redux/slices/groups.ts Normal file
View File

@@ -0,0 +1,350 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
type Status = 'idle' | 'loading' | 'successful' | 'failed';
export interface GroupMember {
userId: number;
username: string;
role: string;
}
export interface Group {
id: number;
name: string;
description: string;
members: GroupMember[];
contests: any[];
}
interface GroupsState {
groups: Group[];
currentGroup: Group | null;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchMy: Status;
fetchById: Status;
addMember: Status;
removeMember: Status;
};
error: string | null;
}
const initialState: GroupsState = {
groups: [],
currentGroup: null,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchMy: 'idle',
fetchById: 'idle',
addMember: 'idle',
removeMember: 'idle',
},
error: null,
};
// ─── Async Thunks ─────────────────────────────────────
// POST /groups
export const createGroup = createAsyncThunk(
'groups/createGroup',
async (
{ name, description }: { name: string; description: string },
{ rejectWithValue },
) => {
try {
const response = await axios.post('/groups', { name, description });
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при создании группы',
);
}
},
);
// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk(
'groups/updateGroup',
async (
{
groupId,
name,
description,
}: { groupId: number; name: string; description: string },
{ rejectWithValue },
) => {
try {
const response = await axios.put(`/groups/${groupId}`, {
name,
description,
});
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при обновлении группы',
);
}
},
);
// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk(
'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => {
try {
await axios.delete(`/groups/${groupId}`);
return groupId;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении группы',
);
}
},
);
// GET /groups/my
export const fetchMyGroups = createAsyncThunk(
'groups/fetchMyGroups',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/groups/my');
return response.data.groups as Group[];
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении групп',
);
}
},
);
// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk(
'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${groupId}`);
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении группы',
);
}
},
);
// POST /groups/members
export const addGroupMember = createAsyncThunk(
'groups/addGroupMember',
async (
{ userId, role }: { userId: number; role: string },
{ rejectWithValue },
) => {
try {
await axios.post('/groups/members', { userId, role });
return { userId, role };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при добавлении участника',
);
}
},
);
// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk(
'groups/removeGroupMember',
async (
{ groupId, memberId }: { groupId: number; memberId: number },
{ rejectWithValue },
) => {
try {
await axios.delete(`/groups/${groupId}/members/${memberId}`);
return { groupId, memberId };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении участника',
);
}
},
);
// ─── Slice ────────────────────────────────────────────
const groupsSlice = createSlice({
name: 'groups',
initialState,
reducers: {
clearCurrentGroup: (state) => {
state.currentGroup = null;
},
},
extraReducers: (builder) => {
// ─── CREATE GROUP ───
builder.addCase(createGroup.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.create = 'successful';
state.groups.push(action.payload);
},
);
builder.addCase(
createGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
},
);
// ─── UPDATE GROUP ───
builder.addCase(updateGroup.pending, (state) => {
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.update = 'successful';
const index = state.groups.findIndex(
(g) => g.id === action.payload.id,
);
if (index !== -1) state.groups[index] = action.payload;
if (state.currentGroup?.id === action.payload.id) {
state.currentGroup = action.payload;
}
},
);
builder.addCase(
updateGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.update = 'failed';
state.error = action.payload;
},
);
// ─── DELETE GROUP ───
builder.addCase(deleteGroup.pending, (state) => {
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteGroup.fulfilled,
(state, action: PayloadAction<number>) => {
state.statuses.delete = 'successful';
state.groups = state.groups.filter(
(g) => g.id !== action.payload,
);
if (state.currentGroup?.id === action.payload)
state.currentGroup = null;
},
);
builder.addCase(
deleteGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH MY GROUPS ───
builder.addCase(fetchMyGroups.pending, (state) => {
state.statuses.fetchMy = 'loading';
state.error = null;
});
builder.addCase(
fetchMyGroups.fulfilled,
(state, action: PayloadAction<Group[]>) => {
state.statuses.fetchMy = 'successful';
state.groups = action.payload;
},
);
builder.addCase(
fetchMyGroups.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
state.error = action.payload;
},
);
// ─── FETCH GROUP BY ID ───
builder.addCase(fetchGroupById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
});
builder.addCase(
fetchGroupById.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.fetchById = 'successful';
state.currentGroup = action.payload;
},
);
builder.addCase(
fetchGroupById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
},
);
// ─── ADD MEMBER ───
builder.addCase(addGroupMember.pending, (state) => {
state.statuses.addMember = 'loading';
state.error = null;
});
builder.addCase(addGroupMember.fulfilled, (state) => {
state.statuses.addMember = 'successful';
});
builder.addCase(
addGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.addMember = 'failed';
state.error = action.payload;
},
);
// ─── REMOVE MEMBER ───
builder.addCase(removeGroupMember.pending, (state) => {
state.statuses.removeMember = 'loading';
state.error = null;
});
builder.addCase(
removeGroupMember.fulfilled,
(
state,
action: PayloadAction<{ groupId: number; memberId: number }>,
) => {
state.statuses.removeMember = 'successful';
if (
state.currentGroup &&
state.currentGroup.id === action.payload.groupId
) {
state.currentGroup.members =
state.currentGroup.members.filter(
(m) => m.userId !== action.payload.memberId,
);
}
},
);
builder.addCase(
removeGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.removeMember = 'failed';
state.error = action.payload;
},
);
},
});
export const { clearCurrentGroup } = groupsSlice.actions;
export const groupsReducer = groupsSlice.reducer;

View File

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

View File

@@ -1,30 +1,38 @@
import { createSlice, PayloadAction} from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// Типы данных // Типы данных
interface StorState { interface StorState {
menu: { menu: {
activePage: string; activePage: string;
} activeProfilePage: string;
};
} }
// Инициализация состояния // Инициализация состояния
const initialState: StorState = { const initialState: StorState = {
menu: { menu: {
activePage: "", activePage: '',
} activeProfilePage: '',
},
}; };
// Slice // Slice
const storeSlice = createSlice({ const storeSlice = createSlice({
name: "store", name: 'store',
initialState, initialState,
reducers: { reducers: {
setMenuActivePage: (state, activePage: PayloadAction<string>) => { setMenuActivePage: (state, activePage: PayloadAction<string>) => {
state.menu.activePage = activePage.payload; state.menu.activePage = activePage.payload;
},
setMenuActiveProfilePage: (
state,
activeProfilePage: PayloadAction<string>,
) => {
state.menu.activeProfilePage = activeProfilePage.payload;
},
}, },
},
}); });
export const { setMenuActivePage } = storeSlice.actions; export const { setMenuActivePage, setMenuActiveProfilePage } =
storeSlice.actions;
export const storeReducer = storeSlice.reducer; export const storeReducer = storeSlice.reducer;

View File

@@ -1,184 +1,224 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from "../../axios"; import axios from '../../axios';
// Типы данных // Типы данных
export interface Submit { export interface Submit {
id?: number; id?: number;
missionId: number; missionId: number;
language: string; language: string;
languageVersion: string; languageVersion: string;
sourceCode: string; sourceCode: string;
contestId: number | null; contestId: number | null;
} }
export interface Solution { export interface Solution {
id: number; id: number;
missionId: number; missionId: number;
language: string; language: string;
languageVersion: string; languageVersion: string;
sourceCode: string; sourceCode: string;
status: string; status: string;
time: string; time: string;
testerState: string; testerState: string;
testerErrorCode: string; testerErrorCode: string;
testerMessage: string; testerMessage: string;
currentTest: number; currentTest: number;
amountOfTests: number; amountOfTests: number;
} }
export interface MissionSubmit { export interface MissionSubmit {
id: number; id: number;
userId: number; userId: number;
solution: Solution; solution: Solution;
contestId: number | null; contestId: number | null;
contestName: string | null; contestName: string | null;
sourceType: string; sourceType: string;
} }
interface SubmitState { interface SubmitState {
submits: Submit[]; submits: Submit[];
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
currentSubmit?: Submit; currentSubmit?: Submit;
status: "idle" | "loading" | "successful" | "failed"; status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null; error: string | null;
} }
// Начальное состояние // Начальное состояние
const initialState: SubmitState = { const initialState: SubmitState = {
submits: [], submits: [],
submitsById: {}, // ✅ инициализация submitsById: {}, // ✅ инициализация
currentSubmit: undefined, currentSubmit: undefined,
status: "idle", status: 'idle',
error: null, error: null,
}; };
// AsyncThunk: Отправка решения // AsyncThunk: Отправка решения
export const submitMission = createAsyncThunk( export const submitMission = createAsyncThunk(
"submit/submitMission", 'submit/submitMission',
async (submitData: Submit, { rejectWithValue }) => { async (submitData: Submit, { rejectWithValue }) => {
try { try {
const response = await axios.post("/submits", submitData); const response = await axios.post('/submits', submitData);
return response.data; return response.data;
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Submit failed"); return rejectWithValue(
} err.response?.data?.message || 'Submit failed',
} );
}
},
); );
// AsyncThunk: Получить все свои отправки // AsyncThunk: Получить все свои отправки
export const fetchMySubmits = createAsyncThunk( export const fetchMySubmits = createAsyncThunk(
"submit/fetchMySubmits", 'submit/fetchMySubmits',
async (_, { rejectWithValue }) => { async (_, { rejectWithValue }) => {
try { try {
const response = await axios.get("/submits/my"); const response = await axios.get('/submits/my');
return response.data as Submit[]; return response.data as Submit[];
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch submits"); return rejectWithValue(
} err.response?.data?.message || 'Failed to fetch submits',
} );
}
},
); );
// AsyncThunk: Получить конкретную отправку по ID // AsyncThunk: Получить конкретную отправку по ID
export const fetchSubmitById = createAsyncThunk( export const fetchSubmitById = createAsyncThunk(
"submit/fetchSubmitById", 'submit/fetchSubmitById',
async (id: number, { rejectWithValue }) => { async (id: number, { rejectWithValue }) => {
try { try {
const response = await axios.get(`/submits/${id}`); const response = await axios.get(`/submits/${id}`);
return response.data as Submit; return response.data as Submit;
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch submit"); return rejectWithValue(
} err.response?.data?.message || 'Failed to fetch submit',
} );
}
},
); );
// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура) // ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура)
export const fetchMySubmitsByMission = createAsyncThunk( export const fetchMySubmitsByMission = createAsyncThunk(
"submit/fetchMySubmitsByMission", 'submit/fetchMySubmitsByMission',
async (missionId: number, { rejectWithValue }) => { async (missionId: number, { rejectWithValue }) => {
try { try {
const response = await axios.get(`/submits/my/mission/${missionId}`); const response = await axios.get(
return { missionId, data: response.data as MissionSubmit[] }; `/submits/my/mission/${missionId}`,
} catch (err: any) { );
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits"); return { missionId, data: response.data as MissionSubmit[] };
} } catch (err: any) {
} return rejectWithValue(
err.response?.data?.message ||
'Failed to fetch mission submits',
);
}
},
); );
// Slice // Slice
const submitSlice = createSlice({ const submitSlice = createSlice({
name: "submit", name: 'submit',
initialState, initialState,
reducers: { reducers: {
clearCurrentSubmit: (state) => { clearCurrentSubmit: (state) => {
state.currentSubmit = undefined; state.currentSubmit = undefined;
state.status = "idle"; state.status = 'idle';
state.error = null; state.error = null;
},
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
delete state.submitsById[action.payload];
},
}, },
clearSubmitsByMission: (state, action: PayloadAction<number>) => { extraReducers: (builder) => {
delete state.submitsById[action.payload]; // Отправка решения
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;
},
);
}, },
},
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 { clearCurrentSubmit, clearSubmitsByMission } =
submitSlice.actions;
export const submitReducer = submitSlice.reducer; export const submitReducer = submitSlice.reducer;

View File

@@ -1,9 +1,11 @@
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from '@reduxjs/toolkit';
import { authReducer } from "./slices/auth"; import { authReducer } from './slices/auth';
import { storeReducer } from "./slices/store"; import { storeReducer } from './slices/store';
import { missionsReducer } from "./slices/missions"; import { missionsReducer } from './slices/missions';
import { submitReducer } from "./slices/submit"; import { submitReducer } from './slices/submit';
import { contestsReducer } from './slices/contests';
import { groupsReducer } from './slices/groups';
import { articlesReducer } from './slices/articles';
// использование // использование
// import { useAppDispatch, useAppSelector } from '../redux/hooks'; // import { useAppDispatch, useAppSelector } from '../redux/hooks';
@@ -13,15 +15,17 @@ import { submitReducer } from "./slices/submit";
// const dispatch = useAppDispatch(); // const dispatch = useAppDispatch();
// const user = useAppSelector((state) => state.user); // const user = useAppSelector((state) => state.user);
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
//user: userReducer, //user: userReducer,
auth: authReducer, auth: authReducer,
store: storeReducer, store: storeReducer,
missions: missionsReducer, missions: missionsReducer,
submin: submitReducer, submin: submitReducer,
}, contests: contestsReducer,
groups: groupsReducer,
articles: articlesReducer,
},
}); });
// тип состояния всего стора // тип состояния всего стора

View File

@@ -2,116 +2,109 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import "./latex-container.css"; @import './latex-container.css';
* { * {
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/ -webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
/* outline: 1px solid green; */ /* outline: 1px solid green; */
} }
:root { :root {
color-scheme: light dark; color-scheme: light dark;
width: 100%; width: 100%;
height: 100svh; height: 100svh;
/* @apply bg-layout-background; */ /* @apply bg-layout-background; */
/* transition: all linear 200ms; */ /* transition: all linear 200ms; */
font-family: 'Source Code Pro', monospace; font-family: 'Source Code Pro', monospace;
/* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */ /* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
background-color: var(--color-liquid-background); background-color: var(--color-liquid-background);
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
overflow-x: hidden;
} }
#root { #root {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
} }
body { body {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
} }
/* Общий контейнер полосы прокрутки */ /* Общий контейнер полосы прокрутки */
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */ width: 4px; /* ширина вертикального */
} }
/* Трек (фон) */ /* Трек (фон) */
.thin-scrollbar::-webkit-scrollbar-track { .thin-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Ползунок (thumb) */ /* Ползунок (thumb) */
.thin-scrollbar::-webkit-scrollbar-thumb { .thin-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-light); background: var(--color-liquid-light);
border-radius: 1000px; border-radius: 1000px;
cursor: pointer; cursor: pointer;
} }
/* Общий контейнер полосы прокрутки */ /* Общий контейнер полосы прокрутки */
.medium-scrollbar::-webkit-scrollbar { .medium-scrollbar::-webkit-scrollbar {
width: 8px; /* ширина вертикального */ width: 8px; /* ширина вертикального */
} }
/* Трек (фон) */ /* Трек (фон) */
.medium-scrollbar::-webkit-scrollbar-track { .medium-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Ползунок (thumb) */ /* Ползунок (thumb) */
.medium-scrollbar::-webkit-scrollbar-thumb { .medium-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-light); background: var(--color-liquid-light);
border-radius: 1000px; border-radius: 1000px;
cursor: pointer; cursor: pointer;
} }
/* Общий контейнер полосы прокрутки */ /* Общий контейнер полосы прокрутки */
.thin-dark-scrollbar::-webkit-scrollbar { .thin-dark-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */ width: 4px; /* ширина вертикального */
} }
/* Трек (фон) */ /* Трек (фон) */
.thin-dark-scrollbar::-webkit-scrollbar-track { .thin-dark-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Ползунок (thumb) */ /* Ползунок (thumb) */
.thin-dark-scrollbar::-webkit-scrollbar-thumb { .thin-dark-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-lighter); background: var(--color-liquid-lighter);
border-radius: 1000px; border-radius: 1000px;
cursor: pointer; cursor: pointer;
} }
html { html {
scrollbar-gutter: stable; scrollbar-gutter: stable;
padding-left: 8px; padding-left: 8px;
} }
html::-webkit-scrollbar { html::-webkit-scrollbar {
width: 8px; /* ширина вертикального */ width: 8px; /* ширина вертикального */
} }
/* Трек (фон) */ /* Трек (фон) */
html::-webkit-scrollbar-track { html::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Ползунок (thumb) */ /* Ползунок (thumb) */
html::-webkit-scrollbar-thumb { html::-webkit-scrollbar-thumb {
background-color: var(--color-liquid-lighter); background-color: var(--color-liquid-lighter);
border-radius: 1000px; border-radius: 1000px;
cursor: pointer; cursor: pointer;
} }

View File

@@ -1,26 +1,24 @@
.latex-container p { .latex-container p {
text-align: justify; /* выравнивание по ширине */ text-align: justify; /* выравнивание по ширине */
text-justify: inter-word; text-justify: inter-word;
margin-bottom: 0.8em; /* небольшой отступ между абзацами */ margin-bottom: 0.8em; /* небольшой отступ между абзацами */
line-height: 1.2; line-height: 1.2;
/* text-indent: 1em; */ /* text-indent: 1em; */
} }
.latex-container ol { .latex-container ol {
padding-left: 1.5em; /* отступ для нумерации */ padding-left: 1.5em; /* отступ для нумерации */
margin: 0.5em 0; /* небольшой отступ сверху и снизу */ margin: 0.5em 0; /* небольшой отступ сверху и снизу */
line-height: 1.5; /* удобный межстрочный интервал */ line-height: 1.5; /* удобный межстрочный интервал */
font-family: "Inter", sans-serif; font-family: 'Inter', sans-serif;
font-size: 1rem; font-size: 1rem;
} }
.latex-container ol li { .latex-container ol li {
margin-bottom: 0.4em; /* расстояние между пунктами */ margin-bottom: 0.4em; /* расстояние между пунктами */
} }
.latex-container .section-title{ .latex-container .section-title {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
} }

View File

@@ -1,16 +1,16 @@
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@layer base { @layer base {
:root[data-theme~="dark"] { :root[data-theme~='dark'] {
--color-liquid-brightmain: #00DBD9; --color-liquid-brightmain: #00dbd9;
--color-liquid-darkmain: #075867; --color-liquid-darkmain: #075867;
--color-liquid-darker: #141515; --color-liquid-darker: #141515;
--color-liquid-background: #202222; --color-liquid-background: #202222;
--color-liquid-lighter: #2A2E2F; --color-liquid-lighter: #2a2e2f;
--color-liquid-white: #EDF6F7; --color-liquid-white: #edf6f7;
--color-liquid-red: #F13E5F; --color-liquid-red: #f13e5f;
--color-liquid-green: #10BE59; --color-liquid-green: #10be59;
--color-liquid-light: #576466; --color-liquid-light: #576466;
--color-liquid-orange: #FF951B; --color-liquid-orange: #ff951b;
} }
} }

View File

@@ -1,16 +1,16 @@
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@layer base { @layer base {
:root { :root {
--color-liquid-brightmain: #00DBD9; --color-liquid-brightmain: #00dbd9;
--color-liquid-darkmain: #075867; --color-liquid-darkmain: #075867;
--color-liquid-darker: #141515; --color-liquid-darker: #141515;
--color-liquid-background: #202222; --color-liquid-background: #202222;
--color-liquid-lighter: #2A2E2F; --color-liquid-lighter: #2a2e2f;
--color-liquid-white: #EDF6F7; --color-liquid-white: #edf6f7;
--color-liquid-red: #F13E5F; --color-liquid-red: #f13e5f;
--color-liquid-green: #10BE59; --color-liquid-green: #10be59;
--color-liquid-light: #576466; --color-liquid-light: #576466;
--color-liquid-orange: #FF951B; --color-liquid-orange: #ff951b;
} }
} }

View File

@@ -0,0 +1,66 @@
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 {
articleId: number;
back?: string;
}
const Header: React.FC<HeaderProps> = ({ articleId, back }) => {
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(back ? back : '/home/articles');
}}
/>
<div className="flex gap-[10px]">
<img
src={chevroneLeft}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
if (articleId <= 1) return;
if (back)
navigate(`/article/${articleId - 1}?back=${back}`);
else navigate(`/article/${articleId - 1}`);
}}
/>
<span className="text-[18px] font-bold">#{articleId}</span>
<img
src={chevroneRight}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
if (back)
navigate(`/article/${articleId + 1}?back=${back}`);
else navigate(`/article/${articleId + 1}`);
}}
/>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,297 @@
import { FC, useEffect, useState } from 'react';
import axios from '../../axios';
import 'highlight.js/styles/github-dark.css';
import MarkdownPreview from './MarckDownPreview';
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue ||
`# 🌙 Добро пожаловать в Markdown-редактор
Добро пожаловать в **Markdown-редактор**!
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
---
## 🧱 1. Форматирование текста
Вот примеры базового форматирования:
- **Жирный текст**
- *Курсивный текст*
- ***Жирный курсив***
- ~~Зачёркнутый~~
> 💬 _Цитаты_ можно использовать для выделения текста, заметок или описаний.
---
## 🧩 2. Списки
### 🔹 Маркированный список
- Один
- Два
- Вложенный уровень
- Ещё глубже
- Три
### 🔸 Нумерованный список
1. Первый
2. Второй
3. Третий
1. Вложенный
2. Ещё один
---
## ✅ 3. Чеклисты (GFM)
- [x] Поддержка Markdown
- [x] Подсветка кода
- [x] Таблицы
- [x] Эмодзи 😎
- [ ] Экспорт в PDF (в будущем)
---
## 💻 4. Код и подсветка
Пример **TypeScript**:
\`\`\`tsx
type User = {
name: string;
role: "Разработчик" | "Помощник";
};
function greet(user: User) {
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
}
console.log(greet({ name: "Ты", role: "Разработчик" }));
\`\`\`
Пример **JavaScript**:
\`\`\`js
const sum = (a, b) => a + b;
console.log(sum(2, 3)); // 5
\`\`\`
Пример **Python**:
\`\`\`python
def greet(name):
return f"Привет, {name}! 👋"
print(greet("Мир"))
\`\`\`
---
## 📊 5. Таблицы (GFM)
| Имя | Роль | Активен | Эмодзи |
|-------------|----------------|----------|--------|
| ChatGPT | Помощник 🤖 | ✅ | 🤓 |
| Ты | Разработчик 💻 | ✅ | 🚀 |
| TailwindCSS | Стилизация 🎨 | 🟢 | 💅 |
> Таблицы поддерживают **жирный текст**, _курсив_ и даже \`инлайн-код\` внутри ячеек.
---
## 🔗 6. Ссылки
- [Документация Markdown](https://www.markdownguide.org/)
- [React Markdown на GitHub](https://github.com/remarkjs/react-markdown)
- Автоматическая ссылка: https://github.com
---
## 🖼️ 7. Изображения
### Markdown-логотип:
![Markdown Logo](https://upload.wikimedia.org/wikipedia/commons/4/48/Markdown-mark.svg)
или
<img src=\"https://upload.wikimedia.org/wikipedia/commons/4/48/Markdown-mark.svg\" alt=\"img\"/>
или если нужно выравнивание по центру
<div style=\"display: flex; items-align: center; justify-content: center; background: gray;\">
<img src=\"https://upload.wikimedia.org/wikipedia/commons/4/48/Markdown-mark.svg\" alt=\"img\"/>
</div>
---
## 🧠 8. Цитаты и вложенность
> 💭 Это обычная цитата.
>
> > А это — **вложенная цитата**.
> >
> > > Можно вкладывать сколько угодно уровней!
---
## ⚙️ 9. Горизонтальные линии
---
***
---
## 🧮 10. Таблица внутри цитаты
> Вот таблица прямо внутри блока цитаты:
>
> | Язык | Назначение |
> |-------|-------------|
> | JS | Web-разработка |
> | TS | Строгая типизация |
> | PY | Скрипты и AI |
---
## 🧩 11. Встроенный HTML
<details>
<summary>📂 Раскрывающийся блок</summary>
Этот текст виден только после раскрытия!
<ul>
<li>HTML списки работают</li>
<li>И даже <b>жирный текст</b></li>
</ul>
</details>
---
## 🎨 12. Вложенные списки с кодом
- Этапы:
1. Создай проект
2. Добавь зависимости:
\`\`\`bash
npm install react-markdown remark-gfm rehype-highlight highlight.js
\`\`\`
3. Импортируй стили:
\`\`\`tsx
import "highlight.js/styles/github-dark.css";
\`\`\`
4. Готово!
---
## 🚀 13. Финал
Поздравляю! 🎉
Ты только что увидел все ключевые возможности **Markdown + GFM** в действии.
> ✨ Используй этот текст как шаблон для тестирования рендерера.
> 💡 Совет: попробуй поменять тему \`highlight.js\` (например \`monokai.css\` или \`atom-one-dark.css\`).
---
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
`,
);
useEffect(() => {
onChange(markdown);
}, [markdown]);
// Обработчик вставки
const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement>,
) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault(); // предотвращаем вставку картинки как текста
const file = item.getAsFile();
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post(
'/media/upload',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
},
);
const imageUrl = response.data.url;
// Вставляем ссылку на картинку в текст
const cursorPos = (e.target as HTMLTextAreaElement)
.selectionStart;
const newText =
markdown.slice(0, cursorPos) +
`<img src=\"${imageUrl}\" alt=\"img\"/>` +
markdown.slice(cursorPos);
setMarkdown(newText);
} catch (err) {
console.error('Ошибка загрузки изображения:', err);
}
}
}
};
return (
<div className="grid grid-cols-2 h-full min-h-0">
{/* Предпросмотр */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100">
👀 Предпросмотр
</h2>
<MarkdownPreview
content={markdown}
className="h-[calc(100%-40px)]"
/>
</div>
</div>
{/* Редактор */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100">
📝 Редактор
</h2>
<textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
onPaste={handlePaste} // <-- вот сюда обработчик вставки
className="flex-1 w-full bg-[#0d1117] text-gray-200 border border-gray-700
rounded-lg p-5 font-mono text-sm resize-none focus:outline-none focus:ring-2
medium-scrollbar"
placeholder="Пиши в формате Markdown..."
/>
</div>
</div>
</div>
);
};
export default MarkdownEditor;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { arrowLeft } from '../../assets/icons/header';
import { Logo } from '../../assets/logos';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
backClick?: () => void;
}
const Header: React.FC<HeaderProps> = ({ backClick }) => {
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={() => {
if (backClick) backClick();
}}
/>
{/* <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;

View File

@@ -0,0 +1,55 @@
import { FC } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import 'highlight.js/styles/github-dark.css';
import { defaultSchema } from 'hast-util-sanitize';
import { cn } from '../../lib/cn';
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
div: [
...(defaultSchema.attributes?.div || []),
['style'], // разрешаем атрибут style на div
],
},
};
interface MarkdownPreviewProps {
content: string;
className?: string;
}
const MarkdownPreview: FC<MarkdownPreviewProps> = ({
content,
className = '',
}) => {
return (
<div
className={cn(
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
className,
)}
>
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, schema],
rehypeHighlight,
]}
>
{content}
</ReactMarkdown>
</div>
</div>
);
};
export default MarkdownPreview;

View File

@@ -0,0 +1,56 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AccountMenu from './AccoutMenu';
import RightPanel from './RightPanel';
import MissionsBlock from './MissionsBlock';
import ContestsBlock from './ContestsBlock';
import ArticlesBlock from './ArticlesBlock';
import { useAppDispatch } from '../../../redux/hooks';
import { useEffect } from 'react';
import { setMenuActivePage } from '../../../redux/slices/store';
const Account = () => {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setMenuActivePage('account'));
}, []);
return (
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,520px] relative divide-x-[1px] divide-liquid-lighter">
<div className=" h-full min-h-0 flex flex-col">
<div className=" h-full grid grid-rows-[80px,1fr] ">
<div className="h-full w-full">
<AccountMenu />
</div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px] ">
<Routes>
<Route
path="missions"
element={<MissionsBlock />}
/>
<Route
path="articles"
element={<ArticlesBlock />}
/>
<Route
path="contests"
element={<ContestsBlock />}
/>
<Route
path="*"
element={
<Navigate to="/home/account/missions" />
}
/>
</Routes>
</div>
</div>
</div>
<div className=" h-full min-h-0">
<RightPanel />
</div>
</div>
);
};
export default Account;

View File

@@ -0,0 +1,94 @@
import { Openbook, Cup, Clipboard } from '../../../assets/icons/menu';
import React from 'react';
import { Link } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import {
setMenuActivePage,
setMenuActiveProfilePage,
} from '../../../redux/slices/store';
interface MenuItemProps {
icon: string;
text: string;
href: string;
page: string;
profilePage: string;
active?: boolean;
}
const MenuItem: React.FC<MenuItemProps> = ({
icon,
text = '',
href = '',
active = false,
page = '',
profilePage = '',
}) => {
const dispatch = useAppDispatch();
return (
<Link
to={href}
className={`
flex items-center gap-3 p-[16px] rounded-[10px] h-[40px] text-[18px] font-bold
transition-all duration-300 text-liquid-white
active:scale-95 hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-light hover:ring-inset
${active && 'bg-liquid-lighter '}
`}
onClick={() => {
dispatch(setMenuActivePage(page));
dispatch(setMenuActiveProfilePage(profilePage));
}}
>
<img src={icon} />
<span>{text}</span>
</Link>
);
};
const AccountMenu = () => {
const menuItems = [
{
text: 'Задачи',
href: '/home/account/missions',
icon: Clipboard,
page: 'account',
profilePage: 'missions',
},
{
text: 'Статьи',
href: '/home/account/articles',
icon: Openbook,
page: 'account',
profilePage: 'articles',
},
{
text: 'Контесты',
href: '/home/account/contests',
icon: Cup,
page: 'account',
profilePage: 'contests',
},
];
const activeProfilePage = useAppSelector(
(state) => state.store.menu.activeProfilePage,
);
console.log('active', [activeProfilePage]);
return (
<div className="h-full w-full relative flex p-[20px] gap-[10px]">
{menuItems.map((v, i) => (
<MenuItem
{...v}
key={i}
active={activeProfilePage == v.profilePage}
/>
))}
</div>
);
};
export default AccountMenu;

View File

@@ -0,0 +1,124 @@
import { FC, useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
import { cn } from '../../../lib/cn';
import { ChevroneDown, Edit } from '../../../assets/icons/groups';
import { fetchArticles } from '../../../redux/slices/articles';
import { useNavigate } from 'react-router-dom';
export interface ArticleItemProps {
id: number;
name: string;
tags: string[];
}
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
const navigate = useNavigate();
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 cursor-pointer hover:bg-liquid-lighter transition-all duration-300',
)}
onClick={() => {
navigate(`/article/${id}?back=/home/account/articles`);
}}
>
<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">
{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>
<img
className=" absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
src={Edit}
onClick={(e) => {
e.stopPropagation();
navigate(
`/article/create?back=/home/account/articles&articleId=${id}`,
);
}}
/>
</div>
);
};
interface ArticlesBlockProps {
className?: string;
}
const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
const dispatch = useAppDispatch();
const articles = useAppSelector((state) => state.articles.articles);
const [active, setActive] = useState<boolean>(true);
useEffect(() => {
dispatch(setMenuActiveProfilePage('articles'));
dispatch(fetchArticles({}));
}, []);
return (
<div className="h-full w-full relative p-[20px]">
<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>Мои статьи</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 gap-[20px] pt-[20px] pb-[20px] box-border">
{articles.map((v, i) => (
<ArticleItem key={i} {...v} />
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default ArticlesBlock;

View File

@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useAppDispatch } from '../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
const ContestsBlock = () => {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setMenuActiveProfilePage('contests'));
}, []);
return (
<div className="h-full w-full relative flex items-center justify-center text-[60px] font-bold">
Пока пусто :(
</div>
);
};
export default ContestsBlock;

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useAppDispatch } from '../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
const MissionsBlock = () => {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setMenuActiveProfilePage('missions'));
}, []);
return (
<div className="h-full w-full relative flex items-center justify-center text-[60px] font-bold">
Пока пусто :(
</div>
);
};
export default MissionsBlock;

View File

@@ -0,0 +1,118 @@
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { logout } from '../../../redux/slices/auth';
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
import { FC } from 'react';
interface StatisticItemProps {
icon: string;
title: string;
count?: number;
countLastWeek?: number;
}
const StatisticItem: FC<StatisticItemProps> = ({
title,
icon,
count = 0,
countLastWeek = 0,
}) => {
return (
<div className="h-[53px] grid grid-cols-[36px,1fr] gap-[20px] text-liquid-white">
<img src={icon} />
<div className="h-full flex flex-col justify-between">
<div className="flex gap-[20px] text-[18px] font-bold leading-[23px]">
<div>{title}</div>
<div>{count}</div>
</div>
<div className="text-[16px] font-medium leading-[20px]">
{'За 7 дней '}
<span className="text-liquid-light">{countLastWeek}</span>
</div>
</div>
</div>
);
};
const RightPanel = () => {
const dispatch = useAppDispatch();
const name = useAppSelector((state) => state.auth.username);
const email = useAppSelector((state) => state.auth.email);
return (
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
<div className=" relative">
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
{name}
</div>
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
{email}
</div>
<div className=" absolute bottom-0 text-liquid-light text-[24px] leading-[30px] font-bold">
Топ 50%
</div>
</div>
</div>
<PrimaryButton
onClick={() => {}}
text="Редактировать"
className="w-full"
/>
<div className="h-[1px] w-full bg-liquid-lighter"></div>
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
{'Статистика решений'}
</div>
<StatisticItem
icon={Clipboard}
title={'Задачи'}
count={14}
countLastWeek={5}
/>
<StatisticItem
icon={Cup}
title={'Контесты'}
count={8}
countLastWeek={2}
/>
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
{'Статистика созданий'}
</div>
<StatisticItem
icon={Clipboard}
title={'Задачи'}
count={4}
countLastWeek={2}
/>
<StatisticItem
icon={OpenBook}
title={'Статьи'}
count={12}
countLastWeek={4}
/>
<StatisticItem
icon={Cup}
title={'Контесты'}
count={2}
countLastWeek={0}
/>
<ReverseButton
className="absolute bottom-[20px] right-[20px]"
onClick={() => {
dispatch(logout());
}}
text="Выход"
color="error"
/>
</div>
);
};
export default RightPanel;

View File

@@ -1,4 +1,5 @@
import { cn } from "../../../lib/cn"; import { useNavigate } from 'react-router-dom';
import { cn } from '../../../lib/cn';
export interface ArticleItemProps { export interface ArticleItemProps {
id: number; id: number;
@@ -6,17 +7,21 @@ export interface ArticleItemProps {
tags: string[]; tags: string[];
} }
const ArticleItem: React.FC<ArticleItemProps> = ({ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
id, name, tags const navigate = useNavigate();
}) => {
return ( return (
<div className={cn("w-full relative rounded-[10px] text-liquid-white mb-[20px]", <div
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background", className={cn(
"gap-[20px] px-[20px] py-[10px] box-border ", 'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
"border-b-[1px] border-b-liquid-lighter", // type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
)}> 'gap-[20px] px-[20px] py-[10px] box-border ',
'border-b-[1px] border-b-liquid-lighter cursor-pointer hover:bg-liquid-lighter transition-all duration-300',
)}
onClick={() => {
navigate(`/article/${id}`);
}}
>
<div className="h-[23px] flex "> <div className="h-[23px] flex ">
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center"> <div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
#{id} #{id}
</div> </div>
@@ -25,15 +30,18 @@ const ArticleItem: React.FC<ArticleItemProps> = ({
</div> </div>
</div> </div>
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]"> <div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
{tags.map((v, i) => {tags.map((v, i) => (
<div key={i} className={cn( <div
"rounded-full px-[16px] py-[8px] bg-liquid-lighter", key={i}
v == "Sertificated" && "text-liquid-green")}> className={cn(
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
v == 'Sertificated' && 'text-liquid-green',
)}
>
{v} {v}
</div> </div>
)} ))}
</div> </div>
</div> </div>
); );
}; };

View File

@@ -1,9 +1,10 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { useAppDispatch } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import ArticleItem from "./ArticleItem"; import ArticleItem from './ArticleItem';
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { useNavigate } from 'react-router-dom';
import { fetchArticles } from '../../../redux/slices/articles';
export interface Article { export interface Article {
id: number; id: number;
@@ -11,158 +12,45 @@ export interface Article {
tags: string[]; tags: string[];
} }
const Articles = () => { const Articles = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate();
const articles: Article[] = [ const articles = useAppSelector((state) => state.articles.articles);
{ const status = useAppSelector((state) => state.articles.statuses.fetchAll);
"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(() => { useEffect(() => {
dispatch(setMenuActivePage("articles")) dispatch(setMenuActivePage('articles'));
}, []); dispatch(fetchArticles({}));
}, []);
if (status == 'loading') return <div>Загрузка...</div>;
return ( return (
<div className=" h-full w-full box-border p-[20px] pt-[20px]"> <div className=" h-full w-full box-border p-[20px] pt-[20px]">
<div className="h-full box-border"> <div className="h-full box-border">
<div className="relative flex items-center mb-[20px]"> <div className="relative flex items-center mb-[20px]">
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center"> <div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
Статьи Статьи
</div> </div>
<SecondaryButton <SecondaryButton
onClick={() => { }} onClick={() => {
navigate('/article/create');
}}
text="Создать статью" text="Создать статью"
className="absolute right-0" className="absolute right-0"
/> />
</div> </div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"> <div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
</div>
<div> <div>
{articles.map((v, i) => ( {articles.map((v, i) => (
<ArticleItem key={i} {...v} /> <ArticleItem key={i} {...v} />
))} ))}
</div> </div>
<div>pages</div>
<div>
pages
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,112 +1,133 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { PrimaryButton } from "../../../components/button/PrimaryButton"; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from "../../../components/input/Input"; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from 'react-router-dom';
import { loginUser } from "../../../redux/slices/auth"; import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from "../../../assets/icons/auth"; import { Balloon } from '../../../assets/icons/auth';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { googleLogo } from "../../../assets/icons/input"; import { googleLogo } from '../../../assets/icons/input';
const Login = () => { const Login = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>('');
const [submitClicked, setSubmitClicked] = useState<boolean>(false); const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const { status, 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'));
console.log(submitClicked);
}, []);
// После успешного логина useEffect(() => {
useEffect(() => { if (jwt) {
dispatch(setMenuActivePage("account")) navigate('/home/account'); // или другая страница после входа
}, []); }
}, [jwt]);
useEffect(() => { const handleLogin = () => {
if (jwt) { // setErr(err == "" ? "Неверная почта и/или пароль" : "");
navigate("/home/offices"); // или другая страница после входа setSubmitClicked(true);
}
}, [jwt]);
const handleLogin = () => { if (!username || !password) return;
// setErr(err == "" ? "Неверная почта и/или пароль" : "");
setSubmitClicked(true);
if (!username || !password) return; dispatch(loginUser({ username, password }));
};
dispatch(loginUser({ username, password })); return (
}; <div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
<div className="flex items-center justify-center ">
<img src={Balloon} />
</div>
<div className=" relative pointer-events-auto">
<div>
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
С возвращением
</div>
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
Вход в аккаунт
</div>
</div>
return ( <Input
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center"> name="login"
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative "> autocomplete="login"
<div className="flex items-center justify-center "> className="mt-[10px]"
<img src={Balloon} /> type="text"
</div> label="Логин"
<div className=" relative pointer-events-auto"> onChange={(v) => {
<div> setUsername(v);
<div className="text-[40px] text-liquid-white font-bold h-[50px]"> }}
С возвращением 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
to={''}
className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Забыли пароль?
</Link>
</div>
<div className="mt-[10px]">
<PrimaryButton
className="w-full mb-[8px]"
onClick={handleLogin}
text={status === 'loading' ? 'Вход...' : 'Вход'}
disabled={status === 'loading'}
/>
<SecondaryButton className="w-full" onClick={() => {}}>
<div className="flex items-center">
<img
src={googleLogo}
className="h-[24px] w-[24px] mr-[15px]"
/>
Вход с Google
</div>
</SecondaryButton>
</div>
<div className="flex justify-center mt-[10px]">
<span>
Нет аккаунта?{' '}
<Link
to={'/home/register'}
className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Регистрация
</Link>
</span>
</div>
</div>
</div> </div>
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
Вход в аккаунт
</div>
</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" />
<div className="flex justify-end mt-[10px]">
<Link
to={""}
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}>
Забыли пароль?
</Link>
</div>
<div className="mt-[10px]">
<PrimaryButton
className="w-full mb-[8px]"
onClick={handleLogin}
text={status === "loading" ? "Вход..." : "Вход"}
disabled={status === "loading"}
/>
<SecondaryButton
className="w-full"
onClick={() => { }}
>
<div className="flex items-center">
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" />
Вход с Google
</div>
</SecondaryButton>
</div>
<div className="flex justify-center mt-[10px]">
<span>
Нет аккаунта? <Link
to={"/home/register"}
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}>
Регистрация
</Link>
</span>
</div>
</div> </div>
);
</div>
</div>
);
}; };
export default Login; export default Login;

View File

@@ -1,125 +1,169 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { PrimaryButton } from "../../../components/button/PrimaryButton"; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from "../../../components/input/Input"; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { registerUser } from "../../../redux/slices/auth"; import { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from "../../../assets/icons/auth"; import { Balloon } from '../../../assets/icons/auth';
import { Link } from "react-router-dom"; import { Link } from 'react-router-dom';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Checkbox } from "../../../components/checkbox/Checkbox"; import { Checkbox } from '../../../components/checkbox/Checkbox';
import { googleLogo } from "../../../assets/icons/input"; import { googleLogo } from '../../../assets/icons/input';
const Register = () => { const Register = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>('');
const [email, setEmail] = useState<string>(""); const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>(""); const [confirmPassword, setConfirmPassword] = useState<string>('');
const [submitClicked, setSubmitClicked] = useState<boolean>(false); const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const { status, jwt } = useAppSelector((state) => state.auth); const { status, jwt } = useAppSelector((state) => state.auth);
// После успешной регистрации — переход в систему // После успешной регистрации — переход в систему
useEffect(() => { useEffect(() => {
dispatch(setMenuActivePage("account")) dispatch(setMenuActivePage('account'));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (jwt) { if (jwt) {
navigate("/home"); navigate('/home/account');
} }
}, [jwt]); console.log(submitClicked);
}, [jwt]);
const handleRegister = () => { const handleRegister = () => {
setSubmitClicked(true); setSubmitClicked(true);
if (!username || !email || !password || !confirmPassword) return; if (!username || !email || !password || !confirmPassword) return;
if (password !== confirmPassword) return; if (password !== confirmPassword) return;
dispatch(registerUser({ username, email, password })); dispatch(registerUser({ username, email, password }));
}; };
return ( return (
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center"> <div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative "> <div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
<div className="flex items-center justify-center "> <div className="flex items-center justify-center ">
<img src={Balloon} /> <img src={Balloon} />
</div> </div>
<div className=" relative pointer-events-auto"> <div className=" relative pointer-events-auto">
<div> <div>
<div className="text-[40px] text-liquid-white font-bold h-[50px]"> <div className="text-[40px] text-liquid-white font-bold h-[50px]">
Добро пожаловать Добро пожаловать
</div>
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
Регистрация
</div>
</div>
<Input
name="email"
autocomplete="email"
className="mt-[10px]"
type="email"
label="Почта"
onChange={(v) => {
setEmail(v);
}}
placeholder="example@gmail.com"
/>
<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="confirm-password"
autocomplete="confirm-password"
className="mt-[10px]"
type="password"
label="Повторите пароль"
onChange={(v) => {
setConfirmPassword(v);
}}
placeholder="abCD1234"
/>
<div className=" flex items-center mt-[10px] h-[24px]">
<Checkbox
onChange={(value: boolean) => {
value;
}}
className="p-0 w-fit m-[2.75px]"
size="md"
color="secondary"
variant="default"
/>
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
Я принимаю{' '}
<Link to={'/home'} className={' underline'}>
политику конфиденциальности
</Link>
</span>
</div>
<div className="mt-[10px]">
<PrimaryButton
className="w-full mb-[8px]"
onClick={() => handleRegister()}
text={
status === 'loading'
? 'Регистрация...'
: 'Регистрация'
}
disabled={status === 'loading'}
/>
<SecondaryButton className="w-full" onClick={() => {}}>
<div className="flex items-center">
<img
src={googleLogo}
className="h-[24px] w-[24px] mr-[15px]"
/>
Регистрация с Google
</div>
</SecondaryButton>
</div>
<div className="flex justify-center mt-[10px]">
<span>
Уже есть аккаунт?{' '}
<Link
to={'/home/login'}
className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Авторизация
</Link>
</span>
</div>
</div>
</div> </div>
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
Регистрация
</div>
</div>
<Input name="email" autocomplete="email" className="mt-[10px]" type="email" label="Почта" onChange={(v) => {setEmail(v)}} placeholder="example@gmail.com" />
<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="confirm-password" autocomplete="confirm-password" className="mt-[10px]" type="password" label="Повторите пароль" onChange={(v) => {setConfirmPassword(v)}} placeholder="abCD1234" />
<div className=" flex items-center mt-[10px] h-[24px]">
<Checkbox
onChange={(value: boolean) => { value; }}
className="p-0 w-fit m-[2.75px]"
size="md"
color="secondary"
variant="default" />
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
Я принимаю <Link
to={"/home"}
className={" underline"}
>
политику конфиденциальности
</Link>
</span>
</div>
<div className="mt-[10px]">
<PrimaryButton
className="w-full mb-[8px]"
onClick={() => handleRegister()}
text={status === "loading" ? "Регистрация..." : "Регистрация"}
disabled={status === "loading"}
/>
<SecondaryButton
className="w-full"
onClick={() => { }}
>
<div className="flex items-center">
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" />
Регистрация с Google
</div>
</SecondaryButton>
</div>
<div className="flex justify-center mt-[10px]">
<span>
Уже есть аккаунт? <Link
to={"/home/login"}
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}>
Авторизация
</Link>
</span>
</div>
</div> </div>
);
</div>
</div>
);
}; };
export default Register; export default Register;

View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActivePage } from '../../../redux/slices/store';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { fetchContestById } from '../../../redux/slices/contests';
import ContestMissions from './Missions';
export interface Article {
id: number;
name: string;
tags: string[];
}
const Contest = () => {
const { contestId } = useParams<{ contestId: string }>();
const contestIdNumber =
contestId && /^\d+$/.test(contestId) ? parseInt(contestId, 10) : null;
if (contestIdNumber === null) {
return <Navigate to="/home/contests" replace />;
}
const dispatch = useAppDispatch();
const contest = useAppSelector((state) => state.contests.selectedContest);
useEffect(() => {
dispatch(setMenuActivePage('contest'));
}, []);
useEffect(() => {
dispatch(fetchContestById(contestIdNumber));
}, [contestIdNumber]);
return (
<div>
<Routes>
<Route
path="*"
element={<ContestMissions contest={contest} />}
/>
</Routes>
</div>
);
};
export default Contest;

View File

@@ -0,0 +1,68 @@
import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from '../../../assets/icons/missions';
import { useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
export interface MissionItemProps {
id: number;
name: string;
timeLimit?: number;
memoryLimit?: number;
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,
timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024,
type,
status,
}) => {
const navigate = useNavigate();
const location = useLocation();
const path = location.pathname + location.search;
return (
<div
className={cn(
'min-h-[44px] w-full relative rounded-[10px] text-liquid-white',
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
'grid grid-cols-[80px,2fr,300px,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}?back=${path}`);
}}
>
<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="h-[24px] w-[24px]">
{status == 'error' && <img src={IconError} />}
{status == 'success' && <img src={IconSuccess} />}
</div>
</div>
);
};
export default MissionItem;

View File

@@ -0,0 +1,43 @@
import { FC } from 'react';
import MissionItem from './MissionItem';
import { Contest } from '../../../redux/slices/contests';
export interface Article {
id: number;
name: string;
tags: string[];
}
interface ContestMissionsProps {
contest: Contest | null;
}
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
if (!contest) {
return <></>;
}
return (
<div className=" h-screen grid grid-rows-[74px,1fr] p-[20px] gap-[20px]">
<div className=""></div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className="h-[40px] w-ufll ">
{contest?.name} {contest.id}
</div>
<div className="w-full">
{contest.missions.map((v, i) => (
<MissionItem
id={v.id}
name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
type={i % 2 ? 'second' : 'first'}
/>
))}
</div>
</div>
</div>
);
};
export default ContestMissions;

View File

View File

@@ -1,70 +1,122 @@
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { Account } from '../../../assets/icons/auth';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { useNavigate } from 'react-router-dom';
export interface ContestItemProps { export interface ContestItemProps {
id: number; id: number;
name: string; name: string;
authors: string[];
startAt: string; startAt: string;
registerAt: string;
duration: number; duration: number;
members: number; members: number;
statusRegister: "reg" | "nonreg"; statusRegister: 'reg' | 'nonreg';
type: "first" | "second"; type: 'first' | 'second';
} }
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, "0"); const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear(); const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0"); const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`; return `${day}/${month}/${year}\n${hours}:${minutes}`;
} }
function formatWaitTime(ms: number): string {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const ContestItem: React.FC<ContestItemProps> = ({ const ContestItem: React.FC<ContestItemProps> = ({
id, name, authors, startAt, registerAt, duration, members, statusRegister, type id,
name,
startAt,
duration,
members,
statusRegister,
type,
}) => { }) => {
const navigate = useNavigate();
const now = new Date(); const now = new Date();
const waitTime = new Date(startAt).getTime() - now.getTime(); const waitTime = new Date(startAt).getTime() - now.getTime();
return ( return (
<div className={cn("w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white", <div
waitTime <= 0 ? "grid grid-cols-6" : "grid grid-cols-7", className={cn(
"items-center font-bold text-liquid-white", 'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer',
type == "first" ? " bg-liquid-lighter" : " bg-liquid-background" waitTime <= 0 ? 'grid grid-cols-6' : 'grid grid-cols-7',
)}> 'items-center font-bold text-liquid-white',
<div className="text-left"> type == 'first'
{name} ? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
navigate(`/contest/${id}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal ">
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
valavshonok
</div> </div>
<div className="text-center text-liquid-brightmain font-normal"> <div className="text-center text-nowrap whitespace-pre-line">
{authors.map((v, i) => <p key={i}>{v}</p>)}
</div>
<div className="text-center text-nowrap">
{formatDate(startAt)} {formatDate(startAt)}
</div> </div>
<div className="text-center"> <div className="text-center">{formatWaitTime(duration)}</div>
{duration} {waitTime > 0 && (
</div> <div className="text-center whitespace-pre-line ">
{ {'До начала\n' + formatWaitTime(waitTime)}
waitTime > 0 &&
<div className="text-center">
{waitTime}
</div> </div>
} )}
<div className="text-center"> <div className="items-center justify-center flex gap-[10px] flex-row w-full">
{members} <div>{members}</div>
<img src={Account} className="h-[24px] w-[24px]" />
</div> </div>
<div className="text-center"> <div className="flex items-center justify-end">
{statusRegister} {statusRegister == 'reg' ? (
<>
{' '}
<PrimaryButton
onClick={(e) => {
e.stopPropagation();
}}
text="Регистрация"
/>
</>
) : (
<>
{' '}
<ReverseButton
onClick={(e) => {
e.stopPropagation();
}}
text="Вы записаны"
/>
</>
)}
</div> </div>
</div> </div>
); );
}; };

View File

@@ -1,129 +1,84 @@
import { useEffect } from "react"; import { useEffect, useState } from 'react';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { useAppDispatch } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import ContestsBlock from "./ContestsBlock"; import ContestsBlock from './ContestsBlock';
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchContests } from '../../../redux/slices/contests';
import ModalCreateContest from './ModalCreate';
interface Contest {
id: number;
name: string;
authors: string[];
startAt: string;
registerAt: string;
duration: number;
members: number;
statusRegister: "reg" | "nonreg";
}
const Contests = () => { const Contests = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const now = new Date(); 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",
},
// === Контесты, которые сейчас идут === const [modalActive, setModalActive] = useState<boolean>(false);
{
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",
},
// === Контесты, которые еще не начались === // Берём данные из Redux
{ const contests = useAppSelector((state) => state.contests.contests);
id: 5, const status = useAppSelector((state) => state.contests.statuses.create);
name: "Winter Warmup", const error = useAppSelector((state) => state.contests.error);
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(() => { useEffect(() => {
dispatch(setMenuActivePage("contests")) dispatch(setMenuActivePage('contests'));
dispatch(fetchContests({}));
}, []); }, []);
return ( if (status == 'loading') {
<div className=" h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]"> return (
<div className="h-full box-border"> <div className="text-liquid-white p-4">Загрузка контестов...</div>
);
}
if (error) {
return <div className="text-red-500 p-4">Ошибка: {error}</div>;
}
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="relative flex items-center mb-[20px]">
<div className={cn("h-[50px] text-[40px] font-bold text-liquid-white flex items-center")}> <div
className={cn(
'h-[50px] text-[40px] font-bold text-liquid-white flex items-center',
)}
>
Контесты Контесты
</div> </div>
<SecondaryButton <SecondaryButton
onClick={() => { }} onClick={() => {
text="Создать группу" setModalActive(true);
}}
text="Создать контест"
className="absolute right-0" className="absolute right-0"
/> />
</div> </div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"> <div className="bg-liquid-lighter h-[50px] mb-[20px]" />
</div> <ContestsBlock
className="mb-[20px]"
title="Текущие"
contests={contests.filter((contest) => {
const endTime = new Date(contest.endsAt).getTime();
return endTime >= now.getTime();
})}
/>
<ContestsBlock
<ContestsBlock className="mb-[20px]" title="Текущие" contests={contests.filter(contest => { className="mb-[20px]"
const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000; title="Прошедшие"
return endTime >= now.getTime(); contests={contests.filter((contest) => {
})} /> const endTime = new Date(contest.endsAt).getTime();
<ContestsBlock className="mb-[20px]" title="Прошедшие" contests={contests.filter(contest => { return endTime < now.getTime();
const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000; })}
return endTime < now.getTime(); />
})} />
</div> </div>
<ModalCreateContest
active={modalActive}
setActive={setModalActive}
/>
</div> </div>
); );
}; };

View File

@@ -1,63 +1,75 @@
import { useState, FC } from "react"; import { useState, FC } from 'react';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { ChevroneDown } from "../../../assets/icons/groups"; import { ChevroneDown } from '../../../assets/icons/groups';
import ContestItem from "./ContestItem"; import ContestItem from './ContestItem';
import { Contest } from '../../../redux/slices/contests';
interface ContestsBlockProps {
interface Contest {
id: number;
name: string;
authors: string[];
startAt: string;
registerAt: string;
duration: number;
members: number;
statusRegister: "reg" | "nonreg";
}
interface GroupsBlockProps {
contests: Contest[]; contests: Contest[];
title: string; title: string;
className?: string; className?: string;
} }
const ContestsBlock: FC<ContestsBlockProps> = ({
const GroupsBlock: FC<GroupsBlockProps> = ({ contests, title, className }) => { contests,
title,
className,
const [active, setActive] = useState<boolean>(title != "Скрытые"); }) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
return ( return (
<div
<div className={cn(" border-b-[1px] border-b-liquid-lighter rounded-[10px]", className={cn(
className ' 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"
)} )}
>
<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={() => { onClick={() => {
setActive(!active) setActive(!active);
}}> }}
>
<span>{title}</span> <span>{title}</span>
<img src={ChevroneDown} className={cn("transition-all duration-300", <img
active && "rotate-180" src={ChevroneDown}
)} /> className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div> </div>
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300", <div
active && "grid-rows-[1fr] opacity-100" 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="overflow-hidden">
<div className="pb-[10px] pt-[20px]"> <div className="pb-[10px] pt-[20px]">
{ {contests.map((v, i) => (
contests.map((v, i) => <ContestItem key={i} {...v} type={i % 2 ? "second" : "first"} />) <ContestItem
} key={i}
id={v.id}
name={v.name}
startAt={v.startsAt}
statusRegister={'reg'}
duration={
new Date(v.endsAt).getTime() -
new Date(v.startsAt).getTime()
}
members={v.members.length}
type={i % 2 ? 'second' : 'first'}
/>
))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default GroupsBlock; export default ContestsBlock;

View File

@@ -0,0 +1,191 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { createContest } from '../../../redux/slices/contests';
import { CreateContestBody } from '../../../redux/slices/contests';
import DateRangeInput from '../../../components/input/DateRangeInput';
interface ModalCreateContestProps {
active: boolean;
setActive: (value: boolean) => void;
}
const ModalCreateContest: FC<ModalCreateContestProps> = ({
active,
setActive,
}) => {
const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.contests.statuses.create);
const [form, setForm] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: null,
endsAt: null,
attemptDurationMinutes: null,
maxAttempts: null,
allowEarlyFinish: false,
groupId: null,
missionIds: null,
articleIds: null,
participantIds: null,
organizerIds: null,
});
useEffect(() => {
if (status === 'successful') {
setActive(false);
}
}, [status]);
const handleChange = (key: keyof CreateContestBody, value: any) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = () => {
dispatch(createContest(form));
};
return (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[550px]">
<div className="font-bold text-[30px] mb-[10px]">
Создать контест
</div>
<Input
name="name"
type="text"
label="Название"
className="mt-[10px]"
placeholder="Введите название"
onChange={(v) => handleChange('name', v)}
/>
<Input
name="description"
type="text"
label="Описание"
className="mt-[10px]"
placeholder="Введите описание"
onChange={(v) => handleChange('description', v)}
/>
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<div>
<label className="block text-sm mb-1">
Тип расписания
</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={form.scheduleType}
onChange={(e) =>
handleChange(
'scheduleType',
e.target
.value as CreateContestBody['scheduleType'],
)
}
>
<option value="AlwaysOpen">Всегда открыт</option>
<option value="FixedWindow">
Фиксированные даты
</option>
<option value="RollingWindow">
Скользящее окно
</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Видимость</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={form.visibility}
onChange={(e) =>
handleChange(
'visibility',
e.target
.value as CreateContestBody['visibility'],
)
}
>
<option value="Public">Публичный</option>
<option value="GroupPrivate">Групповой</option>
</select>
</div>
</div>
{/* Даты начала и конца */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<DateRangeInput
startValue={form.startsAt || ''}
endValue={form.endsAt || ''}
onChange={handleChange}
className="mt-[10px]"
/>
</div>
{/* Продолжительность и лимиты */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<Input
name="attemptDurationMinutes"
type="number"
label="Длительность попытки (мин)"
placeholder="Например: 60"
onChange={(v) =>
handleChange('attemptDurationMinutes', Number(v))
}
/>
<Input
name="maxAttempts"
type="number"
label="Макс. попыток"
placeholder="Например: 3"
onChange={(v) => handleChange('maxAttempts', Number(v))}
/>
</div>
{/* Разрешить раннее завершение */}
<div className="flex items-center gap-[10px] mt-[15px]">
<input
id="allowEarlyFinish"
type="checkbox"
checked={!!form.allowEarlyFinish}
onChange={(e) =>
handleChange('allowEarlyFinish', e.target.checked)
}
/>
<label htmlFor="allowEarlyFinish">
Разрешить раннее завершение
</label>
</div>
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleSubmit}
text="Создать"
disabled={status === 'loading'}
/>
<SecondaryButton
onClick={() => setActive(false)}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalCreateContest;

View File

@@ -0,0 +1,26 @@
import { FC } from 'react';
import { cn } from '../../../lib/cn';
import { useParams, Navigate } from 'react-router-dom';
interface GroupsBlockProps {}
const Group: FC<GroupsBlockProps> = () => {
const { groupId } = useParams<{ groupId: string }>();
const groupIdNumber = Number(groupId);
if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) {
return <Navigate to="/home/groups" replace />;
}
return (
<div
className={cn(
'border-b-[1px] border-b-liquid-lighter rounded-[10px]',
)}
>
{groupIdNumber}
</div>
);
};
export default Group;

View File

@@ -1,53 +1,82 @@
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { Book, UserAdd, Edit, EyeClosed, EyeOpen } from "../../../assets/icons/groups"; import {
Book,
UserAdd,
Edit,
EyeClosed,
EyeOpen,
} from '../../../assets/icons/groups';
import { useNavigate } from 'react-router-dom';
import { GroupUpdate } from './Groups';
export interface GroupItemProps { export interface GroupItemProps {
id: number; id: number;
role: "menager" | "member" | "owner" | "viewer"; role: 'menager' | 'member' | 'owner' | 'viewer';
visible: boolean; visible: boolean;
name: string; name: string;
description: string;
setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void;
} }
interface IconComponentProps { interface IconComponentProps {
src: string; src: string;
onClick?: () => void;
} }
const IconComponent: React.FC<IconComponentProps> = ({ const IconComponent: React.FC<IconComponentProps> = ({ src, onClick }) => {
src return (
}) => { <img
src={src}
return <img onClick={(e) => {
src={src} e.stopPropagation();
className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300" if (onClick) onClick();
/> }}
} className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300"
/>
);
};
const GroupItem: React.FC<GroupItemProps> = ({ const GroupItem: React.FC<GroupItemProps> = ({
id, name, visible, role id,
name,
visible,
role,
description,
setUpdateGroup,
setUpdateActive,
}) => { }) => {
const navigate = useNavigate();
return ( return (
<div className={cn("w-full h-[120px] box-border relative rounded-[10px] p-[10px] text-liquid-white bg-liquid-lighter", <div
)}> className={cn(
'w-full h-[120px] box-border relative rounded-[10px] p-[10px] text-liquid-white bg-liquid-lighter cursor-pointer',
)}
onClick={() => navigate(`/group/${id}`)}
>
<div className="grid grid-cols-[100px,1fr] gap-[20px]"> <div className="grid grid-cols-[100px,1fr] gap-[20px]">
<img src={Book} className="bg-liquid-brightmain rounded-[10px]"/> <img
src={Book}
className="bg-liquid-brightmain rounded-[10px]"
/>
<div className="grid grid-flow-row grid-rows-[1fr,24px]"> <div className="grid grid-flow-row grid-rows-[1fr,24px]">
<div className="text-[18px] font-bold"> <div className="text-[18px] font-bold">{name}</div>
{name}
</div>
<div className=" flex gap-[10px]"> <div className=" flex gap-[10px]">
{ {(role == 'menager' || role == 'owner') && (
(role == "menager" || role == "owner") && <IconComponent src={UserAdd}/> <IconComponent src={UserAdd} />
} )}
{ {(role == 'menager' || role == 'owner') && (
(role == "menager" || role == "owner") && <IconComponent src={Edit}/> <IconComponent
} src={Edit}
{ onClick={() => {
visible == false && <IconComponent src={EyeOpen} /> setUpdateGroup({ id, name, description });
} setUpdateActive(true);
{ }}
visible == true && <IconComponent src={EyeClosed} /> />
} )}
{visible == false && <IconComponent src={EyeOpen} />}
{visible == true && <IconComponent src={EyeClosed} />}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,69 +1,124 @@
import { useEffect } from "react"; import { useEffect, useMemo, useState } from 'react';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { useAppDispatch } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import GroupsBlock from "./GroupsBlock"; import GroupsBlock from './GroupsBlock';
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchMyGroups } from '../../../redux/slices/groups';
import ModalCreate from './ModalCreate';
import ModalUpdate from './ModalUpdate';
export interface GroupUpdate {
export interface Group {
id: number; id: number;
role: "menager" | "member" | "owner" | "viewer";
visible: boolean;
name: string; name: string;
description: string;
} }
const Groups = () => { const Groups = () => {
const [modalActive, setModalActive] = useState<boolean>(false);
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false);
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
id: 0,
name: '',
description: '',
});
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const groups: Group[] = [ // Берём группы из стора
{ id: 1, role: "owner", name: "Main Administration", visible: true }, const groups = useAppSelector((store) => store.groups.groups);
{ 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 }, const currentUserName = useAppSelector((store) => store.auth.username);
{ 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(() => { useEffect(() => {
dispatch(setMenuActivePage("groups")) dispatch(setMenuActivePage('groups'));
}, []); dispatch(fetchMyGroups());
}, [dispatch]);
// Разделяем группы
const { managedGroups, currentGroups, hiddenGroups } = useMemo(() => {
if (!groups || !currentUserName) {
return { managedGroups: [], currentGroups: [], hiddenGroups: [] };
}
const managed: typeof groups = [];
const current: typeof groups = [];
const hidden: typeof groups = []; // пока пустые, без логики
groups.forEach((group) => {
const me = group.members.find(
(m) => m.username === currentUserName,
);
if (!me) return;
if (me.role === 'Administrator') {
managed.push(group);
} else {
current.push(group);
}
});
return {
managedGroups: managed,
currentGroups: current,
hiddenGroups: hidden,
};
}, [groups, currentUserName]);
return ( return (
<div className=" h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]"> <div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
<div className="h-full box-border"> <div className="h-full box-border">
<div className="relative flex items-center mb-[20px]"> <div className="relative flex items-center mb-[20px]">
<div className={cn("h-[50px] text-[40px] font-bold text-liquid-white flex items-center")}> <div
className={cn(
'h-[50px] text-[40px] font-bold text-liquid-white flex items-center',
)}
>
Группы Группы
</div> </div>
<SecondaryButton <SecondaryButton
onClick={() => { }} onClick={() => {
setModalActive(true);
}}
text="Создать группу" text="Создать группу"
className="absolute right-0" className="absolute right-0"
/> />
</div> </div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"> <div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
</div> <GroupsBlock
className="mb-[20px]"
title="Управляемые"
<GroupsBlock className="mb-[20px]" title="Управляемые" groups={groups.filter((v) => v.visible && (v.role == "owner" || v.role == "menager"))} /> groups={managedGroups}
<GroupsBlock className="mb-[20px]" title="Текущие" groups={groups.filter((v) => v.visible && (v.role == "member" || v.role == "viewer"))} /> setUpdateActive={setModalUpdateActive}
<GroupsBlock className="mb-[20px]" title="Скрытые" groups={groups.filter((v) => v.visible == false)} /> setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
title="Текущие"
groups={currentGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
title="Скрытые"
groups={hiddenGroups} // пока пусто
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
</div> </div>
<ModalCreate setActive={setModalActive} active={modalActive} />
<ModalUpdate
setActive={setModalUpdateActive}
active={modelUpdateActive}
groupId={updateGroup.id}
groupName={updateGroup.name}
groupDescription={updateGroup.description}
/>
</div> </div>
); );
}; };

View File

@@ -1,54 +1,72 @@
import { useState, FC } from "react"; import { useState, FC } from 'react';
import GroupItem from "./GroupItem"; import GroupItem from './GroupItem';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { ChevroneDown } from "../../../assets/icons/groups"; import { ChevroneDown } from '../../../assets/icons/groups';
import { Group } from '../../../redux/slices/groups';
import { GroupUpdate } from './Groups';
export interface Group {
id: number;
role: "menager" | "member" | "owner" | "viewer";
visible: boolean;
name: string;
}
interface GroupsBlockProps { interface GroupsBlockProps {
groups: Group[]; groups: Group[];
title: string; title: string;
className?: string; className?: string;
setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void;
} }
const GroupsBlock: FC<GroupsBlockProps> = ({
const GroupsBlock: FC<GroupsBlockProps> = ({ groups, title, className }) => { groups,
title,
className,
const [active, setActive] = useState<boolean>(title != "Скрытые"); setUpdateActive,
setUpdateGroup,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
return ( return (
<div
<div className={cn(" border-b-[1px] border-b-liquid-lighter rounded-[10px]", className={cn(
className ' 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"
)} )}
>
<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={() => { onClick={() => {
setActive(!active) setActive(!active);
}}> }}
>
<span>{title}</span> <span>{title}</span>
<img src={ChevroneDown} className={cn("transition-all duration-300", <img
active && "rotate-180" src={ChevroneDown}
)}/> className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div> </div>
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300", <div
active && "grid-rows-[1fr] opacity-100" 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="overflow-hidden">
<div className="grid grid-cols-3 gap-[20px] pt-[20px] pb-[20px] box-border"> <div className="grid grid-cols-3 gap-[20px] pt-[20px] pb-[20px] box-border">
{ {groups.map((v, i) => (
groups.map((v, i) => <GroupItem key={i} {...v} />) <GroupItem
} key={i}
id={v.id}
visible={true}
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}
role={'owner'}
name={v.name}
/>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,78 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { createGroup } from '../../../redux/slices/groups';
interface ModalCreateProps {
active: boolean;
setActive: (value: boolean) => void;
}
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const status = useAppSelector((state) => state.groups.statuses.create);
const dispatch = useAppDispatch();
useEffect(() => {
if (status == 'successful') {
setActive(false);
}
}, [status]);
return (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]">
<div className="font-bold text-[30px]">Создать группу</div>
<Input
name="name"
autocomplete="name"
className="mt-[10px]"
type="text"
label="Название"
onChange={(v) => {
setName(v);
}}
placeholder="login"
/>
<Input
name="description"
autocomplete="description"
className="mt-[10px]"
type="text"
label="Описание"
onChange={(v) => {
setDescription(v);
}}
placeholder="login"
/>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(createGroup({ name, description }));
}}
text="Создать"
disabled={status == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalCreate;

View File

@@ -0,0 +1,112 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { deleteGroup, updateGroup } from '../../../redux/slices/groups';
interface ModalUpdateProps {
active: boolean;
setActive: (value: boolean) => void;
groupId: number;
groupName: string;
groupDescription: string;
}
const ModalUpdate: FC<ModalUpdateProps> = ({
active,
setActive,
groupName,
groupId,
groupDescription,
}) => {
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const statusUpdate = useAppSelector(
(state) => state.groups.statuses.update,
);
const statusDelete = useAppSelector(
(state) => state.groups.statuses.delete,
);
const dispatch = useAppDispatch();
useEffect(() => {
if (statusUpdate == 'successful') {
setActive(false);
}
}, [statusUpdate]);
useEffect(() => {
if (statusDelete == 'successful') {
setActive(false);
}
}, [statusDelete]);
return (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]">
<div className="font-bold text-[30px]">
Изменить группу {groupName} #{groupId}
</div>
<Input
name="name"
autocomplete="name"
className="mt-[10px]"
type="text"
label="Новое название"
defaultState={groupName}
onChange={(v) => {
setName(v);
}}
placeholder="login"
/>
<Input
name="description"
autocomplete="description"
className="mt-[10px]"
type="text"
label="Описание"
onChange={(v) => {
setDescription(v);
}}
placeholder="login"
defaultState={groupDescription}
/>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(deleteGroup(groupId));
}}
text="Удалить"
disabled={statusDelete == 'loading'}
color="error"
/>
<PrimaryButton
onClick={() => {
dispatch(
updateGroup({ name, description, groupId }),
);
}}
text="Обновить"
disabled={statusUpdate == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalUpdate;

View File

@@ -1,29 +1,63 @@
import { Logo } from "../../../assets/logos"; import { Logo } from '../../../assets/logos';
import {Account, Clipboard, Cup, Home, Openbook, Users} from "../../../assets/icons/menu"; import {
import MenuItem from "./MenuItem"; Account,
import { useAppSelector } from "../../../redux/hooks"; Clipboard,
Cup,
Home,
Openbook,
Users,
} from '../../../assets/icons/menu';
import MenuItem from './MenuItem';
import { useAppSelector } from '../../../redux/hooks';
const Menu = () => { const Menu = () => {
const menuItems = [ const menuItems = [
{text: "Главная", href: "/home", icon: Home, page: "home" }, { text: 'Главная', href: '/home', icon: Home, page: 'home' },
{text: "Задачи", href: "/home/missions", icon: Clipboard, page: "missions" }, {
{text: "Статьи", href: "/home/articles", icon: Openbook, page: "articles" }, text: 'Задачи',
{text: "Группы", href: "/home/groups", icon: Users, page: "groups" }, href: '/home/missions',
{text: "Контесты", href: "/home/contests", icon: Cup, page: "contests" }, icon: Clipboard,
{text: "Аккаунт", href: "/home/account", icon: Account, page: "account" }, page: 'missions',
]; },
const activePage = useAppSelector((state) => state.store.menu.activePage); {
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 ( return (
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]"> <div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
<img src={Logo} className="w-[173px]" /> <img src={Logo} className="w-[173px]" />
<div className=""> <div className="">
{menuItems.map((v, i) => ( {menuItems.map((v, i) => (
<MenuItem key={i} icon={v.icon} text={v.text} href={v.href} active={v.page == activePage} page={v.page}/> <MenuItem
))} key={i}
</div> icon={v.icon}
</div> text={v.text}
); href={v.href}
active={v.page == activePage}
page={v.page}
/>
))}
</div>
</div>
);
}; };
export default Menu; export default Menu;

View File

@@ -1,37 +1,44 @@
import React from "react"; import React from 'react';
import { Link } from "react-router-dom"; import { Link } from 'react-router-dom';
import { useAppDispatch } from "../../../redux/hooks"; import { useAppDispatch } from '../../../redux/hooks';
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
interface MenuItemProps { interface MenuItemProps {
icon: string; // SVG или любой JSX icon: string; // SVG или любой JSX
text: string; text: string;
href: string; href: string;
page: string; page: string;
active?: boolean; // необязательный, по умолчанию false active?: boolean; // необязательный, по умолчанию false
} }
const MenuItem: React.FC<MenuItemProps> = ({ icon, text = "", href = "", active = false, page = "" }) => { const MenuItem: React.FC<MenuItemProps> = ({
const dispatch = useAppDispatch(); icon,
text = '',
href = '',
active = false,
page = '',
}) => {
const dispatch = useAppDispatch();
return ( return (
<Link <Link
to={href} to={href}
className={` className={`
flex items-center gap-3 p-[16px] rounded-[10px\] h-[40px] text-[18px] font-bold flex items-center gap-3 p-[16px] rounded-[10px] h-[40px] text-[18px] font-bold
transition-all duration-300 text-liquid-white mt-[20px] transition-all duration-300 text-liquid-white mt-[20px]
active:scale-95 active:scale-95
${active ? "bg-liquid-darkmain hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-darkmain hover:ring-inset" ${
: " hover:bg-liquid-lighter"} active
? 'bg-liquid-darkmain hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-darkmain hover:ring-inset'
: ' hover:bg-liquid-lighter'
}
`} `}
onClick={ onClick={() => dispatch(setMenuActivePage(page))}
() => dispatch(setMenuActivePage(page)) >
} <img src={icon} />
> <span>{text}</span>
<img src={icon} /> </Link>
<span>{text}</span> );
</Link>
);
}; };
export default MenuItem; export default MenuItem;

View File

@@ -1,71 +1,78 @@
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from "../../../assets/icons/missions"; import { IconError, IconSuccess } from '../../../assets/icons/missions';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
export interface MissionItemProps { export interface MissionItemProps {
id: number; id: number;
authorId: number; authorId?: number;
name: string; name: string;
difficulty: "Easy" | "Medium" | "Hard"; difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[]; tags?: string[];
timeLimit: number; timeLimit?: number;
memoryLimit: number; memoryLimit?: number;
createdAt: string; createdAt?: string;
updatedAt: string; updatedAt?: string;
type: "first" | "second"; type?: 'first' | 'second';
status: "empty" | "success" | "error"; status?: 'empty' | 'success' | 'error';
} }
export function formatMilliseconds(ms: number): string { export function formatMilliseconds(ms: number): string {
const rounded = Math.round(ms) / 1000; const rounded = Math.round(ms) / 1000;
const formatted = rounded.toString().replace(/\.?0+$/, ''); const formatted = rounded.toString().replace(/\.?0+$/, '');
return `${formatted} c`; return `${formatted} c`;
} }
export function formatBytesToMB(bytes: number): string { export function formatBytesToMB(bytes: number): string {
const megabytes = Math.floor(bytes / (1024 * 1024)); const megabytes = Math.floor(bytes / (1024 * 1024));
return `${megabytes} МБ`; return `${megabytes} МБ`;
} }
const MissionItem: React.FC<MissionItemProps> = ({ const MissionItem: React.FC<MissionItemProps> = ({
id, name, difficulty, timeLimit, memoryLimit, type, status id,
name,
difficulty,
timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024,
type,
status,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className={cn("h-[44px] w-full relative rounded-[10px] text-liquid-white", <div
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background", className={cn(
"grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center", 'min-h-[44px] w-full relative rounded-[10px] text-liquid-white',
status == "error" && "border-l-[11px] border-l-liquid-red pl-[9px]", type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]", 'grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center',
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300", status == 'error' &&
)} 'border-l-[11px] border-l-liquid-red pl-[9px]',
onClick={() => {navigate(`/mission/${id}`)}} 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"> <div className="text-[18px] font-bold">#{id}</div>
#{id} <div className="text-[18px] font-bold">{name}</div>
</div>
<div className="text-[18px] font-bold">
{name}
</div>
<div className="text-[12px] text-right"> <div className="text-[12px] text-right">
стандартный ввод/вывод {formatMilliseconds(timeLimit)}, {formatBytesToMB(memoryLimit)} стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
{formatBytesToMB(memoryLimit)}
</div> </div>
<div className={cn( <div
"text-center text-[18px]", className={cn(
difficulty == "Hard" && "text-liquid-red", 'text-center text-[18px]',
difficulty == "Medium" && "text-liquid-orange", difficulty == 'Hard' && 'text-liquid-red',
difficulty == "Easy" && "text-liquid-green", difficulty == 'Medium' && 'text-liquid-orange',
)}> difficulty == 'Easy' && 'text-liquid-green',
)}
>
{difficulty} {difficulty}
</div> </div>
<div className="h-[24px] w-[24px]"> <div className="h-[24px] w-[24px]">
{ {status == 'error' && <img src={IconError} />}
status == "error" && <img src={IconError}/> {status == 'success' && <img src={IconSuccess} />}
}
{
status == "success" && <img src={IconSuccess}/>
}
</div> </div>
</div> </div>
); );

View File

@@ -1,17 +1,16 @@
import MissionItem from "./MissionItem"; import MissionItem from './MissionItem';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useEffect } from "react"; import { useEffect, useState } from 'react';
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { useNavigate } from "react-router-dom"; import { fetchMissions } from '../../../redux/slices/missions';
import { fetchMissions } from "../../../redux/slices/missions"; import ModalCreate from './ModalCreate';
export interface Mission { export interface Mission {
id: number; id: number;
authorId: number; authorId: number;
name: string; name: string;
difficulty: "Easy" | "Medium" | "Hard"; difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[]; tags: string[];
timeLimit: number; timeLimit: number;
memoryLimit: number; memoryLimit: number;
@@ -20,61 +19,63 @@ export interface Mission {
} }
const Missions = () => { const Missions = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const naivgate = useNavigate(); const [modalActive, setModalActive] = useState<boolean>(false);
const missions = useAppSelector((state) => state.missions.missions); const missions = useAppSelector((state) => state.missions.missions);
useEffect(() => { useEffect(() => {
dispatch(setMenuActivePage("missions")) dispatch(setMenuActivePage('missions'));
dispatch(fetchMissions({})) dispatch(fetchMissions({}));
}, []); }, []);
return ( return (
<div className=" h-full w-full box-border p-[20px] pt-[20px]"> <div className=" h-full w-full box-border p-[20px] pt-[20px]">
<div className="h-full box-border"> <div className="h-full box-border">
<div className="relative flex items-center mb-[20px]"> <div className="relative flex items-center mb-[20px]">
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center"> <div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
Задачи Задачи
</div> </div>
<SecondaryButton <SecondaryButton
onClick={() => {naivgate("/upload")}} onClick={() => {
text="Создать задачу" setModalActive(true);
}}
text="Добавить задачу"
className="absolute right-0" className="absolute right-0"
/> />
</div> </div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"> <div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
</div>
<div> <div>
{missions.map((v, i) => (
{missions.map((v, i) => ( <MissionItem
<MissionItem key={i}
key={i} id={v.id}
id={v.id} authorId={v.authorId}
authorId={v.authorId} name={v.name}
name={v.name} difficulty={'Easy'}
difficulty={"Easy"} tags={v.tags}
tags={v.tags} timeLimit={1000}
timeLimit={1000} memoryLimit={256 * 1024 * 1024}
memoryLimit={256 * 1024 * 1024} createdAt={v.createdAt}
createdAt={v.createdAt} updatedAt={v.updatedAt}
updatedAt={v.updatedAt} type={i % 2 == 0 ? 'first' : 'second'}
type={i % 2 == 0 ? "first" : "second"} status={
status={i == 0 || i == 3 || i == 7 ? "success" : (i == 2 || i == 4 || i == 9 ? "error" : "empty")}/> i == 0 || i == 3 || i == 7
))} ? 'success'
: i == 2 || i == 4 || i == 9
? 'error'
: 'empty'
}
/>
))}
</div> </div>
<div>pages</div>
<div>
pages
</div>
</div> </div>
<ModalCreate setActive={setModalActive} active={modalActive} />
</div> </div>
); );
}; };

View File

@@ -0,0 +1,169 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import {
setMissionsStatus,
uploadMission,
} from '../../../redux/slices/missions';
interface ModalCreateProps {
active: boolean;
setActive: (value: boolean) => void;
}
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>('');
const [difficulty, setDifficulty] = useState<number>(1);
const [file, setFile] = useState<File | null>(null);
const [tagInput, setTagInput] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const status = useAppSelector((state) => state.missions.statuses.upload);
const dispatch = useAppDispatch();
const addTag = () => {
const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]);
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
}
};
const handleSubmit = async () => {
if (!file) return alert('Выберите файл миссии!');
dispatch(uploadMission({ file, name, difficulty, tags }));
};
useEffect(() => {
if (status === 'successful') {
alert('Миссия успешно загружена!');
setName('');
setDifficulty(1);
setTags([]);
setFile(null);
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
setActive(false);
}
}, [status]);
useEffect(() => {
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
}, [active]);
return (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]">
<div className="font-bold text-[30px]">Добавить задачу</div>
<Input
name="name"
autocomplete="name"
className="mt-[10px]"
type="text"
label="Название"
defaultState={name}
onChange={setName}
placeholder="В яблочко"
/>
<Input
name="difficulty"
autocomplete="difficulty"
className="mt-[10px]"
type="number"
label="Сложность"
defaultState={'' + difficulty}
onChange={(v) => setDifficulty(Number(v))}
placeholder="1"
/>
<div className="mt-4">
<label className="block mb-2">Файл задачи</label>
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 bg-liquid-lighter hover:bg-liquid-dark transition-colors rounded-[10px] text-liquid-white font-medium shadow-md">
{file ? file.name : 'Выбрать файл'}
<input
type="file"
onChange={handleFileChange}
accept=".zip"
className="hidden"
/>
</label>
</div>
{/* Теги */}
<div className="mb-[50px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
name="articleTag"
autocomplete="articleTag"
className="mt-[20px] max-w-[600px]"
type="text"
label="Теги"
onChange={(v) => setTagInput(v)}
defaultState={tagInput}
placeholder="arrays"
onKeyDown={(e) => {
if (e.key === 'Enter') addTag();
}}
/>
<PrimaryButton
onClick={addTag}
text="Добавить"
className="h-[40px] w-[140px]"
/>
</div>
<div className="flex flex-wrap gap-[10px] mt-2">
{tags.map((tag) => (
<div
key={tag}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
>
<span>{tag}</span>
<button
onClick={() => removeTag(tag)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div>
))}
</div>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleSubmit}
text={status === 'loading' ? 'Загрузка...' : 'Создать'}
disabled={status === 'loading'}
/>
<SecondaryButton
onClick={() => setActive(false)}
text="Отмена"
/>
</div>
{status == 'failed' && <div>error</div>}
</div>
</Modal>
);
};
export default ModalCreate;

View File

@@ -1,101 +0,0 @@
import React, { useState } from "react";
import { useAppDispatch, useAppSelector } from "../../redux/hooks";
import { uploadMission } from "../../redux/slices/missions";
const UploadMissionForm: React.FC = () => {
const dispatch = useAppDispatch();
const { status, error } = useAppSelector(state => state.missions);
// Локальные состояния формы
const [name, setName] = useState("");
const [difficulty, setDifficulty] = useState(1);
const [tags, setTags] = useState<string[]>([]);
const [tagsValue, setTagsValue] = useState<string>("");
const [file, setFile] = useState<File | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return alert("Выберите файл миссии!");
try {
dispatch(uploadMission({ file, name, difficulty, tags }));
alert("Миссия успешно загружена!");
setName("");
setDifficulty(1);
setTags([]);
setFile(null);
} catch (err) {
console.error(err);
alert("Ошибка при загрузке миссии: " + err);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
}
};
const handleTagsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTagsValue(e.target.value);
const value = e.target.value;
const tagsArray = value.split(",").map(tag => tag.trim()).filter(tag => tag);
setTags(tagsArray);
};
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-4 border rounded">
<div className="mb-4">
<label className="block mb-1">Название миссии</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full border px-2 py-1"
required
/>
</div>
<div className="mb-4">
<label className="block mb-1">Сложность</label>
<input
type="number"
value={difficulty}
min={1}
max={5}
onChange={e => setDifficulty(Number(e.target.value))}
className="w-full border px-2 py-1"
required
/>
</div>
<div className="mb-4">
<label className="block mb-1">Теги (через запятую)</label>
<input
type="text"
value={tagsValue}
onChange={handleTagsChange}
className="w-full border px-2 py-1"
/>
</div>
<div className="mb-4">
<label className="block mb-1">Файл миссии</label>
<input type="file" onChange={handleFileChange} accept=".zip" required />
</div>
<button
type="submit"
disabled={status === "loading"}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{status === "loading" ? "Загрузка..." : "Загрузить миссию"}
</button>
{status === "failed" && error && <p className="text-red-500 mt-2">{error}</p>}
</form>
);
};
export default UploadMissionForm;

View File

@@ -1,141 +1,153 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
import Editor from "@monaco-editor/react"; import Editor from '@monaco-editor/react';
import { upload } from "../../../assets/icons/input"; import { upload } from '../../../assets/icons/input';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { DropDownList } from "../../../components/drop-down-list/DropDownList"; import { DropDownList } from '../../../components/drop-down-list/DropDownList';
const languageMap: Record<string, string> = { const languageMap: Record<string, string> = {
c: "cpp", c: 'cpp',
cpp: "cpp", 'C++': 'cpp',
java: "java", java: 'java',
python: "python", python: 'python',
pascal: "pascal", pascal: 'pascal',
kotlin: "kotlin", kotlin: 'kotlin',
csharp: "csharp" csharp: 'csharp',
}; };
export interface CodeEditorProps { export interface CodeEditorProps {
onChange: (value: string) => void; onChange: (value: string) => void;
onChangeLanguage: (value: string) => void; onChangeLanguage: (value: string) => void;
} }
const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) => { const CodeEditor: React.FC<CodeEditorProps> = ({
const [language, setLanguage] = useState<string>("C++"); onChange,
const [code, setCode] = useState<string>(""); onChangeLanguage,
const [isDragging, setIsDragging] = useState<boolean>(false); }) => {
const [language, setLanguage] = useState<string>('C++');
const [code, setCode] = useState<string>('');
const [isDragging, setIsDragging] = useState<boolean>(false);
const items = [
{ value: 'c', text: 'C' },
{ value: 'C++', text: 'C++' },
{ value: 'java', text: 'Java' },
{ value: 'python', text: 'Python' },
{ value: 'pascal', text: 'Pascal' },
{ value: 'kotlin', text: 'Kotlin' },
{ value: 'csharp', text: 'C#' },
];
const items = [ useEffect(() => {
{ value: "c", text: "C" }, onChange(code);
{ value: "C++", text: "C++" }, }, [code]);
{ value: "java", text: "Java" }, useEffect(() => {
{ value: "python", text: "Python" }, onChangeLanguage(language);
{ value: "pascal", text: "Pascal" }, }, [language]);
{ value: "kotlin", text: "Kotlin" },
{ value: "csharp", text: "C#" },
];
useEffect(() => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(code); const file = e.target.files?.[0];
}, [code]) if (!file) return;
useEffect(() => {
onChangeLanguage(language);
}, [language])
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const reader = new FileReader();
const file = e.target.files?.[0]; reader.onload = (event) => {
if (!file) return; const text = event.target?.result;
if (typeof text === 'string') setCode(text);
const reader = new FileReader(); };
reader.onload = (event) => { reader.readAsText(file);
const text = event.target?.result; e.target.value = '';
if (typeof text === "string") setCode(text);
}; };
reader.readAsText(file);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => { const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
const droppedFile = e.dataTransfer.files[0]; const droppedFile = e.dataTransfer.files[0];
if (!droppedFile) return; if (!droppedFile) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result; const text = event.target?.result;
if (typeof text === "string") setCode(text); if (typeof text === 'string') setCode(text);
};
reader.readAsText(droppedFile);
}; };
reader.readAsText(droppedFile);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => { const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault(); // обязательно e.preventDefault(); // обязательно
}; };
const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>) => { const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault(); e.preventDefault();
setIsDragging(true); setIsDragging(true);
}; };
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => { const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
}; };
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
{/* Панель выбора языка и загрузки файла */} {/* Панель выбора языка и загрузки файла */}
<div className="flex items-center justify-between py-3 "> <div className="flex items-center justify-between py-3 ">
<div className="flex items-center gap-[20px]"> <div className="flex items-center gap-[20px]">
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/> <DropDownList
items={items}
onChange={(v) => {
setLanguage(v);
}}
defaultState={{ value: 'C++', text: 'C++' }}
/>
<label <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%]", className={cn(
isDragging && "outline-blue-500 " '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%]',
)} isDragging && 'outline-blue-500 ',
onDrop={handleDrop} )}
onDragOver={handleDragOver} onDrop={handleDrop}
onDragEnter={handleDragEnter} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragEnter={handleDragEnter}
> onDragLeave={handleDragLeave}
<span className="text-[18px] text-liquid-white font-bold pointer-events-none"> >
{"Загрузить решение"} <span className="text-[18px] text-liquid-white font-bold pointer-events-none">
</span> {'Загрузить решение'}
<img src={upload} className="absolute right-[16px] pointer-events-none" /> </span>
<input <img
type="file" src={upload}
onChange={(e) => handleFileUpload(e)} className="absolute right-[16px] pointer-events-none"
className="hidden" />
/> <input
</label> type="file"
onChange={(e) => handleFileUpload(e)}
className="hidden"
/>
</label>
</div>
</div>
{/* Monaco Editor */}
<div className="bg-[#1E1E1E] py-[10px] h-full rounded-[10px]">
<Editor
width="100%"
height="100%"
language={languageMap[language]}
value={code}
onChange={(value) => setCode(value ?? '')}
theme="vs-dark"
options={{
fontSize: 14,
minimap: { enabled: false },
automaticLayout: true,
quickSuggestions: true,
suggestOnTriggerCharacters: true,
tabSize: 4,
insertSpaces: true,
detectIndentation: false,
autoIndent: 'full',
}}
/>
</div>
</div> </div>
</div> );
{/* Monaco Editor */}
<div className="bg-[#1E1E1E] py-[10px] h-full rounded-[10px]">
<Editor
width="100%"
height="100%"
language={languageMap[language]}
value={code}
onChange={(value) => setCode(value ?? "")}
theme="vs-dark"
options={{
fontSize: 14,
minimap: { enabled: false },
automaticLayout: true,
quickSuggestions: true,
suggestOnTriggerCharacters: true,
tabSize: 4,
insertSpaces: true,
detectIndentation: false,
autoIndent: "full",
}}
/>
</div>
</div>
);
}; };
export default CodeEditor; export default CodeEditor;

View File

@@ -1,28 +1,64 @@
import React from "react"; import React from 'react';
import { chevroneLeft, chevroneRight, arrowLeft } from "../../../assets/icons/header"; import {
import { Logo } from "../../../assets/logos"; chevroneLeft,
import { useNavigate } from "react-router-dom"; chevroneRight,
arrowLeft,
} from '../../../assets/icons/header';
import { Logo } from '../../../assets/logos';
import { useNavigate } from 'react-router-dom';
interface HeaderProps { interface HeaderProps {
missionId: number; missionId: number;
back?: string;
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
missionId
}) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]"> <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={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") }} /> <img
src={arrowLeft}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
if (back) navigate(back);
else navigate('/home/missions');
}}
/>
<div className="flex gap-[10px]"> <div className="flex gap-[10px]">
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} /> <img
src={chevroneLeft}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
if (missionId <= 1) return;
if (back)
navigate(`/mission/${missionId - 1}?back=${back}`);
else navigate(`/mission/${missionId - 1}`);
}}
/>
<span>{missionId}</span> <span>{missionId}</span>
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} /> <img
src={chevroneRight}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
if (back)
navigate(`/mission/${missionId + 1}?back=${back}`);
else navigate(`/mission/${missionId + 1}`);
}}
/>
</div> </div>
</header> </header>
); );
}; };

View File

@@ -1,113 +1,131 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
declare global { declare global {
interface Window { interface Window {
MathJax?: { MathJax?: {
startup?: { promise?: Promise<void> }; startup?: { promise?: Promise<void> };
typesetPromise?: (elements?: Element[]) => Promise<void>; typesetPromise?: (elements?: Element[]) => Promise<void>;
[key: string]: any; [key: string]: any;
}; };
} }
} }
interface MediaFile { interface MediaFile {
id: number; id: number;
fileName: string; fileName: string;
mediaUrl: string; mediaUrl: string;
} }
interface LaTextContainerProps { interface LaTextContainerProps {
html: string; html: string;
latex: string; latex: string;
mediaFiles?: MediaFile[]; mediaFiles?: MediaFile[];
} }
let mathJaxPromise: Promise<void> | null = null; let mathJaxPromise: Promise<void> | null = null;
const loadMathJax = () => { const loadMathJax = () => {
if (mathJaxPromise) return mathJaxPromise; if (mathJaxPromise) return mathJaxPromise;
mathJaxPromise = new Promise<void>((resolve, reject) => { mathJaxPromise = new Promise<void>((resolve, reject) => {
if (window.MathJax?.typesetPromise) { if (window.MathJax?.typesetPromise) {
resolve(); resolve();
return; return;
} }
(window as any).MathJax = { (window as any).MathJax = {
tex: { tex: {
inlineMath: [["$$$", "$$$"]], inlineMath: [['$$$', '$$$']],
displayMath: [["$$$$$$", "$$$$$$"]], displayMath: [['$$$$$$', '$$$$$$']],
processEscapes: true, processEscapes: true,
}, },
options: { options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"], skipHtmlTags: [
}, 'script',
startup: { typeset: false }, 'noscript',
}; 'style',
'textarea',
'pre',
'code',
],
},
startup: { typeset: false },
};
const script = document.createElement("script"); const script = document.createElement('script');
script.id = "mathjax-script"; script.id = 'mathjax-script';
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"; script.src =
script.async = true; 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
script.async = true;
script.onload = () => { script.onload = () => {
window.MathJax?.startup?.promise?.then(resolve).catch(reject); window.MathJax?.startup?.promise?.then(resolve).catch(reject);
}; };
script.onerror = reject; script.onerror = reject;
document.head.appendChild(script); document.head.appendChild(script);
}); });
return mathJaxPromise; return mathJaxPromise;
}; };
const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) => { const replaceImages = (
const parser = new DOMParser(); html: string,
const doc = parser.parseFromString(html, "text/html"); latex: string,
mediaFiles?: MediaFile[],
) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const latexImageNames = Array.from(latex.matchAll(/\\includegraphics\{(.+?)\}/g)).map( const latexImageNames = Array.from(
(match) => match[1] latex.matchAll(/\\includegraphics\{(.+?)\}/g),
); ).map((match) => match[1]);
const imgs = doc.querySelectorAll<HTMLImageElement>("img.tex-graphics"); const imgs = doc.querySelectorAll<HTMLImageElement>('img.tex-graphics');
imgs.forEach((img, idx) => { imgs.forEach((img, idx) => {
const imageName = latexImageNames[idx]; const imageName = latexImageNames[idx];
if (!imageName || !mediaFiles) return; if (!imageName || !mediaFiles) return;
const mediaFile = mediaFiles.find((f) => f.fileName === imageName); const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
if (mediaFile) img.src = mediaFile.mediaUrl; if (mediaFile) img.src = mediaFile.mediaUrl;
}); });
return doc.body.innerHTML; return doc.body.innerHTML;
}; };
const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFiles }) => { const LaTextContainer: React.FC<LaTextContainerProps> = ({
const containerRef = useRef<HTMLDivElement>(null); html,
const [processedHtml, setProcessedHtml] = useState<string>(html); latex,
mediaFiles,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedHtml, setProcessedHtml] = useState<string>(html);
// 1⃣ Обновляем HTML при изменении входных данных // 1⃣ Обновляем HTML при изменении входных данных
useEffect(() => { useEffect(() => {
setProcessedHtml(replaceImages(html, latex, mediaFiles)); setProcessedHtml(replaceImages(html, latex, mediaFiles));
}, [html, latex, mediaFiles]); }, [html, latex, mediaFiles]);
// 2⃣ После рендера обновленного HTML применяем MathJax // 2⃣ После рендера обновленного HTML применяем MathJax
useEffect(() => { useEffect(() => {
const renderMath = () => { const renderMath = () => {
if (containerRef.current && window.MathJax?.typesetPromise) { if (containerRef.current && window.MathJax?.typesetPromise) {
window.MathJax.typesetPromise([containerRef.current]).catch(console.error); window.MathJax.typesetPromise([containerRef.current]).catch(
} console.error,
}; );
}
};
loadMathJax().then(renderMath).catch(console.error); loadMathJax().then(renderMath).catch(console.error);
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml }, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
return ( return (
<div <div
className="latex-container" className="latex-container"
ref={containerRef} ref={containerRef}
dangerouslySetInnerHTML={{ __html: processedHtml }} dangerouslySetInnerHTML={{ __html: processedHtml }}
/> />
); );
}; };
export default LaTextContainer; export default LaTextContainer;

View File

@@ -1,17 +1,12 @@
import SubmissionItem from "./SubmissionItem"; import SubmissionItem from './SubmissionItem';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { useAppSelector } from '../../../redux/hooks';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { FC, useEffect } from 'react';
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 { export interface Mission {
id: number; id: number;
authorId: number; authorId: number;
name: string; name: string;
difficulty: "Easy" | "Medium" | "Hard"; difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[]; tags: string[];
timeLimit: number; timeLimit: number;
memoryLimit: number; memoryLimit: number;
@@ -19,39 +14,45 @@ export interface Mission {
updatedAt: string; updatedAt: string;
} }
interface MissionSubmissionsProps{ interface MissionSubmissionsProps {
missionId: number; missionId: number;
} }
const MissionSubmissions: FC<MissionSubmissionsProps> = ({missionId}) => { const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId }) => {
const submissions = useAppSelector((state) => state.submin.submitsById[missionId]); const submissions = useAppSelector(
(state) => state.submin.submitsById[missionId],
useEffect(() => { );
}, []);
useEffect(() => {}, []);
const checkStatus = (status: string) => { const checkStatus = (status: string) => {
if (status == "IncorrectAnswer") if (status == 'IncorrectAnswer') return 'wronganswer';
return "wronganswer"; if (status == 'TimeLimitError') return 'timelimit';
if (status == "TimeLimitError")
return "timelimit";
return undefined; return undefined;
} };
return ( return (
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]"> <div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
{submissions &&
submissions.map((v, i) => (
{submissions && submissions.map((v, i) => ( <SubmissionItem
<SubmissionItem key={i}
key={i} id={v.id}
id={v.id} language={v.solution.language}
language={v.solution.language} time={v.solution.time}
time={v.solution.time} verdict={
verdict={v.solution.testerMessage?.includes("Compilation failed") ? "Compilation failed" : v.solution.testerMessage} v.solution.testerMessage?.includes(
type={i % 2 ? "second" : "first"} 'Compilation failed',
status={v.solution.testerMessage == "All tests passed" ? "success" : checkStatus(v.solution.testerErrorCode)} )
? 'Compilation failed'
: v.solution.testerMessage
}
type={i % 2 ? 'second' : 'first'}
status={
v.solution.testerMessage == 'All tests passed'
? 'success'
: checkStatus(v.solution.testerErrorCode)
}
/> />
))} ))}
</div> </div>

View File

@@ -1,8 +1,48 @@
import React from "react"; import React, { FC } from 'react';
// import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import LaTextContainer from "./LaTextContainer"; import LaTextContainer from './LaTextContainer';
import { CopyIcon } from '../../../assets/icons/missions';
// import FullLatexRenderer from "./FullLatexRenderer"; // import FullLatexRenderer from "./FullLatexRenderer";
import { useState } from 'react';
interface CopyableDivPropd {
content: string;
}
const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
const [hovered, setHovered] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
alert('Скопировано!');
} catch (err) {
console.error('Ошибка копирования:', err);
}
};
return (
<div
className="relative p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{content}
<img
src={CopyIcon}
alt="copy"
className={cn(
'absolute top-2 right-2 w-6 h-6 cursor-pointer opacity-0 transition-all duration-300 hover:h-7 hover:w-7 hover:top-[6px] hover:right-[6px]',
hovered && ' opacity-100',
)}
onClick={handleCopy}
/>
</div>
);
};
export interface StatementData { export interface StatementData {
id?: number; id?: number;
name?: string; name?: string;
@@ -19,10 +59,10 @@ export interface StatementData {
} }
function extractDivByClass(html: string, className: string): string { function extractDivByClass(html: string, className: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html"); const doc = parser.parseFromString(html, 'text/html');
const div = doc.querySelector(`div.${className}`); const div = doc.querySelector(`div.${className}`);
return div ? div.outerHTML : ""; return div ? div.outerHTML : '';
} }
const Statement: React.FC<StatementData> = ({ const Statement: React.FC<StatementData> = ({
@@ -31,63 +71,110 @@ const Statement: React.FC<StatementData> = ({
tags, tags,
timeLimit = 1000, timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024, memoryLimit = 256 * 1024 * 1024,
legend = "", legend = '',
input = "", input = '',
output = "", output = '',
sampleTests = [], sampleTests = [],
notes = "", notes = '',
html = "", html = '',
mediaFiles, mediaFiles,
}) => { }) => {
return ( 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 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> <div>
<p className="h-[50px] text-[40px] font-bold text-liquid-white">{name}</p> <p className="h-[50px] text-[40px] font-bold text-liquid-white">
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #{id}</p> {name}
</p>
<p className="h-[23px] text-[18px] font-bold text-liquid-light">
Задача #{id}
</p>
</div> </div>
<div className="flex gap-[10px] w-full flex-wrap"> <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>)} {tags &&
tags.map((v, i) => (
<div
key={i}
className="px-[16px] py-[8px] rounded-full bg-liquid-lighter "
>
{v}
</div>
))}
</div> </div>
<div className="flex flex-col"> <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">
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по памяти на тест:</span> {memoryLimit / 1024 / 1024} мегабайт</p> <span className="text-liquid-light">
<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> </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>
<div className="flex flex-col gap-[10px] mt-[20px]"> <div className="flex flex-col gap-[10px] mt-[20px]">
<LaTextContainer html={extractDivByClass(html, "legend")} latex={legend} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'legend')}
latex={legend}
mediaFiles={mediaFiles}
/>
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<LaTextContainer html={extractDivByClass(html, "input-specification")} latex={input} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'input-specification')}
latex={input}
mediaFiles={mediaFiles}
/>
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<LaTextContainer html={extractDivByClass(html, "output-specification")} latex={output} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'output-specification')}
latex={output}
mediaFiles={mediaFiles}
/>
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<div className="text-[18px] font-bold">{sampleTests.length == 1 ? "Пример" : "Примеры"}</div> <div className="text-[18px] font-bold">
{sampleTests.length == 1 ? 'Пример' : 'Примеры'}
</div>
{sampleTests.map((v, i) => {sampleTests.map((v, i) => (
<div key={i} className="flex flex-col gap-[10px]"> <div key={i} className="flex flex-col gap-[10px]">
<div className="text-[14px] font-bold">Входные данные</div> <div className="text-[14px] font-bold">
<div className="p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line">{v.input}</div> Входные данные
<div className="text-[14px] font-bold">Выходные данные</div> </div>
<div className="p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line">{v.output}</div> <CopyableDiv content={v.input} />
<div className="text-[14px] font-bold">
Выходные данные
</div>
<CopyableDiv content={v.output} />
</div> </div>
)} ))}
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<LaTextContainer html={extractDivByClass(html, "note")} latex={notes} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'note')}
latex={notes}
mediaFiles={mediaFiles}
/>
<div>Автор: Jacks</div> <div>Автор: Jacks</div>
</div> </div>
</div> </div>
); );
}; };
export default Statement; export default Statement;

View File

@@ -1,14 +1,14 @@
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from "../../../assets/icons/missions"; // import { IconError, IconSuccess } from "../../../assets/icons/missions";
import { useNavigate } from "react-router-dom"; // import { useNavigate } from "react-router-dom";
export interface SubmissionItemProps { export interface SubmissionItemProps {
id: number; id: number;
language: string; language: string;
time: string; time: string;
verdict: string; verdict: string;
type: "first" | "second"; type: 'first' | 'second';
status?: "success" | "wronganswer" | "timelimit"; status?: 'success' | 'wronganswer' | 'timelimit';
} }
export function formatMilliseconds(ms: number): string { export function formatMilliseconds(ms: number): string {
@@ -23,16 +23,16 @@ export function formatBytesToMB(bytes: number): string {
} }
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, "0"); const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear(); const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0"); const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`; return `${day}/${month}/${year}\n${hours}:${minutes}`;
} }
const SubmissionItem: React.FC<SubmissionItemProps> = ({ const SubmissionItem: React.FC<SubmissionItemProps> = ({
@@ -43,33 +43,37 @@ const SubmissionItem: React.FC<SubmissionItemProps> = ({
type, type,
status, status,
}) => { }) => {
const navigate = useNavigate(); // const navigate = useNavigate();
return ( return (
<div className={cn(" w-full relative rounded-[10px] text-liquid-white", <div
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background", className={cn(
"grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center", ' w-full relative rounded-[10px] text-liquid-white',
status == "wronganswer" && "border-l-[11px] border-l-liquid-red pl-[9px]", type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
status == "timelimit" && "border-l-[11px] border-l-liquid-orange pl-[9px]", 'grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center',
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]", status == 'wronganswer' &&
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300", 'border-l-[11px] border-l-liquid-red pl-[9px]',
)} status == 'timelimit' &&
onClick={() => { }} '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"> <div className="text-[18px] font-bold">#{id}</div>
#{id}
</div>
<div className="text-[18px] font-bold text-center"> <div className="text-[18px] font-bold text-center">
{formatDate(time)} {formatDate(time)}
</div> </div>
<div className="text-[18px] font-bold text-center"> <div className="text-[18px] font-bold text-center">{language}</div>
{language} <div
</div> className={cn(
<div className={cn("text-[18px] font-bold text-center", 'text-[18px] font-bold text-center',
status == "wronganswer" && "text-liquid-red", status == 'wronganswer' && 'text-liquid-red',
status == "timelimit" && "text-liquid-orange", status == 'timelimit' && 'text-liquid-orange',
status == "success" && "text-liquid-green", status == 'success' && 'text-liquid-green',
)} > )}
>
{verdict} {verdict}
</div> </div>
</div> </div>

View File

@@ -11,5 +11,6 @@ export default {
}, },
}, },
}, },
plugins: [], plugins: [require('@tailwindcss/typography')],
}; };

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/button/primarybutton.tsx","./src/components/button/secondarybutton.tsx","./src/components/checkbox/checkbox.tsx","./src/components/input/input.tsx","./src/components/switch/switch.tsx","./src/config/colors.ts","./src/lib/cn.ts","./src/redux/store.ts"],"version":"5.6.2"} {"root":["./src/app.tsx","./src/axios.ts","./src/main.tsx","./src/vite-env.d.ts","./src/assets/icons/account/index.ts","./src/assets/icons/auth/index.ts","./src/assets/icons/groups/index.ts","./src/assets/icons/header/index.ts","./src/assets/icons/input/index.ts","./src/assets/icons/menu/index.ts","./src/assets/icons/missions/index.ts","./src/assets/logos/index.ts","./src/components/button/primarybutton.tsx","./src/components/button/reversebutton.tsx","./src/components/button/secondarybutton.tsx","./src/components/checkbox/checkbox.tsx","./src/components/drop-down-list/dropdownlist.tsx","./src/components/input/daterangeinput.tsx","./src/components/input/input.tsx","./src/components/modal/modal.tsx","./src/components/router/protectedroute.tsx","./src/components/switch/switch.tsx","./src/config/colors.ts","./src/hooks/useclickoutside.ts","./src/hooks/usequery.ts","./src/lib/cn.ts","./src/pages/article.tsx","./src/pages/articleeditor.tsx","./src/pages/home.tsx","./src/pages/mission.tsx","./src/redux/hooks.ts","./src/redux/store.ts","./src/redux/slices/account.ts","./src/redux/slices/articles.ts","./src/redux/slices/auth.ts","./src/redux/slices/contests.ts","./src/redux/slices/groups.ts","./src/redux/slices/missions.ts","./src/redux/slices/store.ts","./src/redux/slices/submit.ts","./src/views/article/header.tsx","./src/views/articleeditor/editor.tsx","./src/views/articleeditor/header.tsx","./src/views/articleeditor/marckdownpreview.tsx","./src/views/home/account/account.tsx","./src/views/home/account/accoutmenu.tsx","./src/views/home/account/articlesblock.tsx","./src/views/home/account/contestsblock.tsx","./src/views/home/account/missionsblock.tsx","./src/views/home/account/rightpanel.tsx","./src/views/home/articles/articleitem.tsx","./src/views/home/articles/articles.tsx","./src/views/home/auth/login.tsx","./src/views/home/auth/register.tsx","./src/views/home/contest/contest.tsx","./src/views/home/contest/missionitem.tsx","./src/views/home/contest/missions.tsx","./src/views/home/contest/submissions.tsx","./src/views/home/contests/contestitem.tsx","./src/views/home/contests/contests.tsx","./src/views/home/contests/contestsblock.tsx","./src/views/home/contests/modalcreate.tsx","./src/views/home/groups/group.tsx","./src/views/home/groups/groupitem.tsx","./src/views/home/groups/groups.tsx","./src/views/home/groups/groupsblock.tsx","./src/views/home/groups/modalcreate.tsx","./src/views/home/groups/modalupdate.tsx","./src/views/home/menu/menu.tsx","./src/views/home/menu/menuitem.tsx","./src/views/home/missions/missionitem.tsx","./src/views/home/missions/missions.tsx","./src/views/home/missions/modalcreate.tsx","./src/views/mission/codeeditor/codeeditor.tsx","./src/views/mission/statement/header.tsx","./src/views/mission/statement/latextcontainer.tsx","./src/views/mission/statement/missionsubmissions.tsx","./src/views/mission/statement/statement.tsx","./src/views/mission/statement/submissionitem.tsx","./src/views/mission/submission/submission.tsx"],"version":"5.6.2"}