Compare commits
68 Commits
070edbfc42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f83e31967b | |||
|
|
781945b358 | ||
| c55f7a972c | |||
|
|
0c41cc59b9 | ||
|
|
c761f337b1 | ||
|
|
47152a921e | ||
|
|
d1a46435c4 | ||
|
|
02de330034 | ||
|
|
14d2f5cbf1 | ||
|
|
6675bd871e | ||
|
|
4391114dc3 | ||
|
|
46e27616b2 | ||
|
|
284de59a16 | ||
|
|
4fcbde6a06 | ||
|
|
a4622cf8d2 | ||
|
|
fd34761745 | ||
|
|
358c7def78 | ||
|
|
8f337e6f7b | ||
|
|
95f7479375 | ||
|
|
2e5d4b2653 | ||
|
|
d6ab1cba4d | ||
|
|
ee0e44082a | ||
|
|
dae4584840 | ||
|
|
57a680fba8 | ||
|
|
390f1f52c8 | ||
|
|
abb7301c16 | ||
|
|
304c734169 | ||
|
|
e904297bb9 | ||
| 5693701aa5 | |||
|
|
a5016b23bb | ||
| d9f449f0b8 | |||
|
|
9cbfd88a23 | ||
|
|
b949837e13 | ||
|
|
56b6f9b339 | ||
|
|
dfc2985209 | ||
|
|
ded41ba7f0 | ||
|
|
18d17f895d | ||
|
|
0b0c5c36e1 | ||
|
|
1cbd2dc0b3 | ||
|
|
f7924cd564 | ||
|
|
b12a3acf1d | ||
|
|
69655dda82 | ||
|
|
93a5366fd5 | ||
|
|
046e5d1693 | ||
|
|
6c92c789d0 | ||
|
|
1b39b8c77f | ||
|
|
dc6df1480e | ||
|
|
4a65aa4b53 | ||
| 04da2b565a | |||
|
|
c6303758e1 | ||
|
|
aeab03d35c | ||
|
|
994954c817 | ||
|
|
42da6684ba | ||
|
|
cdb5595769 | ||
|
|
4972836164 | ||
|
|
3cd8e14288 | ||
|
|
2e3a8779fc | ||
|
|
04dbc5eeb1 | ||
|
|
7ef7bdf055 | ||
|
|
1ef655803a | ||
|
|
193234b9e5 | ||
|
|
9a2c2a9589 | ||
|
|
91aa3e1f80 | ||
|
|
f2ec4653bb | ||
|
|
8429bd4082 | ||
|
|
db8828e32b | ||
|
|
fbe441c654 | ||
|
|
a5c7cc9db3 |
2
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -23,3 +24,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>LiquidCode</title>
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap" rel="stylesheet">
|
||||
|
||||
|
||||
11614
package-lock.json
generated
90
package.json
@@ -1,43 +1,51 @@
|
||||
{
|
||||
"name": "react-kit",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@types/react-redux": "^7.1.33",
|
||||
"axios": "^1.12.2",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.9.0",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"postcss": "^8.4.47",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-cn": "^1.0.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
"name": "react-kit",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react-redux": "^7.1.33",
|
||||
"axios": "^1.12.2",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"postcss": "^8.4.47",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"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-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="62" height="56" viewBox="0 0 62 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.7765 44.9299V16.6675C16.7765 7.46416 9.26353 0 0 0V44.9299C0.00729412 45.9589 0.153177 50.4447 3.64706 53.626C4.92353 54.7927 6.27294 55.4377 7.29412 55.8H23.3412C22.4221 55.4377 21.2113 54.7927 20.0588 53.626C16.9151 50.4447 16.7838 45.9589 16.7765 44.9299Z" fill="#0C8092"/>
|
||||
<path d="M35.7412 44.9299V27.5377C35.7412 18.3343 28.2282 10.8701 18.9647 10.8701V44.9299C18.972 45.9589 19.1179 50.4447 22.6118 53.626C23.8882 54.7927 25.2376 55.4377 26.2588 55.8H42.3059C41.3868 55.4377 40.176 54.7927 39.0235 53.626C35.8798 50.4447 35.7485 45.9589 35.7412 44.9299Z" fill="#16A7C6"/>
|
||||
<path d="M58.3529 53.626C54.8591 50.4447 54.7132 45.9589 54.7059 44.9299V39.1325C54.7059 29.9291 47.1929 22.4649 37.9294 22.4649V44.9299C37.9367 45.9589 38.0826 50.4447 41.5765 53.626C42.8529 54.7927 44.2024 55.4377 45.2235 55.8H62C60.9788 55.4377 59.6294 54.7927 58.3529 53.626Z" fill="#00DBD9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 993 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
46
src/App.tsx
@@ -1,27 +1,41 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
// import { PrimaryButton } from "./components/button/PrimaryButton";
|
||||
// import { SecondaryButton } from "./components/button/SecondaryButton";
|
||||
// import { Checkbox } from "./components/checkbox/Checkbox";
|
||||
// import { Input } from "./components/input/Input";
|
||||
// import { Switch } from "./components/switch/Switch";
|
||||
import Home from "./pages/Home";
|
||||
import Mission from "./pages/Mission";
|
||||
import UploadMissionForm from "./views/mission/UploadMissionForm";
|
||||
import Home from './pages/Home';
|
||||
import Mission from './pages/Mission';
|
||||
import ArticleEditor from './pages/ArticleEditor';
|
||||
import Article from './pages/Article';
|
||||
import ContestEditor from './pages/ContestEditor';
|
||||
import ProtectedRoute from './components/router/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="w-full h-full bg-liquid-background flex justify-center">
|
||||
<div className="relative w-full max-w-[1600px] h-full ">
|
||||
<Routes>
|
||||
<Route path="/home/*" element={<Home />} />
|
||||
<Route path="/mission/:missionId" element={<Mission />} />
|
||||
<Route path="/upload" element={<UploadMissionForm/>}/>
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
return (
|
||||
<div className="w-full h-full bg-liquid-background flex justify-center">
|
||||
<div className="relative w-full max-w-[1600px] h-full ">
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route
|
||||
path="/article/create/*"
|
||||
element={<ArticleEditor />}
|
||||
/>
|
||||
<Route
|
||||
path="/contest/create/*"
|
||||
element={<ContestEditor />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<Route path="/home/*" element={<Home />} />
|
||||
<Route path="/mission/:missionId" element={<Mission />} />
|
||||
|
||||
<Route path="/article/:articleId" element={<Article />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
3
src/assets/icons/account/clipboard.svg
Normal 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 |
3
src/assets/icons/account/cup.svg
Normal 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 |
5
src/assets/icons/account/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Clipboard from './clipboard.svg';
|
||||
import Cup from './cup.svg';
|
||||
import OpenBook from './openbook.svg';
|
||||
|
||||
export { Clipboard, Cup, OpenBook };
|
||||
3
src/assets/icons/account/openbook.svg
Normal 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 |
3
src/assets/icons/auth/account.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.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 |
@@ -1,3 +1,4 @@
|
||||
import Balloon from "./balloon.svg";
|
||||
import Balloon from './balloon.svg';
|
||||
import Account from './account.svg';
|
||||
|
||||
export {Balloon};
|
||||
export { Balloon, Account };
|
||||
|
||||
3
src/assets/icons/filters/filters-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V6.17C1.99986 6.58294 2.08497 6.99147 2.25 7.37V7.43C2.39128 7.75097 2.59139 8.04266 2.84 8.29L9 14.41V21C8.99966 21.1699 9.04264 21.3372 9.12487 21.4859C9.20711 21.6346 9.32589 21.7599 9.47 21.85C9.62914 21.9486 9.81277 22.0006 10 22C10.1565 21.9991 10.3107 21.9614 10.45 21.89L14.45 19.89C14.6149 19.8069 14.7536 19.6798 14.8507 19.5227C14.9478 19.3656 14.9994 19.1847 15 19V14.41L21.12 8.29C21.3686 8.04266 21.5687 7.75097 21.71 7.43V7.37C21.8888 6.99443 21.9876 6.58578 22 6.17V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM13.29 13.29C13.1973 13.3834 13.124 13.4943 13.0742 13.6161C13.0245 13.7379 12.9992 13.8684 13 14V18.38L11 19.38V14C11.0008 13.8684 10.9755 13.7379 10.9258 13.6161C10.876 13.4943 10.8027 13.3834 10.71 13.29L5.41 8H18.59L13.29 13.29ZM20 6H4V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V6Z" fill="#00DBD9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
src/assets/icons/filters/filters.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V6.17C1.99986 6.58294 2.08497 6.99147 2.25 7.37V7.43C2.39128 7.75097 2.59139 8.04266 2.84 8.29L9 14.41V21C8.99966 21.1699 9.04264 21.3372 9.12487 21.4859C9.20711 21.6346 9.32589 21.7599 9.47 21.85C9.62914 21.9486 9.81277 22.0006 10 22C10.1565 21.9991 10.3107 21.9614 10.45 21.89L14.45 19.89C14.6149 19.8069 14.7536 19.6798 14.8507 19.5227C14.9478 19.3656 14.9994 19.1847 15 19V14.41L21.12 8.29C21.3686 8.04266 21.5687 7.75097 21.71 7.43V7.37C21.8888 6.99443 21.9876 6.58578 22 6.17V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM13.29 13.29C13.1973 13.3834 13.124 13.4943 13.0742 13.6161C13.0245 13.7379 12.9992 13.8684 13 14V18.38L11 19.38V14C11.0008 13.8684 10.9755 13.7379 10.9258 13.6161C10.876 13.4943 10.8027 13.3834 10.71 13.29L5.41 8H18.59L13.29 13.29ZM20 6H4V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V6Z" fill="#576466"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
7
src/assets/icons/filters/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import iconFilterActive from './filters-active.svg';
|
||||
import iconFilter from './filters.svg';
|
||||
import iconSort from './sort.svg';
|
||||
import iconSortActive from './sort-active.svg';
|
||||
import iconSearch from './search.svg';
|
||||
|
||||
export { iconFilter, iconFilterActive, iconSort, iconSortActive, iconSearch };
|
||||
3
src/assets/icons/filters/search.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.927 17.04L20.4001 20.4M19.2801 11.44C19.2801 15.7699 15.77 19.28 11.4401 19.28C7.11019 19.28 3.6001 15.7699 3.6001 11.44C3.6001 7.11009 7.11019 3.60001 11.4401 3.60001C15.77 3.60001 19.2801 7.11009 19.2801 11.44Z" stroke="#576466" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
3
src/assets/icons/filters/sort-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.4415 6.62732H22M13.4415 11.4421H19.5547M13.4415 16.2569H17.1094M5.80564 6V18M5.80564 18L2 14.3317M5.80564 18L9.7566 14.3317" stroke="#00DBD9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
3
src/assets/icons/filters/sort.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.4415 6.62732H22M13.4415 11.4421H19.5547M13.4415 16.2569H17.1094M5.80564 6V18M5.80564 18L2 14.3317M5.80564 18L9.7566 14.3317" stroke="#576466" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
3
src/assets/icons/group/cup.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 4H18V3C18 2.73478 17.8946 2.48043 17.7071 2.29289C17.5196 2.10536 17.2652 2 17 2H7C6.73478 2 6.48043 2.10536 6.29289 2.29289C6.10536 2.48043 6 2.73478 6 3V4H3C2.73478 4 2.48043 4.10536 2.29289 4.29289C2.10536 4.48043 2 4.73478 2 5V8C2 9.06087 2.42143 10.0783 3.17157 10.8284C3.92172 11.5786 4.93913 12 6 12H7.54C8.44453 13.0091 9.66406 13.6824 11 13.91V16H10C9.20435 16 8.44129 16.3161 7.87868 16.8787C7.31607 17.4413 7 18.2044 7 19V21C7 21.2652 7.10536 21.5196 7.29289 21.7071C7.48043 21.8946 7.73478 22 8 22H16C16.2652 22 16.5196 21.8946 16.7071 21.7071C16.8946 21.5196 17 21.2652 17 21V19C17 18.2044 16.6839 17.4413 16.1213 16.8787C15.5587 16.3161 14.7956 16 14 16H13V13.91C14.3359 13.6824 15.5555 13.0091 16.46 12H18C19.0609 12 20.0783 11.5786 20.8284 10.8284C21.5786 10.0783 22 9.06087 22 8V5C22 4.73478 21.8946 4.48043 21.7071 4.29289C21.5196 4.10536 21.2652 4 21 4ZM6 10C5.46957 10 4.96086 9.78929 4.58579 9.41421C4.21071 9.03914 4 8.53043 4 8V6H6V8C6.0022 8.68171 6.12056 9.35806 6.35 10H6ZM14 18C14.2652 18 14.5196 18.1054 14.7071 18.2929C14.8946 18.4804 15 18.7348 15 19V20H9V19C9 18.7348 9.10536 18.4804 9.29289 18.2929C9.48043 18.1054 9.73478 18 10 18H14ZM16 8C16 9.06087 15.5786 10.0783 14.8284 10.8284C14.0783 11.5786 13.0609 12 12 12C10.9391 12 9.92172 11.5786 9.17157 10.8284C8.42143 10.0783 8 9.06087 8 8V4H16V8ZM20 8C20 8.53043 19.7893 9.03914 19.4142 9.41421C19.0391 9.78929 18.5304 10 18 10H17.65C17.8794 9.35806 17.9978 8.68171 18 8V6H20V8Z" fill="#EDF6F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
3
src/assets/icons/group/home.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 17.0625H16.5M11.3046 3.21117L3.50457 8.48603C3.18802 8.7001 3 9.04666 3 9.41605V19.2882C3 20.2336 3.80589 21 4.8 21H19.2C20.1941 21 21 20.2336 21 19.2882V9.41605C21 9.04665 20.812 8.7001 20.4954 8.48603L12.6954 3.21117C12.2791 2.92961 11.7209 2.92961 11.3046 3.21117Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
6
src/assets/icons/group/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Cup from './cup.svg';
|
||||
import Home from './home.svg';
|
||||
import MessageChat from './message-chat.svg';
|
||||
import Logout from './logout.svg';
|
||||
|
||||
export { Cup, MessageChat, Home, Logout };
|
||||
3
src/assets/icons/group/logout.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.79562 4H5.1075C4.54856 4 4.01251 4.21071 3.61727 4.58579C3.22204 4.96086 3 5.46957 3 6V18C3 18.5304 3.22204 19.0391 3.61727 19.4142C4.01251 19.7893 4.54856 20 5.1075 20H8.79562M9.0575 12.0007L21 12.0007M21 12.0007L16.4368 7.42931M21 12.0007L16.4368 16.5722" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 456 B |
3
src/assets/icons/group/message-chat.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 10.5V6C19 4.89543 18.1046 4 17 4H5C3.89543 4 3 4.89543 3 6V13.8261C3 14.9307 3.89543 15.8261 5 15.8261H6.56522V20L10.7391 15.8261H11M16.163 18.3913L18.7717 21V18.3913H19C20.1046 18.3913 21 17.4959 21 16.3913V13C21 11.8954 20.1046 11 19 11H13C11.8954 11 11 11.8954 11 13V16.3913C11 17.4959 11.8954 18.3913 13 18.3913H16.163Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 524 B |
@@ -1,8 +1,9 @@
|
||||
import Book from "./book.png"
|
||||
import EyeClosed from "./eye-closed.svg";
|
||||
import EyeOpen from "./eye-open.png";
|
||||
import Edit from "./edit.svg";
|
||||
import UserAdd from "./user-profile-add.svg";
|
||||
import ChevroneDown from "./chevron-down.svg"
|
||||
import Book from './book.png';
|
||||
import EyeClosed from './eye-closed.svg';
|
||||
import EyeOpen from './eye-open.png';
|
||||
import Edit from './edit.svg';
|
||||
import UserAdd from './user-profile-add.svg';
|
||||
import ChevroneDown from './chevron-down.svg';
|
||||
|
||||
export {Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown}
|
||||
|
||||
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import arrowLeft from "./arrow-left-sm.svg";
|
||||
import chevroneLeft from "./chevron-left.svg"
|
||||
import chevroneRight from "./chevron-right.svg"
|
||||
import arrowLeft from './arrow-left-sm.svg';
|
||||
import chevroneLeft from './chevron-left.svg';
|
||||
import chevroneRight from './chevron-right.svg';
|
||||
|
||||
export {arrowLeft, chevroneLeft, chevroneRight}
|
||||
export { arrowLeft, chevroneLeft, chevroneRight };
|
||||
|
||||
3
src/assets/icons/input/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.7992 19.5516H19.7992M4.19922 19.5516L8.5652 18.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 |
@@ -1,8 +1,21 @@
|
||||
import eyeClosed from "./eye-closed.svg"
|
||||
import eyeOpen from "./eye-open.png"
|
||||
import googleLogo from "./google-logo.svg"
|
||||
import upload from "./upload.svg"
|
||||
import chevroneDropDownList from "./chevron-drop-down.svg"
|
||||
import checkMark from "./check-mark.svg"
|
||||
import eyeClosed from './eye-closed.svg';
|
||||
import eyeOpen from './eye-open.png';
|
||||
import googleLogo from './google-logo.svg';
|
||||
import upload from './upload.svg';
|
||||
import chevroneDropDownList from './chevron-drop-down.svg';
|
||||
import checkMark from './check-mark.svg';
|
||||
import Edit from './edit.svg';
|
||||
import Send from './send.svg';
|
||||
import Trash from './trash.svg';
|
||||
|
||||
export {eyeClosed, eyeOpen, googleLogo, upload, chevroneDropDownList, checkMark}
|
||||
export {
|
||||
Edit,
|
||||
eyeClosed,
|
||||
eyeOpen,
|
||||
googleLogo,
|
||||
upload,
|
||||
chevroneDropDownList,
|
||||
checkMark,
|
||||
Send,
|
||||
Trash,
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 8.9 KiB |
3
src/assets/icons/input/send.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.4045 11.5763L6.32338 11.5763M5.06681 2.74198L20.8828 10.4103C21.8567 10.8826 21.8567 12.2701 20.8828 12.7423L5.06681 20.4107C3.98332 20.936 2.83166 19.8284 3.3143 18.7253L6.21474 12.0957C6.3596 11.7646 6.3596 11.388 6.21474 11.0569L3.3143 4.42737C2.83167 3.32419 3.98332 2.21665 5.06681 2.74198Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
3
src/assets/icons/input/trash.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 6.17647H20M9 3H15M10 16.7647V10.4118M14 16.7647V10.4118M15.5 21H8.5C7.39543 21 6.5 20.0519 6.5 18.8824L6.0434 7.27937C6.01973 6.67783 6.47392 6.17647 7.04253 6.17647H16.9575C17.5261 6.17647 17.9803 6.67783 17.9566 7.27937L17.5 18.8824C17.5 20.0519 16.6046 21 15.5 21Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -1,8 +1,8 @@
|
||||
import Account from "./account.svg";
|
||||
import Clipboard from "./clipboard.svg";
|
||||
import Cup from "./cup.svg";
|
||||
import Home from "./home.svg";
|
||||
import Openbook from "./openbook.svg";
|
||||
import Users from "./users.svg";
|
||||
import Account from './account.svg';
|
||||
import Clipboard from './clipboard.svg';
|
||||
import Cup from './cup.svg';
|
||||
import Home from './home.svg';
|
||||
import Openbook from './openbook.svg';
|
||||
import Users from './users.svg';
|
||||
|
||||
export {Account, Clipboard, Cup, Home, Openbook, Users};
|
||||
export { Account, Clipboard, Cup, Home, Openbook, Users };
|
||||
|
||||
4
src/assets/icons/missions/copy-icon.svg
Normal 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 |
@@ -1,4 +1,5 @@
|
||||
import IconSuccess from "./icon-success.svg"
|
||||
import IconError from "./icon-error.svg"
|
||||
import IconSuccess from './icon-success.svg';
|
||||
import IconError from './icon-error.svg';
|
||||
import CopyIcon from './copy-icon.svg';
|
||||
|
||||
export {IconError, IconSuccess}
|
||||
export { IconError, IconSuccess, CopyIcon };
|
||||
|
||||
BIN
src/assets/logos/LogoFASIE.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
@@ -1,3 +1,4 @@
|
||||
import Logo from "./Logo.svg"
|
||||
import Logo from './Logo.svg';
|
||||
import LogoFASIE from './LogoFASIE.png';
|
||||
|
||||
export {Logo}
|
||||
export { Logo, LogoFASIE };
|
||||
|
||||
31
src/axios.ts
@@ -1,24 +1,25 @@
|
||||
import axios from "axios";
|
||||
import axios from 'axios';
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor: автоматически подставляет JWT, если есть
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
(config) => {
|
||||
const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState()
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default instance;
|
||||
|
||||
@@ -1,70 +1,93 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<ButtonProps> = ({
|
||||
disabled = false,
|
||||
text = "",
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
}) => {
|
||||
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-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset",
|
||||
"px-[16px] py-[8px]",
|
||||
"bg-liquid-darkmain",
|
||||
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={() => { onClick() }}
|
||||
/>
|
||||
|
||||
{/* Граница при выделении через 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]",
|
||||
"group-hover:text-liquid-brightmain ",
|
||||
disabled && "text-liquid-light"
|
||||
)}
|
||||
>
|
||||
{children || text}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
const ColorBgVariants = {
|
||||
primary: 'bg-liquid-brightmain group-hover:ring-liquid-brightmain',
|
||||
secondary: 'bg-liquid-darkmain group-hover:ring-liquid-darkmain',
|
||||
error: 'bg-liquid-red group-hover:ring-liquid-red',
|
||||
warning: 'bg-liquid-orange group-hover:ring-liquid-orange',
|
||||
success: 'bg-liquid-green group-hover:ring-liquid-green',
|
||||
};
|
||||
|
||||
const ColorTextVariants = {
|
||||
primary: 'group-hover:text-liquid-brightmain ',
|
||||
secondary: 'group-hover:text-liquid-brightmain ',
|
||||
error: 'group-hover:text-liquid-red ',
|
||||
warning: 'group-hover:text-liquid-orange ',
|
||||
success: 'group-hover:text-liquid-green ',
|
||||
};
|
||||
|
||||
export const PrimaryButton: 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,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Основной контейнер, */}
|
||||
<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-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset',
|
||||
'px-[16px] py-[8px]',
|
||||
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={() => {
|
||||
onClick();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Граница при выделении через 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>
|
||||
);
|
||||
};
|
||||
|
||||
95
src/components/button/ReverseButton.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => 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,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Основной контейнер, */}
|
||||
<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={() => {
|
||||
onClick();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Граница при выделении через 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>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +1,73 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SecondaryButton: React.FC<ButtonProps> = ({
|
||||
disabled = false,
|
||||
text = "",
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
disabled = false,
|
||||
text = '',
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
}) => {
|
||||
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-background",
|
||||
"px-[16px] py-[8px]",
|
||||
"bg-liquid-lighter"
|
||||
)}
|
||||
>
|
||||
{/* Скрытый button */}
|
||||
<button
|
||||
className={cn(
|
||||
"absolute opacity-0 -z-10 h-0 w-0",
|
||||
"[&:focus-visible+*]:outline-liquid-brightmain",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => { onClick() }}
|
||||
/>
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'grid relative cursor-pointer select-none group w-fit box-border',
|
||||
disabled && 'pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Основной контейнер, */}
|
||||
<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-background',
|
||||
'px-[16px] py-[8px]',
|
||||
'bg-liquid-lighter',
|
||||
)}
|
||||
>
|
||||
{/* Скрытый button */}
|
||||
<button
|
||||
className={cn(
|
||||
'absolute opacity-0 -z-10 h-0 w-0',
|
||||
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Граница при выделении через 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]",
|
||||
disabled && "text-liquid-light"
|
||||
)}
|
||||
>
|
||||
{children || text}
|
||||
</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]',
|
||||
disabled && 'text-liquid-light',
|
||||
)}
|
||||
>
|
||||
{children || text}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const pathVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
pathLength: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
transition: {
|
||||
delay: 0.15,
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
pathLength: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
transition: {
|
||||
delay: 0.15,
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sizeVariants = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6',
|
||||
};
|
||||
|
||||
const colorsVariants = {
|
||||
default: "bg-default",
|
||||
primary: "bg-liquid-brightmain",
|
||||
secondary: "bg-liquid-darkmain",
|
||||
success: "bg-liquid-green",
|
||||
warning: "bg-liquid-orange",
|
||||
danger: "bg-liquid-red",
|
||||
default: 'bg-default',
|
||||
primary: 'bg-liquid-brightmain',
|
||||
secondary: 'bg-liquid-darkmain',
|
||||
success: 'bg-liquid-green',
|
||||
warning: 'bg-liquid-orange',
|
||||
danger: 'bg-liquid-red',
|
||||
};
|
||||
|
||||
|
||||
const borderColorsVariants = {
|
||||
default: "border-default",
|
||||
primary: "border-liquid-brightmain",
|
||||
secondary: "border-liquid-darkmain",
|
||||
success: "border-liquid-green",
|
||||
warning: "border-liquid-orange",
|
||||
danger: "border-liquid-red",
|
||||
default: 'border-default',
|
||||
primary: 'border-liquid-brightmain',
|
||||
secondary: 'border-liquid-darkmain',
|
||||
success: 'border-liquid-green',
|
||||
warning: 'border-liquid-orange',
|
||||
danger: 'border-liquid-red',
|
||||
};
|
||||
|
||||
const focuseOutlineVariants = {
|
||||
default: "[&:focus-visible+*]:outline-default",
|
||||
primary: "[&:focus-visible+*]:outline-liquid-brightmain",
|
||||
secondary: "[&:focus-visible+*]:outline-liquid-darkmain",
|
||||
success: "[&:focus-visible+*]:outline-liquid-green",
|
||||
warning: "[&:focus-visible+*]:outline-liquid-orange",
|
||||
danger: "[&:focus-visible+*]:outline-liquid-red",
|
||||
default: '[&:focus-visible+*]:outline-default',
|
||||
primary: '[&:focus-visible+*]:outline-liquid-brightmain',
|
||||
secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
|
||||
success: '[&:focus-visible+*]:outline-liquid-green',
|
||||
warning: '[&:focus-visible+*]:outline-liquid-orange',
|
||||
danger: '[&:focus-visible+*]:outline-liquid-red',
|
||||
};
|
||||
|
||||
const radiusVraiants = {
|
||||
none: "",
|
||||
sm: "rounded-[3.5px]",
|
||||
md: "rounded-[5px]",
|
||||
lg: "rounded-[7px]",
|
||||
full: "rounded-full",
|
||||
none: '',
|
||||
sm: 'rounded-[3.5px]',
|
||||
md: 'rounded-[5px]',
|
||||
lg: 'rounded-[7px]',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
interface CheckboxProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
radius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
disabled?: boolean;
|
||||
color?:
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
label?: string;
|
||||
variant?: "default" | "label";
|
||||
className?: string;
|
||||
defaultState?: boolean;
|
||||
onChange: (state: boolean) => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
|
||||
disabled?: boolean;
|
||||
color?:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger';
|
||||
label?: string;
|
||||
variant?: 'default' | 'label';
|
||||
className?: string;
|
||||
defaultState?: boolean;
|
||||
onChange: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
size = "md",
|
||||
radius = "md",
|
||||
disabled = false,
|
||||
color = "primary",
|
||||
label = "",
|
||||
variant = "label",
|
||||
className,
|
||||
onChange,
|
||||
defaultState = false,
|
||||
size = 'md',
|
||||
radius = 'md',
|
||||
disabled = false,
|
||||
color = 'primary',
|
||||
label = '',
|
||||
variant = 'label',
|
||||
className,
|
||||
onChange,
|
||||
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 (
|
||||
<motion.label
|
||||
className={cn(
|
||||
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2",
|
||||
"grid relative cursor-pointer p-2 select-none group ",
|
||||
className,
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
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"
|
||||
/>
|
||||
return (
|
||||
<motion.label
|
||||
className={cn(
|
||||
variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
|
||||
'grid relative cursor-pointer p-2 select-none group ',
|
||||
className,
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
variant == 'default' && '',
|
||||
)}
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
{variant == "label" && (
|
||||
<div className="select-none text-layout-foeground transition-all duration-200">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</motion.label>
|
||||
);
|
||||
>
|
||||
<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',
|
||||
color == 'danger' && ' border-liquid-red',
|
||||
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>
|
||||
{variant == 'label' && (
|
||||
<div className="select-none text-layout-foeground transition-all duration-200">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</motion.label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
import { checkMark, chevroneDropDownList } from "../../assets/icons/input";
|
||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
||||
|
||||
export interface DropDownListItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DropDownListProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: DropDownListItem;
|
||||
items: DropDownListItem[];
|
||||
}
|
||||
|
||||
export const DropDownList: React.FC<DropDownListProps> = ({
|
||||
// disabled = false,
|
||||
className = "",
|
||||
onChange,
|
||||
defaultState,
|
||||
items = [{ text: "", value: "" }],
|
||||
}) => {
|
||||
if (items.length == 0)
|
||||
items.push({ text: "", value: "" });
|
||||
|
||||
const [value, setValue] = React.useState<DropDownListItem>(defaultState != undefined ? defaultState : items[0]);
|
||||
const [active, setActive] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => onChange(value.value), [value]);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<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={() => {
|
||||
setActive(!active);
|
||||
}
|
||||
}>
|
||||
{value.text}
|
||||
|
||||
</div>
|
||||
|
||||
<img src={chevroneDropDownList}
|
||||
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
|
||||
className={cn(" absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300",
|
||||
"grid overflow-hidden",
|
||||
active ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
||||
)}>
|
||||
<div className=" overflow-hidden p-[8px]">
|
||||
<div className={cn(
|
||||
" overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ",
|
||||
)}>
|
||||
|
||||
{items.map((v, i) =>
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"cursor-pointer h-[36px] relative transition-all duration-300",
|
||||
i + 1 != items.length && "border-b-liquid-light border-b-[1px]",
|
||||
"text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]",
|
||||
"hover:bg-liquid-background",
|
||||
"first:rounded-t-[6px] last:rounded-b-[6px]"
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue(v);
|
||||
setActive(false);
|
||||
}}>
|
||||
{v.text}
|
||||
|
||||
{v.text == value.text &&
|
||||
<img src={checkMark} className=" absolute right-[8px]" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
125
src/components/filters/Filter.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { checkMark } from '../../assets/icons/input';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { iconFilter, iconFilterActive } from '../../assets/icons/filters';
|
||||
|
||||
export interface FilterItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (items: FilterItem[]) => void;
|
||||
defaultState?: FilterItem[];
|
||||
items: FilterItem[];
|
||||
}
|
||||
|
||||
export const FilterDropDown: React.FC<FilterProps> = ({
|
||||
disabled = false,
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState = [],
|
||||
items = [],
|
||||
}) => {
|
||||
const [value, setValue] = React.useState<FilterItem[]>(defaultState);
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
onChange(value);
|
||||
}, [value]);
|
||||
|
||||
const toggleItem = (item: FilterItem) => {
|
||||
const exists = value.some((val) => val.value === item.value);
|
||||
if (exists) {
|
||||
setValue(value.filter((val) => val.value !== item.value));
|
||||
} else {
|
||||
setValue([...value, item]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'items-center h-[40px] rounded-full bg-liquid-lighter w-[40px] flex',
|
||||
'text-[18px] font-bold cursor-pointer select-none',
|
||||
'overflow-hidden',
|
||||
(active || value.length > 0) &&
|
||||
'w-fit border-liquid-brightmain border-[1px] border-solid',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) setActive(!active);
|
||||
}}
|
||||
>
|
||||
<div className="text-liquid-brightmain pl-[42px] pr-[16px] w-fit">
|
||||
{value.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter icons */}
|
||||
<img
|
||||
src={iconFilter}
|
||||
className={cn(
|
||||
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={iconFilterActive}
|
||||
className={cn(
|
||||
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
|
||||
(active || value.length > 0) && 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-[10px] bg-liquid-lighter w-[460px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden',
|
||||
active
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden p-[8px]">
|
||||
<div className="overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] grid grid-cols-2 gap-[20px]">
|
||||
{items.map((v) => {
|
||||
const selected = value.some(
|
||||
(val) => val.value === v.value,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={v.value}
|
||||
className={cn(
|
||||
'cursor-pointer h-[36px] relative transition-all duration-300',
|
||||
'text-[16px] font-medium select-none flex items-center pl-[8px]',
|
||||
'hover:bg-liquid-background rounded-[10px]',
|
||||
selected && 'bg-liquid-background/50',
|
||||
)}
|
||||
onClick={() => toggleItem(v)}
|
||||
>
|
||||
{v.text}
|
||||
{selected && (
|
||||
<img
|
||||
src={checkMark}
|
||||
className="absolute right-[8px] h-[20px] w-[20px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
129
src/components/filters/Sorter.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { checkMark } from '../../assets/icons/input';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { iconSort, iconSortActive } from '../../assets/icons/filters';
|
||||
|
||||
export interface SorterItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SorterProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: SorterItem;
|
||||
items: SorterItem[];
|
||||
}
|
||||
|
||||
export const SorterDropDown: FC<SorterProps> = ({
|
||||
// disabled = false,
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState,
|
||||
items = [{ text: '', value: '' }],
|
||||
}) => {
|
||||
if (items.length == 0) items.push({ text: '', value: '' });
|
||||
|
||||
const [value, setValue] = useState<SorterItem>(
|
||||
defaultState != undefined ? defaultState : items[0],
|
||||
);
|
||||
const [active, setActive] = useState<boolean>(false);
|
||||
const [activate, setActivate] = useState<Boolean>(false);
|
||||
|
||||
useEffect(() => onChange(value.value), [value]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid items-center h-[40px] rounded-full bg-liquid-lighter grid-cols-[40px]',
|
||||
'text-[18px] font-bold cursor-pointer select-none',
|
||||
'overflow-hidden',
|
||||
(active || activate) &&
|
||||
' grid-cols-[1fr] border-liquid-brightmain border-[1px] border-solid',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'text-liquid-brightmain pl-[42px] pr-[16px]',
|
||||
active && '',
|
||||
)}
|
||||
>
|
||||
{' '}
|
||||
{value.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={iconSort}
|
||||
className={cn(
|
||||
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={iconSortActive}
|
||||
className={cn(
|
||||
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
|
||||
(active || activate) && ' opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
' absolute rounded-[10px] bg-liquid-lighter w-[220px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden',
|
||||
active
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className=" overflow-hidden p-[8px]">
|
||||
<div
|
||||
className={cn(
|
||||
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
||||
)}
|
||||
>
|
||||
{items.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'cursor-pointer h-[36px] relative transition-all duration-300',
|
||||
i + 1 != items.length &&
|
||||
'border-b-liquid-light border-b-[1px]',
|
||||
'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
|
||||
'hover:bg-liquid-background',
|
||||
'first:rounded-t-[6px] last:rounded-b-[6px]',
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue(v);
|
||||
setActive(false);
|
||||
setActivate(true);
|
||||
}}
|
||||
>
|
||||
{v.text}
|
||||
|
||||
{v.text == value.text && (
|
||||
<img
|
||||
src={checkMark}
|
||||
className=" absolute right-[8px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
161
src/components/filters/TagFilter.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { iconFilter, iconFilterActive } from '../../assets/icons/filters';
|
||||
import { Input } from '../input/Input';
|
||||
import { PrimaryButton } from '../button/PrimaryButton';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
import { SecondaryButton } from '../button/SecondaryButton';
|
||||
|
||||
interface TagFilterProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (items: string[]) => void;
|
||||
}
|
||||
|
||||
export const TagFilter: React.FC<TagFilterProps> = ({
|
||||
disabled = false,
|
||||
className = '',
|
||||
onChange,
|
||||
}) => {
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
|
||||
// ==========================
|
||||
// Теги
|
||||
// ==========================
|
||||
const addTag = () => {
|
||||
if (tags.length > 30) {
|
||||
setTagInput('');
|
||||
toastError('Нельзя добавить больше 30 тегов');
|
||||
return;
|
||||
}
|
||||
const newTag = tagInput.trim();
|
||||
if (newTag && !tags.includes(newTag)) {
|
||||
setTags([...tags, newTag]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter((tag) => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const resetTags = () => {
|
||||
setTags([]);
|
||||
};
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
onChange(tags);
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'items-center h-[40px] rounded-full bg-liquid-lighter w-[40px] flex',
|
||||
'text-[18px] font-bold cursor-pointer select-none',
|
||||
'overflow-hidden',
|
||||
(active || tags.length > 0) &&
|
||||
'w-fit border-liquid-brightmain border-[1px] border-solid',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) setActive(!active);
|
||||
}}
|
||||
>
|
||||
<div className="text-liquid-brightmain pl-[42px] pr-[16px] w-fit">
|
||||
{tags.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter icons */}
|
||||
<img
|
||||
src={iconFilter}
|
||||
className={cn(
|
||||
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={iconFilterActive}
|
||||
className={cn(
|
||||
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
|
||||
(active || tags.length > 0) && 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-[10px] bg-liquid-background w-[590px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden border-liquid-lighter border-[3px] border-solid',
|
||||
active
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden p-[8px]">
|
||||
<div className="overflow-y-scroll min-h-[130px] thin-scrollbar grid gap-[20px]">
|
||||
{/* Теги */}
|
||||
<div className="">
|
||||
<div className="grid grid-cols-[1fr,140px,130px] items-end gap-2">
|
||||
<Input
|
||||
name="articleTag"
|
||||
autocomplete="articleTag"
|
||||
className="max-w-[600px] "
|
||||
type="text"
|
||||
label="Теги"
|
||||
onChange={setTagInput}
|
||||
defaultState={tagInput}
|
||||
placeholder="arrays"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') addTag();
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
onClick={addTag}
|
||||
text="Добавить"
|
||||
className="h-[40px] w-[140px]"
|
||||
/>
|
||||
<SecondaryButton
|
||||
onClick={resetTags}
|
||||
text="Сбросить"
|
||||
className="h-[40px] w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[10px] mt-2 ">
|
||||
{tags.length == 0 ? (
|
||||
<div className="text-liquid-brightmain flex items-center justify-center w-full h-[50px]">
|
||||
Вы еще не добавили ни одного тега
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
35
src/components/input/DateInput.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// DateInput.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface DateInputProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DateInput: React.FC<DateInputProps> = ({
|
||||
label = 'Дата',
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
<label className="block text-sm font-medium text-liquid-white">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="mt-1 block w-full rounded-[10px] sm:text-sm outline-none p-[8px] text-liquid-white cursor-text bg-liquid-lighter"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateInput;
|
||||
48
src/components/input/DateRangeInput.tsx
Normal 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 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 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangeInput;
|
||||
117
src/components/input/DropDownList.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
|
||||
export interface DropDownListItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DropDownListProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: DropDownListItem;
|
||||
items: DropDownListItem[];
|
||||
weight?: string;
|
||||
}
|
||||
|
||||
export const DropDownList: React.FC<DropDownListProps> = ({
|
||||
// disabled = false,
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState,
|
||||
items = [{ text: '', value: '' }],
|
||||
weight = 'w-[180px]',
|
||||
}) => {
|
||||
if (items.length == 0) items.push({ text: '', value: '' });
|
||||
|
||||
const [value, setValue] = React.useState<DropDownListItem>(
|
||||
defaultState != undefined ? defaultState : items[0],
|
||||
);
|
||||
const [active, setActive] = React.useState<boolean>(false);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue(defaultState != undefined ? defaultState : items[0]);
|
||||
}, [defaultState]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px]',
|
||||
'text-[18px] font-bold cursor-pointer select-none',
|
||||
'transitin-all active:scale-95 duration-300',
|
||||
weight,
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
>
|
||||
{value.text}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={chevroneDropDownList}
|
||||
className={cn(
|
||||
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none select-none',
|
||||
active && ' rotate-180',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
' absolute rounded-[10px] bg-liquid-lighter left-0 top-[48px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden',
|
||||
weight,
|
||||
active
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className=" overflow-hidden p-[8px] border-liquid-background border-solid border-[1px] rounded-[10px]">
|
||||
<div
|
||||
className={cn(
|
||||
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
||||
)}
|
||||
>
|
||||
{items.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'cursor-pointer h-[36px] relative transition-all duration-300',
|
||||
i + 1 != items.length &&
|
||||
'border-b-liquid-light border-b-[1px]',
|
||||
'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
|
||||
'hover:bg-liquid-background',
|
||||
'first:rounded-t-[6px] last:rounded-b-[6px]',
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue(v);
|
||||
onChange(v.value);
|
||||
setActive(false);
|
||||
}}
|
||||
>
|
||||
{v.text}
|
||||
|
||||
{v.text == value.text && (
|
||||
<img
|
||||
src={checkMark}
|
||||
className=" absolute right-[8px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +1,98 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
import { eyeClosed, eyeOpen } from "../../assets/icons/input";
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { eyeClosed, eyeOpen } from '../../assets/icons/input';
|
||||
|
||||
interface inputProps {
|
||||
name?: string;
|
||||
type: "text" | "email" | "password" | "first_name";
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: string;
|
||||
autocomplete?: string;
|
||||
name?: string;
|
||||
type: 'text' | 'email' | 'password' | 'first_name' | 'number';
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: string;
|
||||
autocomplete?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const Input: React.FC<inputProps> = ({
|
||||
type = "text",
|
||||
error = "",
|
||||
// disabled = false,
|
||||
// required = false,
|
||||
label = "",
|
||||
placeholder = "",
|
||||
className = "",
|
||||
onChange,
|
||||
defaultState = "",
|
||||
name = "",
|
||||
autocomplete="",
|
||||
type = 'text',
|
||||
error = '',
|
||||
// disabled = false,
|
||||
// required = false,
|
||||
label = '',
|
||||
placeholder = '',
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
onChange,
|
||||
defaultState = '',
|
||||
name = '',
|
||||
autocomplete = '',
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [value, setValue] = React.useState<string>(defaultState);
|
||||
const [visible, setVIsible] = React.useState<boolean>(type != "password");
|
||||
const [value, setValue] = React.useState<string>(defaultState);
|
||||
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]',
|
||||
inputClassName,
|
||||
)}
|
||||
value={value}
|
||||
name={name}
|
||||
autoComplete={autocomplete || undefined}
|
||||
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 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]"
|
||||
)}
|
||||
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>
|
||||
);
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'text-liquid-red text-[14px] h-auto text-right mt-[5px] whitespace-pre-line ',
|
||||
error == '' && 'h-0 mt-0',
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
73
src/components/input/NumberInput.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
interface NumberInputProps {
|
||||
name?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
onChange: (state: number) => void;
|
||||
defaultState?: number;
|
||||
}
|
||||
|
||||
export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
error = '',
|
||||
// disabled = false,
|
||||
// required = false,
|
||||
label = '',
|
||||
placeholder = '',
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState = 0,
|
||||
minValue = 0,
|
||||
maxValue = 365 * 24,
|
||||
name = '',
|
||||
}) => {
|
||||
const [value, setValue] = React.useState<number>(defaultState);
|
||||
|
||||
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 pr-[5px]">
|
||||
<input
|
||||
className={cn(
|
||||
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] pr-[8px] py-[8px] placeholder:text-liquid-light',
|
||||
)}
|
||||
value={value}
|
||||
name={name}
|
||||
type={'number'}
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'text-liquid-red text-[14px] h-auto text-right mt-[5px] whitespace-pre-line ',
|
||||
error == '' && 'h-0 mt-0',
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
src/components/input/SearchInput.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { iconSearch } from '../../assets/icons/filters';
|
||||
|
||||
interface searchInputProps {
|
||||
name?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: string;
|
||||
autocomplete?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC<searchInputProps> = ({
|
||||
placeholder = '',
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState = '',
|
||||
name = '',
|
||||
autocomplete = '',
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [value, setValue] = React.useState<string>(defaultState);
|
||||
|
||||
React.useEffect(() => onChange(value), [value]);
|
||||
React.useEffect(() => setValue(defaultState), [defaultState]);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'relative bg-liquid-lighter w-[200px] h-[40px] flex rounded-full px-[16px] pl-[50px] cursor-text',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={cn(
|
||||
'placeholder:text-liquid-light h-[28px] w-[200px] bg-transparent outline-none text-liquid-white my-[6px]',
|
||||
)}
|
||||
value={value}
|
||||
name={name}
|
||||
autoComplete={autocomplete}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={iconSearch}
|
||||
className=" absolute top-[8px] left-[16px] w-[24px] h-[24px]"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
62
src/components/modal/ConfirmModal.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FC } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { PrimaryButton } from '../../components/button/PrimaryButton';
|
||||
import { SecondaryButton } from '../../components/button/SecondaryButton';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
active: boolean;
|
||||
setActive: (value: boolean) => void;
|
||||
onConfirmClick: () => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmColor?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
||||
confirmText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConfirmModal: FC<ConfirmModalProps> = ({
|
||||
active,
|
||||
setActive,
|
||||
onConfirmClick,
|
||||
title,
|
||||
message,
|
||||
confirmColor = 'secondary',
|
||||
confirmText = 'Ок',
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
className={cn(
|
||||
'bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white',
|
||||
className,
|
||||
)}
|
||||
onOpenChange={setActive}
|
||||
open={active}
|
||||
backdrop="blur"
|
||||
>
|
||||
<div className="w-[500px]">
|
||||
<div className="font-bold text-[30px]">{title}</div>
|
||||
<div className="font-bold text-[20px] mt-[20px]">{message}</div>
|
||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
onConfirmClick();
|
||||
setActive(false);
|
||||
}}
|
||||
text={confirmText}
|
||||
color={confirmColor}
|
||||
/>
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
setActive(false);
|
||||
}}
|
||||
text="Отмена"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmModal;
|
||||
80
src/components/modal/Modal.tsx
Normal 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-[background-color,backdrop-filter,opacity] 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>
|
||||
);
|
||||
};
|
||||
14
src/components/router/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/routes/ProtectedRoute.tsx
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../redux/hooks';
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/home/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1,187 +1,191 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
/* Варианты размера контейнера */
|
||||
const sizeVariants = {
|
||||
sm: "h-6 w-10",
|
||||
md: "h-7 w-12",
|
||||
lg: "h-8 w-14",
|
||||
sm: 'h-6 w-10',
|
||||
md: 'h-7 w-12',
|
||||
lg: 'h-8 w-14',
|
||||
};
|
||||
|
||||
/* Варианты для скользящего шарика */
|
||||
const switchVariants = {
|
||||
size: {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
},
|
||||
activeSize: {
|
||||
sm: "group-active:w-5",
|
||||
md: "group-active:w-6",
|
||||
lg: "group-active:w-7",
|
||||
},
|
||||
iconSize: {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-[0.875rem] w-[0.875rem]",
|
||||
lg: "h-4 w-4",
|
||||
},
|
||||
size: {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6',
|
||||
},
|
||||
activeSize: {
|
||||
sm: 'group-active:w-5',
|
||||
md: 'group-active:w-6',
|
||||
lg: 'group-active:w-7',
|
||||
},
|
||||
iconSize: {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-[0.875rem] w-[0.875rem]',
|
||||
lg: 'h-4 w-4',
|
||||
},
|
||||
};
|
||||
|
||||
const colorsVariants = {
|
||||
default: "bg-default",
|
||||
primary: "bg-liquid-brightmain",
|
||||
secondary: "bg-liquid-darkmain",
|
||||
success: "bg-liquid-green",
|
||||
warning: "bg-liquid-orange",
|
||||
danger: "bg-liquid-red",
|
||||
default: 'bg-default',
|
||||
primary: 'bg-liquid-brightmain',
|
||||
secondary: 'bg-liquid-darkmain',
|
||||
success: 'bg-liquid-green',
|
||||
warning: 'bg-liquid-orange',
|
||||
danger: 'bg-liquid-red',
|
||||
};
|
||||
|
||||
const focuseOutlineVariants = {
|
||||
default: "[&:focus-visible+*]:outline-default",
|
||||
primary: "[&:focus-visible+*]:outline-liquid-brightmain",
|
||||
secondary: "[&:focus-visible+*]:outline-liquid-darkmain",
|
||||
success: "[&:focus-visible+*]:outline-liquid-green",
|
||||
warning: "[&:focus-visible+*]:outline-liquid-orange",
|
||||
danger: "[&:focus-visible+*]:outline-liquid-red",
|
||||
default: '[&:focus-visible+*]:outline-default',
|
||||
primary: '[&:focus-visible+*]:outline-liquid-brightmain',
|
||||
secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
|
||||
success: '[&:focus-visible+*]:outline-liquid-green',
|
||||
warning: '[&:focus-visible+*]:outline-liquid-orange',
|
||||
danger: '[&:focus-visible+*]:outline-liquid-red',
|
||||
};
|
||||
|
||||
/**
|
||||
* Иконка солнца
|
||||
*/
|
||||
const sun = (
|
||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"
|
||||
fill="#292D32"
|
||||
/>
|
||||
<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"
|
||||
fill="#292D32"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"
|
||||
fill="#292D32"
|
||||
/>
|
||||
<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"
|
||||
fill="#292D32"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Иконка луны
|
||||
*/
|
||||
const moon = (
|
||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"
|
||||
fill="#292D32"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"
|
||||
fill="#292D32"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SwitchProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
color?:
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
label?: string;
|
||||
variant?: "default" | "label" | "icon" | "theme";
|
||||
className?: string;
|
||||
defaultState?: boolean;
|
||||
onChange: (state: boolean) => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
color?:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger';
|
||||
label?: string;
|
||||
variant?: 'default' | 'label' | 'icon' | 'theme';
|
||||
className?: string;
|
||||
defaultState?: boolean;
|
||||
onChange: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export const Switch: React.FC<SwitchProps> = ({
|
||||
size = "sm",
|
||||
disabled = false,
|
||||
color = "primary",
|
||||
label = "",
|
||||
variant = "default",
|
||||
className,
|
||||
onChange,
|
||||
defaultState = false,
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
color = 'primary',
|
||||
label = '',
|
||||
variant = 'default',
|
||||
className,
|
||||
onChange,
|
||||
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 (
|
||||
<label
|
||||
className={cn(
|
||||
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2",
|
||||
"grid relative cursor-pointer p-2 select-none group",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
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)]"
|
||||
)}
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
|
||||
'grid relative cursor-pointer p-2 select-none group',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{variant == "theme" && (
|
||||
<>
|
||||
<div
|
||||
{/* Основной контейнер, */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute transition-all duration-300",
|
||||
switchVariants.iconSize[size],
|
||||
active ? "opacity-100 scale-100" : "opacity-0 scale-50"
|
||||
' flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full',
|
||||
sizeVariants[size],
|
||||
active ? colorsVariants[color] : 'bg-default-200',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
>
|
||||
{/* Скрытый checkbox */}
|
||||
<input
|
||||
className={cn(
|
||||
'absolute opacity-0 -z-10 h-0 w-0',
|
||||
focuseOutlineVariants[color],
|
||||
)}
|
||||
disabled={disabled}
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
/>
|
||||
|
||||
{variant == "label" && (
|
||||
<div className="select-none text-layout-foreground transition-all duration-200">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
<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
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export default {
|
||||
liquid: {
|
||||
brightmain: "var(--color-liquid-brightmain)",
|
||||
darkmain: "var(--color-liquid-darkmain)",
|
||||
darker: "var(--color-liquid-darker)",
|
||||
background: "var(--color-liquid-background)",
|
||||
lighter: "var(--color-liquid-lighter)",
|
||||
white: "var(--color-liquid-white)",
|
||||
red: "var(--color-liquid-red)",
|
||||
green: "var(--color-liquid-green)",
|
||||
light: "var(--color-liquid-light)",
|
||||
orange: "var(--color-liquid-orange)",
|
||||
}
|
||||
liquid: {
|
||||
brightmain: 'var(--color-liquid-brightmain)',
|
||||
darkmain: 'var(--color-liquid-darkmain)',
|
||||
darker: 'var(--color-liquid-darker)',
|
||||
background: 'var(--color-liquid-background)',
|
||||
lighter: 'var(--color-liquid-lighter)',
|
||||
white: 'var(--color-liquid-white)',
|
||||
red: 'var(--color-liquid-red)',
|
||||
green: 'var(--color-liquid-green)',
|
||||
light: 'var(--color-liquid-light)',
|
||||
orange: 'var(--color-liquid-orange)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
|
||||
export const useClickOutside = (ref: React.RefObject<any>, onClickOutside: () => void) => {
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
onClickOutside();
|
||||
}
|
||||
}
|
||||
export const useClickOutside = (
|
||||
ref: React.RefObject<any>,
|
||||
onClickOutside: () => void,
|
||||
) => {
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
onClickOutside();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("touchstart", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("touchstart", handleClickOutside);
|
||||
}
|
||||
}, [ref, onClickOutside]);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchstart', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchstart', handleClickOutside);
|
||||
};
|
||||
}, [ref, onClickOutside]);
|
||||
};
|
||||
|
||||
7
src/hooks/useQuery.ts
Normal 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]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
34
src/lib/toastNotification.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const toastSuccess = (mes: string, autoClose: number = 3000) => {
|
||||
toast.success(mes, {
|
||||
position: 'top-right',
|
||||
autoClose: autoClose,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const toastWarning = (mes: string, autoClose: number = 3000) => {
|
||||
toast.warning(mes, {
|
||||
position: 'top-right',
|
||||
autoClose: autoClose,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const toastError = (mes: string, autoClose: number = 3000) => {
|
||||
toast.error(mes, {
|
||||
position: 'top-right',
|
||||
autoClose: autoClose,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
});
|
||||
};
|
||||
30
src/main.tsx
@@ -1,16 +1,18 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./styles/index.css";
|
||||
import "./styles/palette/theme-dark.css";
|
||||
import "./styles/palette/theme-light.css";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './styles/index.css';
|
||||
import './styles/palette/theme-dark.css';
|
||||
import './styles/palette/theme-light.css';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './redux/store';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<ToastContainer />
|
||||
</Provider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
78
src/pages/Article.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
||||
|
||||
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.fetchArticleById.article,
|
||||
);
|
||||
const status = useAppSelector(
|
||||
(state) => state.articles.fetchArticleById.status,
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<ArticlesRightPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Article;
|
||||
274
src/pages/ArticleEditor.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
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';
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
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 refactor = articleId && !isNaN(articleId);
|
||||
const [clickSubmit, setClickSubmit] = useState<boolean>(false);
|
||||
|
||||
// Достаём данные из redux
|
||||
const article = useAppSelector(
|
||||
(state) => state.articles.fetchArticleById.article,
|
||||
);
|
||||
|
||||
const statusCreate = useAppSelector(
|
||||
(state) => state.articles.createArticle.status,
|
||||
);
|
||||
const statusUpdate = useAppSelector(
|
||||
(state) => state.articles.updateArticle.status,
|
||||
);
|
||||
const statusDelete = useAppSelector(
|
||||
(state) => state.articles.deleteArticle.status,
|
||||
);
|
||||
|
||||
// Локальные состояния
|
||||
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 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: 'createArticle', status: 'idle' }),
|
||||
);
|
||||
navigate(back ?? '/home/articles');
|
||||
}
|
||||
}, [statusCreate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusUpdate === 'successful') {
|
||||
dispatch(
|
||||
setArticlesStatus({ key: 'updateArticle', status: 'idle' }),
|
||||
);
|
||||
navigate(back ?? '/home/articles');
|
||||
}
|
||||
}, [statusUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusDelete === 'successful') {
|
||||
dispatch(
|
||||
setArticlesStatus({ key: 'deleteArticle', status: 'idle' }),
|
||||
);
|
||||
navigate(back ?? '/home/articles');
|
||||
}
|
||||
}, [statusDelete]);
|
||||
|
||||
// ==========================
|
||||
// Получение статьи
|
||||
// ==========================
|
||||
useEffect(() => {
|
||||
setClickSubmit(false);
|
||||
if (articleId) {
|
||||
dispatch(fetchArticleById(articleId));
|
||||
}
|
||||
}, [articleId]);
|
||||
|
||||
// Обновление локального состояния после загрузки статьи
|
||||
useEffect(() => {
|
||||
if (article && refactor) {
|
||||
setCode(article.content || '');
|
||||
setName(article.name || '');
|
||||
setTags(article.tags || []);
|
||||
}
|
||||
}, [article]);
|
||||
|
||||
const getNameErrorMessage = (): string => {
|
||||
if (!clickSubmit) return '';
|
||||
if (name == '') return 'Поле не может быть пустым';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getContentErrorMessage = (): string => {
|
||||
if (!clickSubmit) return '';
|
||||
if (code == '') return 'Поле не может быть пустым';
|
||||
return '';
|
||||
};
|
||||
|
||||
// ==========================
|
||||
// Рендер
|
||||
// ==========================
|
||||
return (
|
||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||
{activeEditor ? (
|
||||
<Header backClick={() => setActiveEditor(false)} />
|
||||
) : (
|
||||
<Header backClick={() => navigate(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={() => {
|
||||
setClickSubmit(true);
|
||||
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={() => {
|
||||
setClickSubmit(true);
|
||||
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={setName}
|
||||
placeholder="Новая статья"
|
||||
error={getNameErrorMessage()}
|
||||
/>
|
||||
|
||||
{/* Теги */}
|
||||
<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={setTagInput}
|
||||
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>
|
||||
|
||||
{/* Просмотр и переход в редактор */}
|
||||
<PrimaryButton
|
||||
onClick={() => setActiveEditor(true)}
|
||||
text="Редактировать текст"
|
||||
className="mt-[20px]"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'text-liquid-red text-[14px] h-auto mt-[5px] whitespace-pre-line ',
|
||||
getContentErrorMessage() == '' && 'h-0 mt-0',
|
||||
)}
|
||||
>
|
||||
{getContentErrorMessage()}
|
||||
</div>
|
||||
<MarkdownPreview
|
||||
content={code}
|
||||
className="bg-transparent border-liquid-lighter border-[3px] rounded-[20px] mt-[20px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleEditor;
|
||||
610
src/pages/ContestEditor.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Header from '../views/articleeditor/Header';
|
||||
import { PrimaryButton } from '../components/button/PrimaryButton';
|
||||
import { Input } from '../components/input/Input';
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||
import {
|
||||
CreateContestBody,
|
||||
deleteContest,
|
||||
fetchContestById,
|
||||
setContestStatus,
|
||||
updateContest,
|
||||
} from '../redux/slices/contests';
|
||||
import { useQuery } from '../hooks/useQuery';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { fetchMissionById, fetchMissions } from '../redux/slices/missions';
|
||||
import { ReverseButton } from '../components/button/ReverseButton';
|
||||
import {
|
||||
DropDownList,
|
||||
DropDownListItem,
|
||||
} from '../components/input/DropDownList';
|
||||
import { NumberInput } from '../components/input/NumberInput';
|
||||
import { cn } from '../lib/cn';
|
||||
import DateInput from '../components/input/DateInput';
|
||||
import { fetchMyGroups } from '../redux/slices/groups';
|
||||
|
||||
interface Mission {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const highlightZ = (name: string, filter: string) => {
|
||||
if (!filter) return name;
|
||||
|
||||
const s = filter.toLowerCase();
|
||||
const t = name.toLowerCase();
|
||||
const n = t.length;
|
||||
const m = s.length;
|
||||
|
||||
const mark = Array(n).fill(false);
|
||||
|
||||
// Проходимся с конца и ставим отметки
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
if (i + m <= n && t.slice(i, i + m) === s) {
|
||||
for (let j = i; j < i + m; j++) {
|
||||
if (mark[j]) break;
|
||||
mark[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Формируем единые жёлтые блоки ===
|
||||
const result: any[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < n) {
|
||||
if (!mark[i]) {
|
||||
// обычный символ
|
||||
result.push(name[i]);
|
||||
i++;
|
||||
} else {
|
||||
// начинаем жёлтый блок
|
||||
let j = i;
|
||||
while (j < n && mark[j]) j++;
|
||||
|
||||
const chunk = name.slice(i, j);
|
||||
result.push(
|
||||
<span key={i} className="bg-yellow-400 text-black rounded px-1">
|
||||
{chunk}
|
||||
</span>,
|
||||
);
|
||||
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
function toUtc(localDateTime?: string): string {
|
||||
if (!localDateTime) return '';
|
||||
|
||||
// Создаём дату (она автоматически считается как локальная)
|
||||
const date = new Date(localDateTime);
|
||||
|
||||
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Страница создания / редактирования контеста
|
||||
*/
|
||||
const ContestEditor = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const query = useQuery();
|
||||
const back = query.get('back') ?? undefined;
|
||||
const contestId = Number(query.get('contestId') ?? undefined);
|
||||
const refactor = !!contestId;
|
||||
|
||||
if (!refactor) {
|
||||
return <Navigate to="/home/account/acontest" />;
|
||||
}
|
||||
|
||||
const status = useAppSelector(
|
||||
(state) => state.contests.createContest.status,
|
||||
);
|
||||
|
||||
const [missionFindInput, setMissionFindInput] = useState<string>('');
|
||||
|
||||
const now = new Date();
|
||||
const plus60 = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
|
||||
const toLocal = (d: Date) => {
|
||||
const off = d.getTimezoneOffset();
|
||||
const local = new Date(d.getTime() - off * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const visibilityItems: DropDownListItem[] = [
|
||||
{ value: 'Public', text: 'Публичный' },
|
||||
{ value: 'GroupPrivate', text: 'Для группы' },
|
||||
];
|
||||
|
||||
const scheduleTypeItems: DropDownListItem[] = [
|
||||
{ value: 'AlwaysOpen', text: 'Всегда открыт' },
|
||||
{ value: 'FixedWindow', text: 'Фиксированое окно' },
|
||||
{ value: 'RollingWindow', text: 'Скользящее окно' },
|
||||
];
|
||||
|
||||
const [contest, setContest] = useState<CreateContestBody>({
|
||||
name: '',
|
||||
description: '',
|
||||
scheduleType: 'AlwaysOpen',
|
||||
visibility: 'Public',
|
||||
startsAt: toLocal(now),
|
||||
endsAt: toLocal(plus60),
|
||||
attemptDurationMinutes: 60,
|
||||
maxAttempts: 1,
|
||||
allowEarlyFinish: false,
|
||||
missionIds: [],
|
||||
articleIds: [],
|
||||
});
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
|
||||
const statusDelete = useAppSelector(
|
||||
(state) => state.contests.deleteContest.status,
|
||||
);
|
||||
const statusUpdate = useAppSelector(
|
||||
(state) => state.contests.updateContest.status,
|
||||
);
|
||||
|
||||
const { contest: contestById, status: contestByIdstatus } = useAppSelector(
|
||||
(state) => state.contests.fetchContestById,
|
||||
);
|
||||
|
||||
const globalMissions = useAppSelector((state) => state.missions.missions);
|
||||
|
||||
const myGroups = useAppSelector(
|
||||
(state) => state.groups.fetchMyGroups.groups,
|
||||
).filter((group) =>
|
||||
group.members.some(
|
||||
(member) =>
|
||||
member.username === myname &&
|
||||
member.role.includes('Administrator'),
|
||||
),
|
||||
);
|
||||
|
||||
function toLocalInputValue(utcString: string) {
|
||||
const d = new Date(utcString);
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
||||
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'successful') {
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleChange = (key: keyof CreateContestBody, value: any) => {
|
||||
setContest((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleUpdateContest = () => {
|
||||
dispatch(
|
||||
updateContest({
|
||||
...contest,
|
||||
endsAt: toUtc(contest.endsAt),
|
||||
startsAt: toUtc(contest.startsAt),
|
||||
contestId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteContest = () => {
|
||||
dispatch(deleteContest(contestId));
|
||||
};
|
||||
|
||||
const addMission = () => {
|
||||
const mission = globalMissions
|
||||
.filter((v) => !contest?.missionIds?.includes(v.id))
|
||||
.filter((v) =>
|
||||
(v.id + ' ' + v.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(missionFindInput.toLocaleLowerCase()),
|
||||
)[0];
|
||||
if (!mission) return;
|
||||
const id = mission.id;
|
||||
if (!id || contest.missionIds?.includes(id)) return;
|
||||
dispatch(fetchMissionById(id))
|
||||
.unwrap()
|
||||
.then((mission) => {
|
||||
setMissions((prev) => [...prev, mission]);
|
||||
setContest((prev) => ({
|
||||
...prev,
|
||||
missionIds: [...(prev.missionIds ?? []), id],
|
||||
}));
|
||||
setMissionFindInput('');
|
||||
})
|
||||
.catch((err) => {
|
||||
err;
|
||||
});
|
||||
};
|
||||
|
||||
const removeMission = (removeId: number) => {
|
||||
setContest({
|
||||
...contest,
|
||||
missionIds: contest.missionIds?.filter((v) => v !== removeId),
|
||||
});
|
||||
setMissions(missions.filter((v) => v.id != removeId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (statusDelete == 'successful') {
|
||||
dispatch(
|
||||
setContestStatus({ key: 'deleteContest', status: 'idle' }),
|
||||
);
|
||||
navigate('/home/account/contests');
|
||||
}
|
||||
}, [statusDelete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusUpdate == 'successful') {
|
||||
dispatch(
|
||||
setContestStatus({ key: 'updateContest', status: 'idle' }),
|
||||
);
|
||||
navigate('/home/account/contests');
|
||||
}
|
||||
}, [statusUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refactor) {
|
||||
dispatch(fetchContestById(contestId));
|
||||
dispatch(fetchMyGroups());
|
||||
dispatch(fetchMissions({}));
|
||||
}
|
||||
}, [refactor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refactor && contestByIdstatus == 'successful' && contestById) {
|
||||
setContest({
|
||||
...contestById,
|
||||
// groupIds: contestById.groups.map(group => group.groupId),
|
||||
missionIds: contestById.missions?.map((mission) => mission.id),
|
||||
articleIds: contestById.articles?.map(
|
||||
(article) => article.articleId,
|
||||
),
|
||||
});
|
||||
setMissions(contestById.missions ?? []);
|
||||
}
|
||||
}, [contestById]);
|
||||
|
||||
const visibilityDefaultState =
|
||||
visibilityItems.find(
|
||||
(i) => contest && i.value === contest.visibility,
|
||||
) ?? visibilityItems[0];
|
||||
|
||||
const scheduleTypeDefaultState =
|
||||
scheduleTypeItems.find(
|
||||
(i) => contest && i.value === contest.scheduleType,
|
||||
) ?? scheduleTypeItems[0];
|
||||
|
||||
const groupItems = myGroups.map((v) => {
|
||||
return {
|
||||
value: '' + v.id,
|
||||
text: v.name,
|
||||
};
|
||||
});
|
||||
const groupIdDefaultState =
|
||||
myGroups.find((g) => g.id == contest?.groupId) ??
|
||||
myGroups[0] ??
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<div className="h-screen grid grid-rows-[60px,1fr] text-liquid-white">
|
||||
<Header backClick={() => navigate(back || '/home/contests')} />
|
||||
|
||||
<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>
|
||||
|
||||
<div className="">
|
||||
<div className="font-bold text-[30px] mb-[10px]">
|
||||
{refactor
|
||||
? `Редактирвоание контеста #${contestId} \"${contestById?.name}\"`
|
||||
: 'Создать контест'}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
name="name"
|
||||
type="text"
|
||||
label="Название"
|
||||
className="mt-[10px]"
|
||||
placeholder="Введите название"
|
||||
onChange={(v) => handleChange('name', v)}
|
||||
defaultState={contest.name ?? ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
name="description"
|
||||
type="text"
|
||||
label="Описание"
|
||||
className="mt-[10px]"
|
||||
placeholder="Введите описание"
|
||||
onChange={(v) => handleChange('description', v)}
|
||||
defaultState={contest.description ?? ''}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">
|
||||
Тип контеста
|
||||
</label>
|
||||
|
||||
<DropDownList
|
||||
items={scheduleTypeItems}
|
||||
defaultState={scheduleTypeDefaultState}
|
||||
onChange={(v) => {
|
||||
handleChange('scheduleType', v);
|
||||
}}
|
||||
weight="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">
|
||||
Видимость
|
||||
</label>
|
||||
<DropDownList
|
||||
items={visibilityItems}
|
||||
onChange={(v) => {
|
||||
handleChange('visibility', v);
|
||||
}}
|
||||
defaultState={visibilityDefaultState}
|
||||
weight="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-200 mb-[10px]',
|
||||
contest.visibility == 'GroupPrivate' &&
|
||||
'grid-rows-[1fr] opacity-100',
|
||||
)}
|
||||
>
|
||||
{groupIdDefaultState ? (
|
||||
<div
|
||||
className={cn(
|
||||
contest.visibility !=
|
||||
'GroupPrivate' &&
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm mb-2 mt-[10px]">
|
||||
Группа для привязки
|
||||
</label>
|
||||
|
||||
<DropDownList
|
||||
items={groupItems}
|
||||
defaultState={{
|
||||
value:
|
||||
'' +
|
||||
groupIdDefaultState.id,
|
||||
text: groupIdDefaultState.name,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
handleChange(
|
||||
'groupId',
|
||||
Number(v),
|
||||
);
|
||||
}}
|
||||
weight="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden">
|
||||
<div className="text-liquid-red my-[20px]">
|
||||
У вас нет группы вкоторой вы
|
||||
являетесь Администратором!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Даты */}
|
||||
<div
|
||||
className={cn(
|
||||
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-200',
|
||||
contest.scheduleType != 'AlwaysOpen' &&
|
||||
'grid-rows-[1fr] opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||
<DateInput
|
||||
label="Дата начала"
|
||||
value={
|
||||
contest.startsAt
|
||||
? toLocalInputValue(
|
||||
contest.startsAt,
|
||||
)
|
||||
: ''
|
||||
}
|
||||
onChange={(v) =>
|
||||
handleChange('startsAt', v)
|
||||
}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
label="Дата окончания"
|
||||
value={
|
||||
contest.endsAt
|
||||
? toLocalInputValue(
|
||||
contest.endsAt,
|
||||
)
|
||||
: ''
|
||||
}
|
||||
onChange={(v) =>
|
||||
handleChange('endsAt', v)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Продолжительность и лимиты */}
|
||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||
<NumberInput
|
||||
defaultState={
|
||||
contest.attemptDurationMinutes
|
||||
}
|
||||
name="attemptDurationMinutes"
|
||||
label="Длительность попытки (мин)"
|
||||
placeholder="Например: 60"
|
||||
minValue={1}
|
||||
maxValue={365 * 24 * 60}
|
||||
onChange={(v) =>
|
||||
handleChange(
|
||||
'attemptDurationMinutes',
|
||||
Number(v),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultState={contest.maxAttempts}
|
||||
name="maxAttempts"
|
||||
label="Макс. попыток"
|
||||
placeholder="Например: 3"
|
||||
minValue={1}
|
||||
maxValue={100}
|
||||
onChange={(v) =>
|
||||
handleChange('maxAttempts', Number(v))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||
<PrimaryButton
|
||||
onClick={handleUpdateContest}
|
||||
text="Сохранить"
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
<ReverseButton
|
||||
color="error"
|
||||
onClick={handleDeleteContest}
|
||||
text="Удалить"
|
||||
disabled={statusDelete === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая панель */}
|
||||
<div className="min-h-0 ">
|
||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||
{/* Блок для тегов */}
|
||||
<div className="mt-[20px] max-w-[600px] relative">
|
||||
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
||||
<Input
|
||||
name="missionId"
|
||||
autocomplete="missionId"
|
||||
className="mt-[20px] max-w-[600px]"
|
||||
label="Введите название или ID миссии"
|
||||
type="text"
|
||||
onChange={(v) => {
|
||||
setMissionFindInput(v);
|
||||
}}
|
||||
defaultState={missionFindInput}
|
||||
placeholder={`Наприме: \"458\" или \"Поиск наименьшего\"`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == 'Enter') addMission();
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
onClick={addMission}
|
||||
text="Добавить"
|
||||
className="h-[40px] w-[140px]"
|
||||
/>
|
||||
|
||||
{/* Выпадающие задачи */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-[10px] bg-liquid-background w-[590px] left-0 top-[100px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden border-liquid-lighter border-[3px] border-solid',
|
||||
missionFindInput
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden p-[8px]">
|
||||
<div className="overflow-y-scroll max-h-[250px] thin-scrollbar grid gap-[20px]">
|
||||
{globalMissions
|
||||
.filter(
|
||||
(v) =>
|
||||
!contest?.missionIds?.includes(
|
||||
v.id,
|
||||
),
|
||||
)
|
||||
.filter((v) =>
|
||||
(v.id + ' ' + v.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(
|
||||
missionFindInput.toLocaleLowerCase(),
|
||||
),
|
||||
)
|
||||
.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="hover:bg-liquid-lighter rounded-[10px] px-[12px] py-[4px] transition-colors duration-300 cursor-pointer"
|
||||
onClick={() => {
|
||||
setMissionFindInput(
|
||||
v.id +
|
||||
' ' +
|
||||
v.name,
|
||||
);
|
||||
addMission();
|
||||
}}
|
||||
>
|
||||
{highlightZ(
|
||||
'#' +
|
||||
v.id +
|
||||
' ' +
|
||||
v.name,
|
||||
missionFindInput,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gap-[10px] mt-[20px]">
|
||||
{missions.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[60px,1fr,24px] gap-1 bg-liquid-lighter px-[16px] py-[8px] rounded-[10px] relative mb-[10px] items-center"
|
||||
>
|
||||
<div>{'#' + v.id}</div>
|
||||
<div>{v.name}</div>
|
||||
<button
|
||||
onClick={() => removeMission(v.id)}
|
||||
className="text-liquid-red font-bold ml-[5px] absolute right-[16px]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestEditor;
|
||||
@@ -1,51 +1,73 @@
|
||||
// import React from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import Login from "../views/home/auth/Login";
|
||||
import Register from "../views/home/auth/Register";
|
||||
import Menu from "../views/home/menu/Menu";
|
||||
import { useAppDispatch, useAppSelector } from "../redux/hooks";
|
||||
import { useEffect } from "react";
|
||||
import { fetchWhoAmI, logout } from "../redux/slices/auth";
|
||||
import Missions from "../views/home/missions/Missions";
|
||||
import Articles from "../views/home/articles/Articles";
|
||||
import Groups from "../views/home/groups/Groups";
|
||||
import Contests from "../views/home/contests/Contests";
|
||||
import { PrimaryButton } from "../components/button/PrimaryButton";
|
||||
// src/pages/Home.tsx
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import Login from '../views/home/auth/Login';
|
||||
import Register from '../views/home/auth/Register';
|
||||
import Menu from '../views/home/menu/Menu';
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||
import { useEffect } from 'react';
|
||||
import { fetchWhoAmI } from '../redux/slices/auth';
|
||||
import Missions from '../views/home/missions/Missions';
|
||||
import Articles from '../views/home/articles/Articles';
|
||||
import Groups from '../views/home/groups/Groups';
|
||||
import Contests from '../views/home/contests/Contests';
|
||||
import Group from '../views/home/group/Group';
|
||||
import Contest from '../views/home/contest/Contest';
|
||||
import Account from '../views/home/account/Account';
|
||||
import ProtectedRoute from '../components/router/ProtectedRoute';
|
||||
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
|
||||
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
||||
import { GroupRightPanel } from '../views/home/rightpanel/group/Group';
|
||||
import GroupInvite from '../views/home/groupinviter/GroupInvite';
|
||||
|
||||
const Home = () => {
|
||||
const name = useAppSelector((state) => state.auth.username);
|
||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
||||
const dispatch = useAppDispatch();
|
||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchWhoAmI());
|
||||
}, [jwt])
|
||||
useEffect(() => {
|
||||
dispatch(fetchWhoAmI());
|
||||
}, [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
|
||||
path="group-invite/*"
|
||||
element={<GroupInvite />}
|
||||
/>
|
||||
<Route path="group/:groupId/*" element={<Group />} />
|
||||
<Route path="groups/*" element={<Groups />} />
|
||||
</Route>
|
||||
|
||||
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="">
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="account" element={<Login />} />
|
||||
<Route path="register" element={<Register />} />
|
||||
<Route path="missions/*" element={<Missions/>} />
|
||||
<Route path="articles/*" element={<Articles/>} />
|
||||
<Route path="groups/*" element={<Groups/>} />
|
||||
<Route path="contests/*" element={<Contests/>} />
|
||||
<Route path="*" element={<>{name}<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton></>} />
|
||||
</Routes>
|
||||
</div>
|
||||
{
|
||||
<Routes>
|
||||
<Route path="articles/*" element={<div></div>} />
|
||||
</Routes>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="register" element={<Register />} />
|
||||
<Route path="missions/*" element={<Missions />} />
|
||||
<Route path="articles/*" element={<Articles />} />
|
||||
<Route path="contests/*" element={<Contests />} />
|
||||
<Route path="contest/:contestId/*" element={<Contest />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/home/account" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
{
|
||||
<Routes>
|
||||
<Route path="articles/*" element={<ArticlesRightPanel />} />
|
||||
<Route path="missions/*" element={<MissionsRightPanel />} />
|
||||
<Route
|
||||
path="group/:groupId/*"
|
||||
element={<GroupRightPanel />}
|
||||
/>
|
||||
</Routes>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,188 +1,237 @@
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { useParams, Navigate, useNavigate } from 'react-router-dom';
|
||||
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 { useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||
import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
|
||||
import { fetchMissionById } from '../redux/slices/missions';
|
||||
import { fetchMissionById, setMissionsStatus } from '../redux/slices/missions';
|
||||
import Header from '../views/mission/statement/Header';
|
||||
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
||||
import { useQuery } from '../hooks/useQuery';
|
||||
import { fetchMyAttemptsInContest } from '../redux/slices/contests';
|
||||
|
||||
const Mission = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Получаем параметры из URL
|
||||
const { missionId } = useParams<{ missionId: string }>();
|
||||
const mission = useAppSelector((state) => state.missions.currentMission);
|
||||
const missionIdNumber = Number(missionId);
|
||||
if (!missionId || isNaN(missionIdNumber)) {
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
const [code, setCode] = useState<string>("");
|
||||
const [language, setLanguage] = useState<string>("");
|
||||
|
||||
const pollingRef = useRef<number | null>(null);
|
||||
const submissions = useAppSelector((state) => state.submin.submitsById[missionIdNumber] || []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMissionById(missionIdNumber));
|
||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||
}, [missionIdNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (submissions.length === 0) return;
|
||||
|
||||
const hasWaiting = submissions.some(
|
||||
s => s.solution.status === "Waiting" || s.solution.testerState === "Waiting"
|
||||
// Получаем параметры из URL
|
||||
const { missionId } = useParams<{ missionId: string }>();
|
||||
const mission = useAppSelector((state) => state.missions.currentMission);
|
||||
const missionStatus = useAppSelector(
|
||||
(state) => state.missions.statuses.fetchById,
|
||||
);
|
||||
const attempt = useAppSelector(
|
||||
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
|
||||
);
|
||||
|
||||
if (hasWaiting) {
|
||||
startPolling();
|
||||
const missionIdNumber = Number(missionId);
|
||||
|
||||
const query = useQuery();
|
||||
const back = query.get('back') ?? undefined;
|
||||
const contestId = Number(query.get('contestId') ?? undefined);
|
||||
|
||||
if (!missionId || isNaN(missionIdNumber)) {
|
||||
if (back) return <Navigate to={back} replace />;
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
}, [submissions]);
|
||||
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [language, setLanguage] = useState<string>('');
|
||||
|
||||
if (!mission || !mission.statements || mission.statements.length === 0) {
|
||||
return <div>Загрузка...</div>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface StatementData {
|
||||
id: number;
|
||||
legend?: string;
|
||||
timeLimit?: number;
|
||||
output?: string;
|
||||
input?: string;
|
||||
sampleTests?: any[];
|
||||
name?: string;
|
||||
memoryLimit?: number;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
html?: string;
|
||||
mediaFiles?: any[];
|
||||
}
|
||||
|
||||
let statementData: StatementData = { id: mission.id };
|
||||
|
||||
try {
|
||||
// 1. Берём первый statement с форматом Latex и языком russian
|
||||
const latexStatement = mission.statements.find(
|
||||
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Latex"
|
||||
const pollingRef = useRef<number | null>(null);
|
||||
const submissions = useAppSelector(
|
||||
(state) => state.submin.submitsById[missionIdNumber] || [],
|
||||
);
|
||||
const submissionsRef = useRef(submissions);
|
||||
|
||||
// 2. Берём первый statement с форматом Html и языком russian
|
||||
const htmlStatement = mission.statements.find(
|
||||
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html"
|
||||
);
|
||||
const startPolling = () => {
|
||||
if (pollingRef.current) return;
|
||||
|
||||
if (!latexStatement) throw new Error("Не найден блок Latex на русском");
|
||||
if (!htmlStatement) throw new Error("Не найден блок Html на русском");
|
||||
pollingRef.current = setInterval(async () => {
|
||||
if (contestId) {
|
||||
dispatch(fetchMyAttemptsInContest(contestId));
|
||||
}
|
||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||
|
||||
// 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
|
||||
const hasWaiting = submissionsRef.current.some(
|
||||
(s: any) =>
|
||||
s.solution.status == 'Waiting' ||
|
||||
s.solution.testerState === 'Waiting' ||
|
||||
s.solution.status === 'Compiling' ||
|
||||
s.solution.testerState === 'Compiling',
|
||||
);
|
||||
if (!hasWaiting) {
|
||||
// Всё проверено — стоп
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 5000); // 10 секунд
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Ошибка парсинга statementTexts:", err);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollingRef.current)
|
||||
return;
|
||||
|
||||
pollingRef.current = setInterval(async () => {
|
||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||
|
||||
const hasWaiting = submissions.some(
|
||||
(s: any) => s.solution.status == "Waiting" || s.solution.testerState === "Waiting"
|
||||
);
|
||||
if (!hasWaiting) {
|
||||
// Всё проверено — стоп
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
useEffect(() => {
|
||||
if (contestId) {
|
||||
dispatch(fetchMyAttemptsInContest(contestId));
|
||||
}
|
||||
}
|
||||
}, 5000); // 10 секунд
|
||||
};
|
||||
}, [contestId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMissionById(missionIdNumber));
|
||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||
}, [missionIdNumber]);
|
||||
|
||||
useEffect(() => {}, [submissions]);
|
||||
|
||||
return (
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (missionStatus == 'failed') {
|
||||
setMissionsStatus({ key: 'fetchById', status: 'idle' });
|
||||
navigate(back ?? '/home/missions');
|
||||
}
|
||||
}, [missionStatus]);
|
||||
|
||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||
<div className="">
|
||||
<Header missionId={missionIdNumber} />
|
||||
</div>
|
||||
useEffect(() => {
|
||||
submissionsRef.current = submissions;
|
||||
|
||||
<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}
|
||||
if (submissions.length) {
|
||||
const hasWaiting = submissions.some(
|
||||
(s) =>
|
||||
s.solution.status === 'Waiting' ||
|
||||
s.solution.testerState === 'Waiting' ||
|
||||
s.solution.status === 'Compiling' ||
|
||||
s.solution.testerState === 'Compiling',
|
||||
);
|
||||
|
||||
/>
|
||||
if (hasWaiting) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
}, [submissions]);
|
||||
|
||||
if (!mission || !mission.statements || mission.statements.length === 0) {
|
||||
return <div>Загрузка...</div>;
|
||||
}
|
||||
|
||||
interface StatementData {
|
||||
id: number;
|
||||
legend?: string;
|
||||
timeLimit?: number;
|
||||
output?: string;
|
||||
input?: string;
|
||||
sampleTests?: any[];
|
||||
name?: string;
|
||||
memoryLimit?: number;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
html?: string;
|
||||
mediaFiles?: any[];
|
||||
}
|
||||
|
||||
let statementData: StatementData = { id: mission.id };
|
||||
|
||||
try {
|
||||
// 1. Берём первый statement с форматом Latex и языком russian
|
||||
const latexStatement = mission.statements.find(
|
||||
(stmt: any) =>
|
||||
stmt && stmt.language === 'russian' && stmt.format === 'Latex',
|
||||
);
|
||||
|
||||
// 2. Берём первый statement с форматом Html и языком russian
|
||||
const htmlStatement = mission.statements.find(
|
||||
(stmt: any) =>
|
||||
stmt && stmt.language === 'russian' && stmt.format === 'Html',
|
||||
);
|
||||
|
||||
if (!latexStatement) throw new Error('Не найден блок Latex на русском');
|
||||
if (!htmlStatement) throw new Error('Не найден блок Html на русском');
|
||||
|
||||
// 3. Парсим данные из problem-properties.json
|
||||
const statementTexts = JSON.parse(
|
||||
latexStatement.statementTexts['problem-properties.json'],
|
||||
);
|
||||
|
||||
statementData = {
|
||||
id: missionIdNumber,
|
||||
legend: statementTexts.legend,
|
||||
timeLimit: statementTexts.timeLimit,
|
||||
output: statementTexts.output,
|
||||
input: statementTexts.input,
|
||||
sampleTests: statementTexts.sampleTests,
|
||||
name: statementTexts.name,
|
||||
memoryLimit: statementTexts.memoryLimit,
|
||||
tags: mission.tags,
|
||||
notes: statementTexts.notes,
|
||||
html: htmlStatement.statementTexts['problem.html'],
|
||||
mediaFiles: latexStatement.mediaFiles,
|
||||
};
|
||||
} catch (err) {}
|
||||
|
||||
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,
|
||||
contestAttemptId:
|
||||
attempt?.attemptId,
|
||||
}),
|
||||
).unwrap();
|
||||
dispatch(
|
||||
fetchMySubmitsByMission(
|
||||
missionIdNumber,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full ">
|
||||
<MissionSubmissions
|
||||
missionId={missionIdNumber}
|
||||
contestId={contestId}
|
||||
/>
|
||||
</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;
|
||||
|
||||
450
src/redux/slices/articles.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
|
||||
// =====================
|
||||
// Типы
|
||||
// =====================
|
||||
|
||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||
|
||||
export interface Article {
|
||||
id: number;
|
||||
authorId: number;
|
||||
name: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ArticlesResponse {
|
||||
hasNextPage: boolean;
|
||||
articles: Article[];
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Состояние
|
||||
// =====================
|
||||
|
||||
interface ArticlesState {
|
||||
fetchArticles: {
|
||||
articles: Article[];
|
||||
hasNextPage: boolean;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
fetchNewArticles: {
|
||||
articles: Article[];
|
||||
hasNextPage: boolean;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
fetchArticleById: {
|
||||
article?: Article;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
createArticle: {
|
||||
article?: Article;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
updateArticle: {
|
||||
article?: Article;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
deleteArticle: {
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
fetchMyArticles: {
|
||||
articles: Article[];
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: ArticlesState = {
|
||||
fetchArticles: {
|
||||
articles: [],
|
||||
hasNextPage: false,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
fetchNewArticles: {
|
||||
articles: [],
|
||||
hasNextPage: false,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
fetchArticleById: {
|
||||
article: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
createArticle: {
|
||||
article: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
updateArticle: {
|
||||
article: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
deleteArticle: {
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
fetchMyArticles: {
|
||||
articles: [],
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// =====================
|
||||
// Async Thunks
|
||||
// =====================
|
||||
|
||||
// Новые статьи
|
||||
export const fetchNewArticles = createAsyncThunk(
|
||||
'articles/fetchNewArticles',
|
||||
async (
|
||||
{
|
||||
page = 0,
|
||||
pageSize = 5,
|
||||
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<ArticlesResponse>('/articles', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Все статьи
|
||||
export const fetchArticles = createAsyncThunk(
|
||||
'articles/fetchArticles',
|
||||
async (
|
||||
{
|
||||
page = 0,
|
||||
pageSize = 100,
|
||||
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<ArticlesResponse>('/articles', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Мои статьи
|
||||
export const fetchMyArticles = createAsyncThunk(
|
||||
'articles/fetchMyArticles',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get<Article[]>('/articles/my');
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Статья по ID
|
||||
export const fetchArticleById = createAsyncThunk(
|
||||
'articles/fetchById',
|
||||
async (articleId: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get<Article>(`/articles/${articleId}`);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Создание статьи
|
||||
export const createArticle = createAsyncThunk(
|
||||
'articles/create',
|
||||
async (
|
||||
{
|
||||
name,
|
||||
content,
|
||||
tags,
|
||||
}: { name: string; content: string; tags: string[] },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.post<Article>('/articles', {
|
||||
name,
|
||||
content,
|
||||
tags,
|
||||
});
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Обновление статьи
|
||||
export const updateArticle = createAsyncThunk(
|
||||
'articles/update',
|
||||
async (
|
||||
{
|
||||
articleId,
|
||||
name,
|
||||
content,
|
||||
tags,
|
||||
}: { articleId: number; name: string; content: string; tags: string[] },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.put<Article>(
|
||||
`/articles/${articleId}`,
|
||||
{
|
||||
name,
|
||||
content,
|
||||
tags,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Удаление статьи
|
||||
export const deleteArticle = createAsyncThunk(
|
||||
'articles/delete',
|
||||
async (articleId: number, { rejectWithValue }) => {
|
||||
try {
|
||||
await axios.delete(`/articles/${articleId}`);
|
||||
return articleId;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =====================
|
||||
// Slice
|
||||
// =====================
|
||||
|
||||
const articlesSlice = createSlice({
|
||||
name: 'articles',
|
||||
initialState,
|
||||
reducers: {
|
||||
setArticlesStatus: (
|
||||
state,
|
||||
action: PayloadAction<{ key: keyof ArticlesState; status: Status }>,
|
||||
) => {
|
||||
const { key, status } = action.payload;
|
||||
if (state[key]) {
|
||||
(state[key] as any).status = status;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// fetchArticles
|
||||
builder.addCase(fetchArticles.pending, (state) => {
|
||||
state.fetchArticles.status = 'loading';
|
||||
state.fetchArticles.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchArticles.fulfilled,
|
||||
(state, action: PayloadAction<ArticlesResponse>) => {
|
||||
state.fetchArticles.status = 'successful';
|
||||
state.fetchArticles.articles = action.payload.articles;
|
||||
state.fetchArticles.hasNextPage = action.payload.hasNextPage;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchArticles.rejected, (state, action: any) => {
|
||||
state.fetchArticles.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// fetchNewArticles
|
||||
builder.addCase(fetchNewArticles.pending, (state) => {
|
||||
state.fetchNewArticles.status = 'loading';
|
||||
state.fetchNewArticles.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchNewArticles.fulfilled,
|
||||
(state, action: PayloadAction<ArticlesResponse>) => {
|
||||
state.fetchNewArticles.status = 'successful';
|
||||
state.fetchNewArticles.articles = action.payload.articles;
|
||||
state.fetchNewArticles.hasNextPage = action.payload.hasNextPage;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchNewArticles.rejected, (state, action: any) => {
|
||||
state.fetchNewArticles.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// fetchMyArticles
|
||||
builder.addCase(fetchMyArticles.pending, (state) => {
|
||||
state.fetchMyArticles.status = 'loading';
|
||||
state.fetchMyArticles.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchMyArticles.fulfilled,
|
||||
(state, action: PayloadAction<Article[]>) => {
|
||||
state.fetchMyArticles.status = 'successful';
|
||||
state.fetchMyArticles.articles = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchMyArticles.rejected, (state, action: any) => {
|
||||
state.fetchMyArticles.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// fetchArticleById
|
||||
builder.addCase(fetchArticleById.pending, (state) => {
|
||||
state.fetchArticleById.status = 'loading';
|
||||
state.fetchArticleById.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchArticleById.fulfilled,
|
||||
(state, action: PayloadAction<Article>) => {
|
||||
state.fetchArticleById.status = 'successful';
|
||||
state.fetchArticleById.article = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchArticleById.rejected, (state, action: any) => {
|
||||
state.fetchArticleById.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// createArticle
|
||||
builder.addCase(createArticle.pending, (state) => {
|
||||
state.createArticle.status = 'loading';
|
||||
state.createArticle.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
createArticle.fulfilled,
|
||||
(state, action: PayloadAction<Article>) => {
|
||||
state.createArticle.status = 'successful';
|
||||
state.createArticle.article = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(createArticle.rejected, (state, action: any) => {
|
||||
state.createArticle.status = 'failed';
|
||||
state.createArticle.error = action.payload.title;
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// updateArticle
|
||||
builder.addCase(updateArticle.pending, (state) => {
|
||||
state.updateArticle.status = 'loading';
|
||||
state.updateArticle.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
updateArticle.fulfilled,
|
||||
(state, action: PayloadAction<Article>) => {
|
||||
state.updateArticle.status = 'successful';
|
||||
state.updateArticle.article = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(updateArticle.rejected, (state, action: any) => {
|
||||
state.updateArticle.status = 'failed';
|
||||
state.createArticle.error = action.payload.title;
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// deleteArticle
|
||||
builder.addCase(deleteArticle.pending, (state) => {
|
||||
state.deleteArticle.status = 'loading';
|
||||
state.deleteArticle.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
deleteArticle.fulfilled,
|
||||
(state, action: PayloadAction<number>) => {
|
||||
state.deleteArticle.status = 'successful';
|
||||
state.fetchArticles.articles =
|
||||
state.fetchArticles.articles.filter(
|
||||
(a) => a.id !== action.payload,
|
||||
);
|
||||
state.fetchMyArticles.articles =
|
||||
state.fetchMyArticles.articles.filter(
|
||||
(a) => a.id !== action.payload,
|
||||
);
|
||||
},
|
||||
);
|
||||
builder.addCase(deleteArticle.rejected, (state, action: any) => {
|
||||
state.deleteArticle.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setArticlesStatus } = articlesSlice.actions;
|
||||
export const articlesReducer = articlesSlice.reducer;
|
||||
@@ -1,189 +1,330 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import axios from "../../axios";
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
|
||||
// Типы данных
|
||||
interface AuthState {
|
||||
jwt: string | null;
|
||||
refreshToken: string | null;
|
||||
username: string | null;
|
||||
status: "idle" | "loading" | "successful" | "failed";
|
||||
error: string | null;
|
||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||
|
||||
// 🔹 Декодирование JWT
|
||||
function decodeJwt(token: string) {
|
||||
const [, payload] = token.split('.');
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(decodeURIComponent(escape(json)));
|
||||
}
|
||||
|
||||
// Инициализация состояния
|
||||
// 🔹 Типы
|
||||
interface AuthState {
|
||||
jwt: string | null;
|
||||
refreshToken: string | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
id: string | null;
|
||||
status: Status;
|
||||
error: string | null;
|
||||
register: {
|
||||
errors?: Record<string, string[]>;
|
||||
status: Status;
|
||||
};
|
||||
}
|
||||
|
||||
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
|
||||
const jwtFromStorage = localStorage.getItem('jwt');
|
||||
const refreshTokenFromStorage = localStorage.getItem('refreshToken');
|
||||
|
||||
const initialState: AuthState = {
|
||||
jwt: null,
|
||||
refreshToken: null,
|
||||
username: null,
|
||||
status: "idle",
|
||||
error: null,
|
||||
jwt: jwtFromStorage || null,
|
||||
refreshToken: refreshTokenFromStorage || null,
|
||||
username: null,
|
||||
email: null,
|
||||
id: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
register: {
|
||||
status: 'idle',
|
||||
},
|
||||
};
|
||||
|
||||
// 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(
|
||||
"auth/register",
|
||||
async (
|
||||
{ username, email, password }: { username: string; email: string; password: string },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.post("/authentication/register", { username, email, password });
|
||||
return response.data; // { jwt, refreshToken }
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Registration failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// AsyncThunk: Логин
|
||||
export const loginUser = createAsyncThunk(
|
||||
"auth/login",
|
||||
async (
|
||||
{ username, password }: { username: string; password: string },
|
||||
{ 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'];
|
||||
'auth/register',
|
||||
async (
|
||||
{
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
}: { username: string; email: string; password: string },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.post('/authentication/register', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.errors ? err.response?.data?.errors : {"error" : [err.response?.data]});
|
||||
}
|
||||
},
|
||||
},
|
||||
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;
|
||||
});
|
||||
);
|
||||
|
||||
// Логин
|
||||
builder.addCase(loginUser.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(loginUser.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(loginUser.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
export const loginUser = createAsyncThunk(
|
||||
'auth/login',
|
||||
async (
|
||||
{ username, password }: { username: string; password: string },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.post('/authentication/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Login failed',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Обновление токена
|
||||
builder.addCase(refreshToken.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => {
|
||||
state.status = "successful";
|
||||
state.username = action.payload.username;
|
||||
});
|
||||
builder.addCase(refreshToken.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
export const refreshToken = createAsyncThunk(
|
||||
'auth/refresh',
|
||||
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('/authentication/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Refresh token failed',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Получение информации о пользователе
|
||||
builder.addCase(fetchWhoAmI.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => {
|
||||
state.status = "successful";
|
||||
state.username = action.payload.username;
|
||||
});
|
||||
builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
export const fetchWhoAmI = createAsyncThunk(
|
||||
'auth/whoami',
|
||||
async (_, { dispatch, getState, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get('/authentication/whoami');
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const state: any = getState();
|
||||
const refresh = state.auth.refreshToken;
|
||||
|
||||
// Загрузка токенов из localStorage
|
||||
builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => {
|
||||
state.jwt = action.payload.jwt;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
if (action.payload.jwt) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
if (refresh) {
|
||||
// пробуем refresh
|
||||
const result = await dispatch(
|
||||
refreshToken({ refreshToken: refresh }),
|
||||
);
|
||||
|
||||
// если успешный, повторить whoami
|
||||
if (refreshToken.fulfilled.match(result)) {
|
||||
const retry = await axios.get('/authentication/whoami');
|
||||
return retry.data;
|
||||
}
|
||||
}
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Failed to fetch user info',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 🔹 Slice
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
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'];
|
||||
},
|
||||
setAuthStatus: (
|
||||
state,
|
||||
action: PayloadAction<{ key: keyof AuthState; status: Status }>,
|
||||
) => {
|
||||
const { key, status } = action.payload;
|
||||
if (state[key]) {
|
||||
(state[key] as any).status = status;
|
||||
}
|
||||
},
|
||||
},
|
||||
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.register.errors = action.payload as Record<string, string[]>;
|
||||
Object.values(state.register.errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------- 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;
|
||||
|
||||
// Если пользователь не авторизован (401), делаем logout и пытаемся refresh
|
||||
if (
|
||||
action.payload === 'Unauthorized' ||
|
||||
action.payload === 'Failed to fetch user info'
|
||||
) {
|
||||
// Вызов logout
|
||||
state.jwt = null;
|
||||
state.refreshToken = null;
|
||||
state.username = null;
|
||||
state.email = null;
|
||||
state.id = null;
|
||||
localStorage.removeItem('jwt');
|
||||
localStorage.removeItem('refreshToken');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { logout } = authSlice.actions;
|
||||
export const { logout, setAuthStatus } = authSlice.actions;
|
||||
export const authReducer = authSlice.reducer;
|
||||
|
||||
1036
src/redux/slices/contests.ts
Normal file
203
src/redux/slices/groupChat.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
|
||||
// =========================================
|
||||
// Типы
|
||||
// =========================================
|
||||
|
||||
export type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
groupId: number;
|
||||
authorId: number;
|
||||
authorUsername: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface FetchMessagesParams {
|
||||
groupId: number;
|
||||
limit?: number;
|
||||
afterMessageId?: number;
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
interface SendMessageParams {
|
||||
groupId: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// State
|
||||
// =========================================
|
||||
|
||||
interface GroupChatState {
|
||||
messages: Record<number, ChatMessage[]>; // по группам
|
||||
lastMessage: Record<number, number>;
|
||||
|
||||
fetchMessages: {
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
sendMessage: {
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: GroupChatState = {
|
||||
messages: {},
|
||||
lastMessage: {},
|
||||
fetchMessages: {
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
sendMessage: {
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Thunks
|
||||
// =========================================
|
||||
|
||||
// Получение сообщений
|
||||
export const fetchGroupMessages = createAsyncThunk(
|
||||
'groupChat/fetchGroupMessages',
|
||||
async (params: FetchMessagesParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/groups/${params.groupId}/chat`, {
|
||||
params: {
|
||||
limit: params.limit,
|
||||
afterMessageId: params.afterMessageId,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
groupId: params.groupId,
|
||||
messages: response.data as ChatMessage[],
|
||||
};
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Отправка
|
||||
export const sendGroupMessage = createAsyncThunk(
|
||||
'groupChat/sendGroupMessage',
|
||||
async ({ groupId, content }: SendMessageParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post(`/groups/${groupId}/chat`, {
|
||||
content,
|
||||
});
|
||||
return response.data as ChatMessage;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =========================================
|
||||
// Slice
|
||||
// =========================================
|
||||
|
||||
const groupChatSlice = createSlice({
|
||||
name: 'groupChat',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearChat(state, action: PayloadAction<number>) {
|
||||
delete state.messages[action.payload];
|
||||
},
|
||||
setGroupChatStatus: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
key: keyof GroupChatState;
|
||||
status: Status;
|
||||
}>,
|
||||
) => {
|
||||
const { key, status } = action.payload;
|
||||
if (state[key]) {
|
||||
(state[key] as any).status = status;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// fetch messages
|
||||
builder.addCase(fetchGroupMessages.pending, (state) => {
|
||||
state.fetchMessages.status = 'loading';
|
||||
});
|
||||
|
||||
builder.addCase(
|
||||
fetchGroupMessages.fulfilled,
|
||||
(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupId: number;
|
||||
messages: ChatMessage[];
|
||||
}>,
|
||||
) => {
|
||||
const { groupId, messages } = action.payload;
|
||||
const existing = state.messages[groupId] || [];
|
||||
|
||||
const ids = new Set(existing.map((m) => m.id));
|
||||
const filtered = messages.filter((m) => !ids.has(m.id));
|
||||
|
||||
state.messages[groupId] = [...existing, ...filtered].sort(
|
||||
(a, b) => a.id - b.id,
|
||||
);
|
||||
if (state.messages[groupId].length) {
|
||||
state.lastMessage[groupId] =
|
||||
state.messages[groupId][
|
||||
state.messages[groupId].length - 1
|
||||
].id;
|
||||
}
|
||||
|
||||
state.fetchMessages.status = 'successful';
|
||||
},
|
||||
);
|
||||
|
||||
builder.addCase(fetchGroupMessages.rejected, (state, action: any) => {
|
||||
state.fetchMessages.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// send message
|
||||
builder.addCase(sendGroupMessage.pending, (state) => {
|
||||
state.sendMessage.status = 'loading';
|
||||
});
|
||||
|
||||
builder.addCase(
|
||||
sendGroupMessage.fulfilled,
|
||||
(state, action: PayloadAction<ChatMessage>) => {
|
||||
const msg = action.payload;
|
||||
if (!state.messages[msg.groupId])
|
||||
state.messages[msg.groupId] = [];
|
||||
state.messages[msg.groupId].push(msg);
|
||||
state.sendMessage.status = 'successful';
|
||||
},
|
||||
);
|
||||
|
||||
builder.addCase(sendGroupMessage.rejected, (state, action: any) => {
|
||||
state.sendMessage.status = 'failed';
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearChat, setGroupChatStatus } = groupChatSlice.actions;
|
||||
export const groupChatReducer = groupChatSlice.reducer;
|
||||
368
src/redux/slices/groupfeed.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
|
||||
// =====================
|
||||
// Типы
|
||||
// =====================
|
||||
|
||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||
|
||||
export interface Post {
|
||||
id: number;
|
||||
groupId: number;
|
||||
authorId: number;
|
||||
authorUsername: string;
|
||||
name: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PostsPage {
|
||||
items: Post[];
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Состояние
|
||||
// =====================
|
||||
|
||||
interface PostsState {
|
||||
fetchPosts: {
|
||||
pages: Record<number, PostsPage>; // страница => данные
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
fetchPostById: {
|
||||
post?: Post;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
createPost: {
|
||||
post?: Post;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
updatePost: {
|
||||
post?: Post;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
deletePost: {
|
||||
deletedId?: number;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: PostsState = {
|
||||
fetchPosts: {
|
||||
pages: {},
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
fetchPostById: {
|
||||
post: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
createPost: {
|
||||
post: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
updatePost: {
|
||||
post: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
deletePost: {
|
||||
deletedId: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// =====================
|
||||
// Async Thunks
|
||||
// =====================
|
||||
|
||||
// Получить посты группы (пагинация)
|
||||
export const fetchGroupPosts = createAsyncThunk(
|
||||
'posts/fetchGroupPosts',
|
||||
async (
|
||||
{
|
||||
groupId,
|
||||
page = 0,
|
||||
pageSize = 100,
|
||||
}: { groupId: number; page?: number; pageSize?: number },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/groups/${groupId}/feed?page=${page}&pageSize=${pageSize}`,
|
||||
);
|
||||
return { page, data: response.data as PostsPage };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Получить один пост
|
||||
export const fetchPostById = createAsyncThunk(
|
||||
'posts/fetchPostById',
|
||||
async (
|
||||
{ groupId, postId }: { groupId: number; postId: number },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/groups/${groupId}/feed/${postId}`,
|
||||
);
|
||||
return response.data as Post;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Создать пост
|
||||
export const createPost = createAsyncThunk(
|
||||
'posts/createPost',
|
||||
async (
|
||||
{
|
||||
groupId,
|
||||
name,
|
||||
content,
|
||||
}: { groupId: number; name: string; content: string },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.post(`/groups/${groupId}/feed`, {
|
||||
name,
|
||||
content,
|
||||
});
|
||||
return response.data as Post;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Обновить пост
|
||||
export const updatePost = createAsyncThunk(
|
||||
'posts/updatePost',
|
||||
async (
|
||||
{
|
||||
groupId,
|
||||
postId,
|
||||
name,
|
||||
content,
|
||||
}: {
|
||||
groupId: number;
|
||||
postId: number;
|
||||
name: string;
|
||||
content: string;
|
||||
},
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`/groups/${groupId}/feed/${postId}`,
|
||||
{
|
||||
name,
|
||||
content,
|
||||
},
|
||||
);
|
||||
return response.data as Post;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Удалить пост
|
||||
export const deletePost = createAsyncThunk(
|
||||
'posts/deletePost',
|
||||
async (
|
||||
{ groupId, postId }: { groupId: number; postId: number },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
await axios.delete(`/groups/${groupId}/feed/${postId}`);
|
||||
return postId;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =====================
|
||||
// Slice
|
||||
// =====================
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState,
|
||||
reducers: {
|
||||
setGroupFeedStatus: (
|
||||
state,
|
||||
action: PayloadAction<{ key: keyof PostsState; status: Status }>,
|
||||
) => {
|
||||
const { key, status } = action.payload;
|
||||
if (state[key]) {
|
||||
(state[key] as any).status = status;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// fetchGroupPosts
|
||||
builder.addCase(fetchGroupPosts.pending, (state) => {
|
||||
state.fetchPosts.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
fetchGroupPosts.fulfilled,
|
||||
(
|
||||
state,
|
||||
action: PayloadAction<{ page: number; data: PostsPage }>,
|
||||
) => {
|
||||
const { page, data } = action.payload;
|
||||
state.fetchPosts.status = 'successful';
|
||||
state.fetchPosts.pages[page] = data;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchGroupPosts.rejected, (state, action: any) => {
|
||||
state.fetchPosts.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// fetchPostById
|
||||
builder.addCase(fetchPostById.pending, (state) => {
|
||||
state.fetchPostById.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
fetchPostById.fulfilled,
|
||||
(state, action: PayloadAction<Post>) => {
|
||||
state.fetchPostById.status = 'successful';
|
||||
state.fetchPostById.post = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchPostById.rejected, (state, action: any) => {
|
||||
state.fetchPostById.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// createPost
|
||||
builder.addCase(createPost.pending, (state) => {
|
||||
state.createPost.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
createPost.fulfilled,
|
||||
(state, action: PayloadAction<Post>) => {
|
||||
state.createPost.status = 'successful';
|
||||
state.createPost.post = action.payload;
|
||||
|
||||
// добавляем сразу в первую страницу (page = 0)
|
||||
if (state.fetchPosts.pages[0]) {
|
||||
state.fetchPosts.pages[0].items.unshift(action.payload);
|
||||
}
|
||||
},
|
||||
);
|
||||
builder.addCase(createPost.rejected, (state, action: any) => {
|
||||
state.createPost.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// updatePost
|
||||
builder.addCase(updatePost.pending, (state) => {
|
||||
state.updatePost.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
updatePost.fulfilled,
|
||||
(state, action: PayloadAction<Post>) => {
|
||||
state.updatePost.status = 'successful';
|
||||
state.updatePost.post = action.payload;
|
||||
|
||||
// обновим в списках
|
||||
for (const page of Object.values(state.fetchPosts.pages)) {
|
||||
const index = page.items.findIndex(
|
||||
(p) => p.id === action.payload.id,
|
||||
);
|
||||
if (index !== -1) page.items[index] = action.payload;
|
||||
}
|
||||
|
||||
// обновим если открыт одиночный пост
|
||||
if (state.fetchPostById.post?.id === action.payload.id) {
|
||||
state.fetchPostById.post = action.payload;
|
||||
}
|
||||
},
|
||||
);
|
||||
builder.addCase(updatePost.rejected, (state, action: any) => {
|
||||
state.updatePost.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// deletePost
|
||||
builder.addCase(deletePost.pending, (state) => {
|
||||
state.deletePost.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
deletePost.fulfilled,
|
||||
(state, action: PayloadAction<number>) => {
|
||||
state.deletePost.status = 'successful';
|
||||
state.deletePost.deletedId = action.payload;
|
||||
|
||||
// удалить из всех страниц
|
||||
for (const page of Object.values(state.fetchPosts.pages)) {
|
||||
page.items = page.items.filter(
|
||||
(p) => p.id !== action.payload,
|
||||
);
|
||||
}
|
||||
|
||||
// если открыт индивидуальный пост
|
||||
if (state.fetchPostById.post?.id === action.payload) {
|
||||
state.fetchPostById.post = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
builder.addCase(deletePost.rejected, (state, action: any) => {
|
||||
state.deletePost.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setGroupFeedStatus } = postsSlice.actions;
|
||||
export const groupFeedReducer = postsSlice.reducer;
|
||||
509
src/redux/slices/groups.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
|
||||
// =====================
|
||||
// Типы
|
||||
// =====================
|
||||
|
||||
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 {
|
||||
fetchMyGroups: {
|
||||
groups: Group[];
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
fetchGroupById: {
|
||||
group?: Group;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
createGroup: {
|
||||
group?: Group;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
updateGroup: {
|
||||
group?: Group;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
deleteGroup: {
|
||||
deletedId?: number;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
addGroupMember: {
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
removeGroupMember: {
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
fetchGroupJoinLink: {
|
||||
joinLink?: { token: string; expiresAt: string };
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
joinGroupByToken: {
|
||||
group?: Group;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: GroupsState = {
|
||||
fetchMyGroups: {
|
||||
groups: [],
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
fetchGroupById: {
|
||||
group: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
createGroup: {
|
||||
group: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
updateGroup: {
|
||||
group: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
deleteGroup: {
|
||||
deletedId: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
addGroupMember: {
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
removeGroupMember: {
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
fetchGroupJoinLink: {
|
||||
joinLink: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
joinGroupByToken: {
|
||||
group: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// =====================
|
||||
// Async Thunks
|
||||
// =====================
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const addGroupMember = createAsyncThunk(
|
||||
'groups/addGroupMember',
|
||||
async (
|
||||
{
|
||||
groupId,
|
||||
userId,
|
||||
role,
|
||||
}: { groupId: number; userId: number; role: string },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const response = await axios.post(`/groups/${groupId}/members`, {
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =====================
|
||||
// Новые Async Thunks
|
||||
// =====================
|
||||
|
||||
// Получение актуальной ссылки для присоединения к группе
|
||||
export const fetchGroupJoinLink = createAsyncThunk(
|
||||
'groups/fetchGroupJoinLink',
|
||||
async (groupId: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/groups/${groupId}/join-link`);
|
||||
return response.data as { token: string; expiresAt: string };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Присоединение к группе по токену приглашения
|
||||
export const joinGroupByToken = createAsyncThunk(
|
||||
'groups/joinGroupByToken',
|
||||
async (token: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post(`/groups/join/${token}`);
|
||||
return response.data as Group;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =====================
|
||||
// Slice
|
||||
// =====================
|
||||
|
||||
const groupsSlice = createSlice({
|
||||
name: 'groups',
|
||||
initialState,
|
||||
reducers: {
|
||||
setGroupsStatus: (
|
||||
state,
|
||||
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
|
||||
) => {
|
||||
const { key, status } = action.payload;
|
||||
if (state[key]) {
|
||||
(state[key] as any).status = status;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// fetchMyGroups
|
||||
builder.addCase(fetchMyGroups.pending, (state) => {
|
||||
state.fetchMyGroups.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
fetchMyGroups.fulfilled,
|
||||
(state, action: PayloadAction<Group[]>) => {
|
||||
state.fetchMyGroups.status = 'successful';
|
||||
state.fetchMyGroups.groups = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
|
||||
state.fetchMyGroups.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// fetchGroupById
|
||||
builder.addCase(fetchGroupById.pending, (state) => {
|
||||
state.fetchGroupById.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
fetchGroupById.fulfilled,
|
||||
(state, action: PayloadAction<Group>) => {
|
||||
state.fetchGroupById.status = 'successful';
|
||||
state.fetchGroupById.group = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
|
||||
state.fetchGroupById.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// createGroup
|
||||
builder.addCase(createGroup.pending, (state) => {
|
||||
state.createGroup.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
createGroup.fulfilled,
|
||||
(state, action: PayloadAction<Group>) => {
|
||||
state.createGroup.status = 'successful';
|
||||
state.createGroup.group = action.payload;
|
||||
state.fetchMyGroups.groups.push(action.payload);
|
||||
},
|
||||
);
|
||||
builder.addCase(createGroup.rejected, (state, action: any) => {
|
||||
state.createGroup.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// updateGroup
|
||||
builder.addCase(updateGroup.pending, (state) => {
|
||||
state.updateGroup.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
updateGroup.fulfilled,
|
||||
(state, action: PayloadAction<Group>) => {
|
||||
state.updateGroup.status = 'successful';
|
||||
state.updateGroup.group = action.payload;
|
||||
const index = state.fetchMyGroups.groups.findIndex(
|
||||
(g) => g.id === action.payload.id,
|
||||
);
|
||||
if (index !== -1)
|
||||
state.fetchMyGroups.groups[index] = action.payload;
|
||||
if (state.fetchGroupById.group?.id === action.payload.id)
|
||||
state.fetchGroupById.group = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(updateGroup.rejected, (state, action: any) => {
|
||||
state.updateGroup.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// deleteGroup
|
||||
builder.addCase(deleteGroup.pending, (state) => {
|
||||
state.deleteGroup.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
deleteGroup.fulfilled,
|
||||
(state, action: PayloadAction<number>) => {
|
||||
state.deleteGroup.status = 'successful';
|
||||
state.deleteGroup.deletedId = action.payload;
|
||||
state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter(
|
||||
(g) => g.id !== action.payload,
|
||||
);
|
||||
if (state.fetchGroupById.group?.id === action.payload)
|
||||
state.fetchGroupById.group = undefined;
|
||||
},
|
||||
);
|
||||
builder.addCase(deleteGroup.rejected, (state, action: any) => {
|
||||
state.deleteGroup.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// addGroupMember
|
||||
builder.addCase(addGroupMember.pending, (state) => {
|
||||
state.addGroupMember.status = 'loading';
|
||||
});
|
||||
builder.addCase(addGroupMember.fulfilled, (state) => {
|
||||
state.addGroupMember.status = 'successful';
|
||||
});
|
||||
builder.addCase(addGroupMember.rejected, (state, action: any) => {
|
||||
state.addGroupMember.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// removeGroupMember
|
||||
builder.addCase(removeGroupMember.pending, (state) => {
|
||||
state.removeGroupMember.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
removeGroupMember.fulfilled,
|
||||
(
|
||||
state,
|
||||
action: PayloadAction<{ groupId: number; memberId: number }>,
|
||||
) => {
|
||||
state.removeGroupMember.status = 'successful';
|
||||
if (
|
||||
state.fetchGroupById.group &&
|
||||
state.fetchGroupById.group.id === action.payload.groupId
|
||||
) {
|
||||
state.fetchGroupById.group.members =
|
||||
state.fetchGroupById.group.members.filter(
|
||||
(m) => m.userId !== action.payload.memberId,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
|
||||
state.removeGroupMember.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// fetchGroupJoinLink
|
||||
builder.addCase(fetchGroupJoinLink.pending, (state) => {
|
||||
state.fetchGroupJoinLink.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
fetchGroupJoinLink.fulfilled,
|
||||
(
|
||||
state,
|
||||
action: PayloadAction<{ token: string; expiresAt: string }>,
|
||||
) => {
|
||||
state.fetchGroupJoinLink.status = 'successful';
|
||||
state.fetchGroupJoinLink.joinLink = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
|
||||
state.fetchGroupJoinLink.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// joinGroupByToken
|
||||
builder.addCase(joinGroupByToken.pending, (state) => {
|
||||
state.joinGroupByToken.status = 'loading';
|
||||
});
|
||||
builder.addCase(
|
||||
joinGroupByToken.fulfilled,
|
||||
(state, action: PayloadAction<Group>) => {
|
||||
state.joinGroupByToken.status = 'successful';
|
||||
state.joinGroupByToken.group = action.payload;
|
||||
state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список
|
||||
},
|
||||
);
|
||||
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
|
||||
state.joinGroupByToken.status = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<string, string[]>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setGroupsStatus } = groupsSlice.actions;
|
||||
export const groupsReducer = groupsSlice.reducer;
|
||||
@@ -1,146 +1,406 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import axios from "../../axios";
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
import { toastError } from '../../lib/toastNotification';
|
||||
|
||||
// ─── Типы ────────────────────────────────────────────
|
||||
|
||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||
|
||||
// Типы данных
|
||||
interface Statement {
|
||||
id: number;
|
||||
language: string;
|
||||
statementTexts: Record<string, string>;
|
||||
mediaFiles?: { id: number; fileName: string; mediaUrl: string }[];
|
||||
id: number;
|
||||
language: string;
|
||||
statementTexts: Record<string, string>;
|
||||
mediaFiles?: { id: number; fileName: string; mediaUrl: string }[];
|
||||
}
|
||||
|
||||
interface Mission {
|
||||
id: number;
|
||||
authorId: number;
|
||||
name: string;
|
||||
difficulty: number;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
statements?: Statement[];
|
||||
export interface Mission {
|
||||
id: number;
|
||||
authorId: number;
|
||||
name: string;
|
||||
difficulty: number;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
timeLimit: number;
|
||||
memoryLimit: number;
|
||||
statements?: Statement[];
|
||||
}
|
||||
|
||||
interface MissionsState {
|
||||
missions: Mission[];
|
||||
currentMission: Mission | null;
|
||||
hasNextPage: boolean;
|
||||
status: "idle" | "loading" | "successful" | "failed";
|
||||
error: string | null;
|
||||
missions: Mission[];
|
||||
newMissions: Mission[];
|
||||
currentMission: Mission | null;
|
||||
hasNextPage: boolean;
|
||||
create: {
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
statuses: {
|
||||
fetchList: Status;
|
||||
fetchById: Status;
|
||||
upload: Status;
|
||||
fetchMy: Status;
|
||||
delete: Status;
|
||||
};
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Инициализация состояния
|
||||
// ─── Инициализация состояния ──────────────────────────
|
||||
|
||||
const initialState: MissionsState = {
|
||||
missions: [],
|
||||
currentMission: null,
|
||||
hasNextPage: false,
|
||||
status: "idle",
|
||||
error: null,
|
||||
missions: [],
|
||||
newMissions: [],
|
||||
currentMission: null,
|
||||
hasNextPage: false,
|
||||
create: {},
|
||||
statuses: {
|
||||
fetchList: 'idle',
|
||||
fetchById: 'idle',
|
||||
upload: 'idle',
|
||||
fetchMy: 'idle',
|
||||
delete: 'idle',
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
// AsyncThunk: Получение списка миссий
|
||||
// ─── Async Thunks ─────────────────────────────────────
|
||||
|
||||
// GET /missions
|
||||
export const fetchMissions = createAsyncThunk(
|
||||
"missions/fetchMissions",
|
||||
async (
|
||||
{ page = 0, pageSize = 10, tags = [] }: { page?: number; pageSize?: number; tags?: string[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const params: any = { page, pageSize };
|
||||
if (tags) params.tags = tags;
|
||||
const response = await axios.get("/missions", { params });
|
||||
return response.data; // { hasNextPage, missions }
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch missions");
|
||||
}
|
||||
}
|
||||
'missions/fetchMissions',
|
||||
async (
|
||||
{
|
||||
page = 0,
|
||||
pageSize = 100,
|
||||
tags = [],
|
||||
}: { page?: number; pageSize?: number; tags?: string[] },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const params: any = { page, pageSize };
|
||||
if (tags.length) params.tags = tags;
|
||||
const response = await axios.get('/missions', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
});
|
||||
return response.data; // { missions, hasNextPage }
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// AsyncThunk: Получение миссии по id
|
||||
// GET /missions
|
||||
export const fetchNewMissions = createAsyncThunk(
|
||||
'missions/fetchNewMissions',
|
||||
async (
|
||||
{
|
||||
page = 0,
|
||||
pageSize = 10,
|
||||
tags = [],
|
||||
}: { page?: number; pageSize?: number; tags?: string[] },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const params: any = { page, pageSize };
|
||||
if (tags.length) params.tags = tags;
|
||||
const response = await axios.get('/missions', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
});
|
||||
return response.data; // { missions, hasNextPage }
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /missions/{id}
|
||||
export const fetchMissionById = createAsyncThunk(
|
||||
"missions/fetchMissionById",
|
||||
async (id: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/missions/${id}`);
|
||||
return response.data; // Mission
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission");
|
||||
}
|
||||
}
|
||||
'missions/fetchMissionById',
|
||||
async (id: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/missions/${id}`);
|
||||
return response.data; // Mission
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// AsyncThunk: Загрузка миссии
|
||||
// ✅ GET /missions/my
|
||||
export const fetchMyMissions = createAsyncThunk(
|
||||
'missions/fetchMyMissions',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get('/missions/my');
|
||||
return response.data as Mission[]; // массив миссий пользователя
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /missions/upload
|
||||
export const uploadMission = createAsyncThunk(
|
||||
"missions/uploadMission",
|
||||
async (
|
||||
{ file, name, difficulty, tags }: { file: File; name: string; difficulty: number; tags: string[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("MissionFile", file);
|
||||
formData.append("Name", name);
|
||||
formData.append("Difficulty", difficulty.toString());
|
||||
tags.forEach(tag => formData.append("Tags", tag));
|
||||
'missions/uploadMission',
|
||||
async (
|
||||
{
|
||||
file,
|
||||
name,
|
||||
difficulty,
|
||||
tags,
|
||||
}: { file: File; name: string; difficulty: number; tags: string[] },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('MissionFile', file);
|
||||
formData.append('Name', name);
|
||||
formData.append('Difficulty', difficulty.toString());
|
||||
tags.forEach((tag) => formData.append('Tags', tag));
|
||||
|
||||
const response = await axios.post("/missions/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return response.data; // Mission
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Failed to upload mission");
|
||||
}
|
||||
}
|
||||
const response = await axios.post('/missions/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data; // Mission
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Slice
|
||||
// DELETE /missions/{id}
|
||||
export const deleteMission = createAsyncThunk(
|
||||
'missions/deleteMission',
|
||||
async (id: number, { rejectWithValue }) => {
|
||||
try {
|
||||
await axios.delete(`/missions/${id}`);
|
||||
return id; // возвращаем id удалённой миссии
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Slice ────────────────────────────────────────────
|
||||
|
||||
const missionsSlice = createSlice({
|
||||
name: "missions",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
// fetchMissions
|
||||
builder.addCase(fetchMissions.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchMissions.fulfilled, (state, action: PayloadAction<{ missions: Mission[]; hasNextPage: boolean }>) => {
|
||||
state.status = "successful";
|
||||
state.missions = action.payload.missions;
|
||||
state.hasNextPage = action.payload.hasNextPage;
|
||||
});
|
||||
builder.addCase(fetchMissions.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
name: 'missions',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMissionsStatus: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
key: keyof MissionsState['statuses'];
|
||||
status: Status;
|
||||
}>,
|
||||
) => {
|
||||
const { key, status } = action.payload;
|
||||
state.statuses[key] = status;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// ─── FETCH MISSIONS ───
|
||||
builder.addCase(fetchMissions.pending, (state) => {
|
||||
state.statuses.fetchList = 'loading';
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchMissions.fulfilled,
|
||||
(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
missions: Mission[];
|
||||
hasNextPage: boolean;
|
||||
}>,
|
||||
) => {
|
||||
state.statuses.fetchList = 'successful';
|
||||
state.missions = action.payload.missions;
|
||||
state.hasNextPage = action.payload.hasNextPage;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchMissions.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
state.statuses.fetchList = 'failed';
|
||||
|
||||
// fetchMissionById
|
||||
builder.addCase(fetchMissionById.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchMissionById.fulfilled, (state, action: PayloadAction<Mission>) => {
|
||||
state.status = "successful";
|
||||
state.currentMission = action.payload;
|
||||
});
|
||||
builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
const errors = action.payload.errors as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// uploadMission
|
||||
builder.addCase(uploadMission.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(uploadMission.fulfilled, (state, action: PayloadAction<Mission>) => {
|
||||
state.status = "successful";
|
||||
state.missions.unshift(action.payload); // Добавляем новую миссию в начало списка
|
||||
});
|
||||
builder.addCase(uploadMission.rejected, (state, action: PayloadAction<any>) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload;
|
||||
});
|
||||
},
|
||||
// ─── FETCH NEW MISSIONS ───
|
||||
builder.addCase(fetchNewMissions.pending, (state) => {
|
||||
state.statuses.fetchList = 'loading';
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchNewMissions.fulfilled,
|
||||
(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
missions: Mission[];
|
||||
hasNextPage: boolean;
|
||||
}>,
|
||||
) => {
|
||||
state.statuses.fetchList = 'successful';
|
||||
state.newMissions = action.payload.missions;
|
||||
state.hasNextPage = action.payload.hasNextPage;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchNewMissions.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
state.statuses.fetchList = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ─── FETCH MISSION BY ID ───
|
||||
builder.addCase(fetchMissionById.pending, (state) => {
|
||||
state.statuses.fetchById = 'loading';
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchMissionById.fulfilled,
|
||||
(state, action: PayloadAction<Mission>) => {
|
||||
state.statuses.fetchById = 'successful';
|
||||
state.currentMission = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchMissionById.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
state.statuses.fetchById = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ✅ FETCH MY MISSIONS ───
|
||||
builder.addCase(fetchMyMissions.pending, (state) => {
|
||||
state.statuses.fetchMy = 'loading';
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchMyMissions.fulfilled,
|
||||
(state, action: PayloadAction<Mission[]>) => {
|
||||
state.statuses.fetchMy = 'successful';
|
||||
state.missions = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchMyMissions.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
state.statuses.fetchMy = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ─── UPLOAD MISSION ───
|
||||
builder.addCase(uploadMission.pending, (state) => {
|
||||
state.statuses.upload = 'loading';
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(
|
||||
uploadMission.fulfilled,
|
||||
(state, action: PayloadAction<Mission>) => {
|
||||
state.statuses.upload = 'successful';
|
||||
state.missions.unshift(action.payload);
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
uploadMission.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
state.statuses.upload = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
|
||||
state.create.errors = errors;
|
||||
},
|
||||
);
|
||||
|
||||
// ─── DELETE MISSION ───
|
||||
builder.addCase(deleteMission.pending, (state) => {
|
||||
state.statuses.delete = 'loading';
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(
|
||||
deleteMission.fulfilled,
|
||||
(state, action: PayloadAction<number>) => {
|
||||
state.statuses.delete = 'successful';
|
||||
state.missions = state.missions.filter(
|
||||
(m) => m.id !== action.payload,
|
||||
);
|
||||
|
||||
if (state.currentMission?.id === action.payload) {
|
||||
state.currentMission = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
deleteMission.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
state.statuses.delete = 'failed';
|
||||
|
||||
const errors = action.payload.errors as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
Object.values(errors).forEach((messages) => {
|
||||
messages.forEach((msg) => {
|
||||
toastError(msg);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const { setMissionsStatus } = missionsSlice.actions;
|
||||
export const missionsReducer = missionsSlice.reducer;
|
||||
|
||||
395
src/redux/slices/profile.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
|
||||
// =====================
|
||||
// Типы
|
||||
// =====================
|
||||
|
||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||
|
||||
// Основной профиль
|
||||
export interface ProfileIdentity {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileSolutions {
|
||||
totalSolved: number;
|
||||
solvedLast7Days: number;
|
||||
}
|
||||
|
||||
export interface ProfileContestsInfo {
|
||||
totalParticipations: number;
|
||||
participationsLast7Days: number;
|
||||
}
|
||||
|
||||
export interface ProfileCreationStats {
|
||||
missions: { total: number; last7Days: number };
|
||||
contests: { total: number; last7Days: number };
|
||||
articles: { total: number; last7Days: number };
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
identity: ProfileIdentity;
|
||||
solutions: ProfileSolutions;
|
||||
contests: ProfileContestsInfo;
|
||||
creation: ProfileCreationStats;
|
||||
}
|
||||
|
||||
// Missions
|
||||
export interface MissionsBucket {
|
||||
key: string;
|
||||
label: string;
|
||||
solved: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MissionItem {
|
||||
missionId: number;
|
||||
missionName: string;
|
||||
difficultyLabel: string;
|
||||
difficultyValue: number;
|
||||
createdAt: string;
|
||||
timeLimitMilliseconds: number;
|
||||
memoryLimitBytes: number;
|
||||
}
|
||||
|
||||
export interface MissionsResponse {
|
||||
summary: {
|
||||
total: MissionsBucket;
|
||||
buckets: MissionsBucket[];
|
||||
};
|
||||
recent: {
|
||||
items: MissionItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
authored: {
|
||||
items: MissionItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Articles
|
||||
export interface ProfileArticleItem {
|
||||
articleId: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface ProfileArticlesResponse {
|
||||
articles: {
|
||||
items: ProfileArticleItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Contests
|
||||
export interface ContestItem {
|
||||
contestId: number;
|
||||
name: string;
|
||||
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||
visibility: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
attemptDurationMinutes: number;
|
||||
role: 'None' | 'Participant' | 'Organizer';
|
||||
}
|
||||
|
||||
export interface ContestsList {
|
||||
items: ContestItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileContestsResponse {
|
||||
upcoming: ContestsList;
|
||||
past: ContestsList;
|
||||
mine: ContestsList;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Состояние
|
||||
// =====================
|
||||
|
||||
interface ProfileState {
|
||||
profile: {
|
||||
data?: ProfileResponse;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
missions: {
|
||||
data?: MissionsResponse;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
articles: {
|
||||
data?: ProfileArticlesResponse;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
contests: {
|
||||
data?: ProfileContestsResponse;
|
||||
status: Status;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: ProfileState = {
|
||||
profile: {
|
||||
data: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
missions: {
|
||||
data: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
articles: {
|
||||
data: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
contests: {
|
||||
data: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// =====================
|
||||
// Async Thunks
|
||||
// =====================
|
||||
|
||||
// Основной профиль
|
||||
export const fetchProfile = createAsyncThunk(
|
||||
'profile/fetch',
|
||||
async (username: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await axios.get<ProfileResponse>(
|
||||
`/profile/${username}`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Ошибка загрузки профиля',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Missions
|
||||
export const fetchProfileMissions = createAsyncThunk(
|
||||
'profile/fetchMissions',
|
||||
async (
|
||||
{
|
||||
username,
|
||||
recentPage = 0,
|
||||
recentPageSize = 100,
|
||||
authoredPage = 0,
|
||||
authoredPageSize = 100,
|
||||
}: {
|
||||
username: string;
|
||||
recentPage?: number;
|
||||
recentPageSize?: number;
|
||||
authoredPage?: number;
|
||||
authoredPageSize?: number;
|
||||
},
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const res = await axios.get<MissionsResponse>(
|
||||
`/profile/${username}/missions`,
|
||||
{
|
||||
params: {
|
||||
recentPage,
|
||||
recentPageSize,
|
||||
authoredPage,
|
||||
authoredPageSize,
|
||||
},
|
||||
},
|
||||
);
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Ошибка загрузки задач',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Articles
|
||||
export const fetchProfileArticles = createAsyncThunk(
|
||||
'profile/fetchArticles',
|
||||
async (
|
||||
{
|
||||
username,
|
||||
page = 0,
|
||||
pageSize = 100,
|
||||
}: { username: string; page?: number; pageSize?: number },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const res = await axios.get<ProfileArticlesResponse>(
|
||||
`/profile/${username}/articles`,
|
||||
{ params: { page, pageSize } },
|
||||
);
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Ошибка загрузки статей',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Contests
|
||||
export const fetchProfileContests = createAsyncThunk(
|
||||
'profile/fetchContests',
|
||||
async (
|
||||
{
|
||||
username,
|
||||
upcomingPage = 0,
|
||||
upcomingPageSize = 100,
|
||||
pastPage = 0,
|
||||
pastPageSize = 100,
|
||||
minePage = 0,
|
||||
minePageSize = 100,
|
||||
}: {
|
||||
username: string;
|
||||
upcomingPage?: number;
|
||||
upcomingPageSize?: number;
|
||||
pastPage?: number;
|
||||
pastPageSize?: number;
|
||||
minePage?: number;
|
||||
minePageSize?: number;
|
||||
},
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const res = await axios.get<ProfileContestsResponse>(
|
||||
`/profile/${username}/contests`,
|
||||
{
|
||||
params: {
|
||||
upcomingPage,
|
||||
upcomingPageSize,
|
||||
pastPage,
|
||||
pastPageSize,
|
||||
minePage,
|
||||
minePageSize,
|
||||
},
|
||||
},
|
||||
);
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Ошибка загрузки контестов',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =====================
|
||||
// Slice
|
||||
// =====================
|
||||
|
||||
const profileSlice = createSlice({
|
||||
name: 'profile',
|
||||
initialState,
|
||||
reducers: {
|
||||
setProfileStatus: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
key: keyof ProfileState;
|
||||
status: Status;
|
||||
}>,
|
||||
) => {
|
||||
state[action.payload.key].status = action.payload.status;
|
||||
},
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
// PROFILE
|
||||
builder.addCase(fetchProfile.pending, (state) => {
|
||||
state.profile.status = 'loading';
|
||||
state.profile.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchProfile.fulfilled,
|
||||
(state, action: PayloadAction<ProfileResponse>) => {
|
||||
state.profile.status = 'successful';
|
||||
state.profile.data = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchProfile.rejected, (state, action: any) => {
|
||||
state.profile.status = 'failed';
|
||||
state.profile.error = action.payload;
|
||||
});
|
||||
|
||||
// MISSIONS
|
||||
builder.addCase(fetchProfileMissions.pending, (state) => {
|
||||
state.missions.status = 'loading';
|
||||
state.missions.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchProfileMissions.fulfilled,
|
||||
(state, action: PayloadAction<MissionsResponse>) => {
|
||||
state.missions.status = 'successful';
|
||||
state.missions.data = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchProfileMissions.rejected, (state, action: any) => {
|
||||
state.missions.status = 'failed';
|
||||
state.missions.error = action.payload;
|
||||
});
|
||||
|
||||
// ARTICLES
|
||||
builder.addCase(fetchProfileArticles.pending, (state) => {
|
||||
state.articles.status = 'loading';
|
||||
state.articles.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchProfileArticles.fulfilled,
|
||||
(state, action: PayloadAction<ProfileArticlesResponse>) => {
|
||||
state.articles.status = 'successful';
|
||||
state.articles.data = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchProfileArticles.rejected, (state, action: any) => {
|
||||
state.articles.status = 'failed';
|
||||
state.articles.error = action.payload;
|
||||
});
|
||||
|
||||
// CONTESTS
|
||||
builder.addCase(fetchProfileContests.pending, (state) => {
|
||||
state.contests.status = 'loading';
|
||||
state.contests.error = undefined;
|
||||
});
|
||||
builder.addCase(
|
||||
fetchProfileContests.fulfilled,
|
||||
(state, action: PayloadAction<ProfileContestsResponse>) => {
|
||||
state.contests.status = 'successful';
|
||||
state.contests.data = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(fetchProfileContests.rejected, (state, action: any) => {
|
||||
state.contests.status = 'failed';
|
||||
state.contests.error = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setProfileStatus } = profileSlice.actions;
|
||||
export const profileReducer = profileSlice.reducer;
|
||||
@@ -1,30 +1,115 @@
|
||||
import { createSlice, PayloadAction} from "@reduxjs/toolkit";
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
// Типы данных
|
||||
interface StorState {
|
||||
menu: {
|
||||
activePage: string;
|
||||
}
|
||||
menu: {
|
||||
activePage: string;
|
||||
activeProfilePage: string;
|
||||
activeGroupPage: string;
|
||||
};
|
||||
group: {
|
||||
groupFilter: string;
|
||||
};
|
||||
articles: {
|
||||
articleTagFilter: string[];
|
||||
filterName: string;
|
||||
};
|
||||
contests: {
|
||||
contestsTagFilter: string[];
|
||||
filterName: string;
|
||||
};
|
||||
missions: {
|
||||
missionsTagFilter: string[];
|
||||
filterName: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Инициализация состояния
|
||||
const initialState: StorState = {
|
||||
menu: {
|
||||
activePage: "",
|
||||
}
|
||||
activePage: '',
|
||||
activeProfilePage: '',
|
||||
activeGroupPage: '',
|
||||
},
|
||||
group: {
|
||||
groupFilter: '',
|
||||
},
|
||||
articles: {
|
||||
articleTagFilter: [],
|
||||
filterName: '',
|
||||
},
|
||||
contests: {
|
||||
contestsTagFilter: [],
|
||||
filterName: '',
|
||||
},
|
||||
missions: {
|
||||
missionsTagFilter: [],
|
||||
filterName: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Slice
|
||||
const storeSlice = createSlice({
|
||||
name: "store",
|
||||
initialState,
|
||||
reducers: {
|
||||
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
|
||||
state.menu.activePage = activePage.payload;
|
||||
name: 'store',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMenuActivePage: (state, action: PayloadAction<string>) => {
|
||||
state.menu.activePage = action.payload;
|
||||
},
|
||||
setMenuActiveProfilePage: (state, action: PayloadAction<string>) => {
|
||||
state.menu.activeProfilePage = action.payload;
|
||||
},
|
||||
setMenuActiveGroupPage: (state, action: PayloadAction<string>) => {
|
||||
state.menu.activeGroupPage = action.payload;
|
||||
},
|
||||
setGroupFilter: (state, action: PayloadAction<string>) => {
|
||||
state.group.groupFilter = action.payload;
|
||||
},
|
||||
|
||||
// ---------- ARTICLES ----------
|
||||
setArticlesTagFilter: (state, action: PayloadAction<string[]>) => {
|
||||
state.articles.articleTagFilter = action.payload;
|
||||
},
|
||||
setArticlesNameFilter: (state, action: PayloadAction<string>) => {
|
||||
state.articles.filterName = action.payload;
|
||||
},
|
||||
|
||||
// ---------- CONTESTS ----------
|
||||
setContestsTagFilter: (state, action: PayloadAction<string[]>) => {
|
||||
state.contests.contestsTagFilter = action.payload;
|
||||
},
|
||||
setContestsNameFilter: (state, action: PayloadAction<string>) => {
|
||||
state.contests.filterName = action.payload;
|
||||
},
|
||||
|
||||
// ---------- MISSIONS ----------
|
||||
setMissionsTagFilter: (state, action: PayloadAction<string[]>) => {
|
||||
state.missions.missionsTagFilter = action.payload;
|
||||
},
|
||||
setMissionsNameFilter: (state, action: PayloadAction<string>) => {
|
||||
state.missions.filterName = action.payload;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setMenuActivePage } = storeSlice.actions;
|
||||
export const {
|
||||
// menu
|
||||
setMenuActivePage,
|
||||
setMenuActiveProfilePage,
|
||||
setMenuActiveGroupPage,
|
||||
setGroupFilter,
|
||||
|
||||
// articles
|
||||
setArticlesTagFilter,
|
||||
setArticlesNameFilter,
|
||||
|
||||
// contests
|
||||
setContestsTagFilter,
|
||||
setContestsNameFilter,
|
||||
|
||||
// missions
|
||||
setMissionsTagFilter,
|
||||
setMissionsNameFilter,
|
||||
} = storeSlice.actions;
|
||||
|
||||
export const storeReducer = storeSlice.reducer;
|
||||
|
||||
@@ -1,184 +1,224 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import axios from "../../axios";
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios from '../../axios';
|
||||
|
||||
// Типы данных
|
||||
export interface Submit {
|
||||
id?: number;
|
||||
missionId: number;
|
||||
language: string;
|
||||
languageVersion: string;
|
||||
sourceCode: string;
|
||||
contestId: number | null;
|
||||
id?: number;
|
||||
missionId: number;
|
||||
language: string;
|
||||
languageVersion: string;
|
||||
sourceCode: string;
|
||||
contestAttemptId?: number;
|
||||
}
|
||||
|
||||
export interface Solution {
|
||||
id: number;
|
||||
missionId: number;
|
||||
language: string;
|
||||
languageVersion: string;
|
||||
sourceCode: string;
|
||||
status: string;
|
||||
time: string;
|
||||
testerState: string;
|
||||
testerErrorCode: string;
|
||||
testerMessage: string;
|
||||
currentTest: number;
|
||||
amountOfTests: number;
|
||||
id: number;
|
||||
missionId: number;
|
||||
language: string;
|
||||
languageVersion: string;
|
||||
sourceCode: string;
|
||||
status: string;
|
||||
time: string;
|
||||
testerState: string;
|
||||
testerErrorCode: string;
|
||||
testerMessage: string;
|
||||
currentTest: number;
|
||||
amountOfTests: number;
|
||||
}
|
||||
|
||||
export interface MissionSubmit {
|
||||
id: number;
|
||||
userId: number;
|
||||
solution: Solution;
|
||||
contestId: number | null;
|
||||
contestName: string | null;
|
||||
sourceType: string;
|
||||
id: number;
|
||||
userId: number;
|
||||
solution: Solution;
|
||||
contestId?: number;
|
||||
contestName?: string;
|
||||
sourceType: string;
|
||||
}
|
||||
|
||||
interface SubmitState {
|
||||
submits: Submit[];
|
||||
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
|
||||
currentSubmit?: Submit;
|
||||
status: "idle" | "loading" | "successful" | "failed";
|
||||
error: string | null;
|
||||
submits: Submit[];
|
||||
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
|
||||
currentSubmit?: Submit;
|
||||
status: 'idle' | 'loading' | 'successful' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Начальное состояние
|
||||
const initialState: SubmitState = {
|
||||
submits: [],
|
||||
submitsById: {}, // ✅ инициализация
|
||||
currentSubmit: undefined,
|
||||
status: "idle",
|
||||
error: null,
|
||||
submits: [],
|
||||
submitsById: {}, // ✅ инициализация
|
||||
currentSubmit: undefined,
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
// AsyncThunk: Отправка решения
|
||||
export const submitMission = createAsyncThunk(
|
||||
"submit/submitMission",
|
||||
async (submitData: Submit, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post("/submits", submitData);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Submit failed");
|
||||
}
|
||||
}
|
||||
'submit/submitMission',
|
||||
async (submitData: Submit, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('/submits', submitData);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Submit failed',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// AsyncThunk: Получить все свои отправки
|
||||
export const fetchMySubmits = createAsyncThunk(
|
||||
"submit/fetchMySubmits",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get("/submits/my");
|
||||
return response.data as Submit[];
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch submits");
|
||||
}
|
||||
}
|
||||
'submit/fetchMySubmits',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get('/submits/my');
|
||||
return response.data as Submit[];
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Failed to fetch submits',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// AsyncThunk: Получить конкретную отправку по ID
|
||||
export const fetchSubmitById = createAsyncThunk(
|
||||
"submit/fetchSubmitById",
|
||||
async (id: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/submits/${id}`);
|
||||
return response.data as Submit;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch submit");
|
||||
}
|
||||
}
|
||||
'submit/fetchSubmitById',
|
||||
async (id: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/submits/${id}`);
|
||||
return response.data as Submit;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message || 'Failed to fetch submit',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура)
|
||||
export const fetchMySubmitsByMission = createAsyncThunk(
|
||||
"submit/fetchMySubmitsByMission",
|
||||
async (missionId: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(`/submits/my/mission/${missionId}`);
|
||||
return { missionId, data: response.data as MissionSubmit[] };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits");
|
||||
}
|
||||
}
|
||||
'submit/fetchMySubmitsByMission',
|
||||
async (missionId: number, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/submits/my/mission/${missionId}`,
|
||||
);
|
||||
return { missionId, data: response.data as MissionSubmit[] };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(
|
||||
err.response?.data?.message ||
|
||||
'Failed to fetch mission submits',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Slice
|
||||
const submitSlice = createSlice({
|
||||
name: "submit",
|
||||
initialState,
|
||||
reducers: {
|
||||
clearCurrentSubmit: (state) => {
|
||||
state.currentSubmit = undefined;
|
||||
state.status = "idle";
|
||||
state.error = null;
|
||||
name: 'submit',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearCurrentSubmit: (state) => {
|
||||
state.currentSubmit = undefined;
|
||||
state.status = 'idle';
|
||||
state.error = undefined;
|
||||
},
|
||||
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
|
||||
delete state.submitsById[action.payload];
|
||||
},
|
||||
},
|
||||
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
|
||||
delete state.submitsById[action.payload];
|
||||
extraReducers: (builder) => {
|
||||
// Отправка решения
|
||||
builder.addCase(submitMission.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
state.error = undefined;
|
||||
});
|
||||
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 = undefined;
|
||||
});
|
||||
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 = undefined;
|
||||
});
|
||||
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 = undefined;
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { authReducer } from "./slices/auth";
|
||||
import { storeReducer } from "./slices/store";
|
||||
import { missionsReducer } from "./slices/missions";
|
||||
import { submitReducer } from "./slices/submit";
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { authReducer } from './slices/auth';
|
||||
import { storeReducer } from './slices/store';
|
||||
import { missionsReducer } from './slices/missions';
|
||||
import { submitReducer } from './slices/submit';
|
||||
import { contestsReducer } from './slices/contests';
|
||||
import { groupsReducer } from './slices/groups';
|
||||
import { articlesReducer } from './slices/articles';
|
||||
import { groupFeedReducer } from './slices/groupfeed';
|
||||
import { groupChatReducer } from './slices/groupChat';
|
||||
import { profileReducer } from './slices/profile';
|
||||
|
||||
// использование
|
||||
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||
@@ -13,15 +18,20 @@ import { submitReducer } from "./slices/submit";
|
||||
// const dispatch = useAppDispatch();
|
||||
// const user = useAppSelector((state) => state.user);
|
||||
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
//user: userReducer,
|
||||
auth: authReducer,
|
||||
store: storeReducer,
|
||||
missions: missionsReducer,
|
||||
submin: submitReducer,
|
||||
},
|
||||
reducer: {
|
||||
//user: userReducer,
|
||||
auth: authReducer,
|
||||
store: storeReducer,
|
||||
missions: missionsReducer,
|
||||
submin: submitReducer,
|
||||
contests: contestsReducer,
|
||||
groups: groupsReducer,
|
||||
articles: articlesReducer,
|
||||
groupfeed: groupFeedReducer,
|
||||
groupchat: groupChatReducer,
|
||||
profile: profileReducer,
|
||||
},
|
||||
});
|
||||
|
||||
// тип состояния всего стора
|
||||
|
||||
@@ -2,116 +2,110 @@
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
@import "./latex-container.css";
|
||||
@import './latex-container.css';
|
||||
@import './toast.css';
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
|
||||
/* outline: 1px solid green; */
|
||||
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
|
||||
/* outline: 1px solid green; */
|
||||
}
|
||||
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
/* @apply bg-layout-background; */
|
||||
/* transition: all linear 200ms; */
|
||||
color-scheme: light dark;
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
/* @apply bg-layout-background; */
|
||||
/* 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-weight: 400;
|
||||
line-height: 1.5;
|
||||
background-color: var(--color-liquid-background);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
/* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
background-color: var(--color-liquid-background);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Общий контейнер полосы прокрутки */
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
width: 4px; /* ширина вертикального */
|
||||
width: 4px; /* ширина вертикального */
|
||||
}
|
||||
|
||||
/* Трек (фон) */
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Ползунок (thumb) */
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-liquid-light);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
background: var(--color-liquid-light);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Общий контейнер полосы прокрутки */
|
||||
.medium-scrollbar::-webkit-scrollbar {
|
||||
width: 8px; /* ширина вертикального */
|
||||
width: 8px; /* ширина вертикального */
|
||||
}
|
||||
|
||||
/* Трек (фон) */
|
||||
.medium-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Ползунок (thumb) */
|
||||
.medium-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-liquid-light);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
background: var(--color-liquid-light);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Общий контейнер полосы прокрутки */
|
||||
.thin-dark-scrollbar::-webkit-scrollbar {
|
||||
width: 4px; /* ширина вертикального */
|
||||
width: 4px; /* ширина вертикального */
|
||||
}
|
||||
|
||||
/* Трек (фон) */
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Ползунок (thumb) */
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-liquid-lighter);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
background: var(--color-liquid-lighter);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
padding-left: 8px;
|
||||
}
|
||||
html::-webkit-scrollbar {
|
||||
width: 8px; /* ширина вертикального */
|
||||
width: 8px; /* ширина вертикального */
|
||||
}
|
||||
/* Трек (фон) */
|
||||
html::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
/* Ползунок (thumb) */
|
||||
html::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-liquid-lighter);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-liquid-lighter);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
|
||||
.latex-container p {
|
||||
text-align: justify; /* выравнивание по ширине */
|
||||
text-justify: inter-word;
|
||||
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
|
||||
line-height: 1.2;
|
||||
/* text-indent: 1em; */
|
||||
text-align: justify; /* выравнивание по ширине */
|
||||
text-justify: inter-word;
|
||||
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
|
||||
line-height: 1.2;
|
||||
/* text-indent: 1em; */
|
||||
}
|
||||
|
||||
.latex-container ol {
|
||||
padding-left: 1.5em; /* отступ для нумерации */
|
||||
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
|
||||
line-height: 1.5; /* удобный межстрочный интервал */
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 1rem;
|
||||
padding-left: 1.5em; /* отступ для нумерации */
|
||||
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
|
||||
line-height: 1.5; /* удобный межстрочный интервал */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.latex-container ol li {
|
||||
margin-bottom: 0.4em; /* расстояние между пунктами */
|
||||
margin-bottom: 0.4em; /* расстояние между пунктами */
|
||||
}
|
||||
|
||||
.latex-container .section-title{
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
.latex-container .section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
@import 'tailwindcss/base';
|
||||
|
||||
@layer base {
|
||||
:root[data-theme~="dark"] {
|
||||
--color-liquid-brightmain: #00DBD9;
|
||||
--color-liquid-darkmain: #075867;
|
||||
--color-liquid-darker: #141515;
|
||||
--color-liquid-background: #202222;
|
||||
--color-liquid-lighter: #2A2E2F;
|
||||
--color-liquid-white: #EDF6F7;
|
||||
--color-liquid-red: #F13E5F;
|
||||
--color-liquid-green: #10BE59;
|
||||
--color-liquid-light: #576466;
|
||||
--color-liquid-orange: #FF951B;
|
||||
}
|
||||
:root[data-theme~='dark'] {
|
||||
--color-liquid-brightmain: #00dbd9;
|
||||
--color-liquid-darkmain: #075867;
|
||||
--color-liquid-darker: #141515;
|
||||
--color-liquid-background: #202222;
|
||||
--color-liquid-lighter: #2a2e2f;
|
||||
--color-liquid-white: #edf6f7;
|
||||
--color-liquid-red: #f13e5f;
|
||||
--color-liquid-green: #10be59;
|
||||
--color-liquid-light: #576466;
|
||||
--color-liquid-orange: #ff951b;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
@import 'tailwindcss/base';
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-liquid-brightmain: #00DBD9;
|
||||
--color-liquid-darkmain: #075867;
|
||||
--color-liquid-darker: #141515;
|
||||
--color-liquid-background: #202222;
|
||||
--color-liquid-lighter: #2A2E2F;
|
||||
--color-liquid-white: #EDF6F7;
|
||||
--color-liquid-red: #F13E5F;
|
||||
--color-liquid-green: #10BE59;
|
||||
--color-liquid-light: #576466;
|
||||
--color-liquid-orange: #FF951B;
|
||||
}
|
||||
:root {
|
||||
--color-liquid-brightmain: #00dbd9;
|
||||
--color-liquid-darkmain: #075867;
|
||||
--color-liquid-darker: #141515;
|
||||
--color-liquid-background: #202222;
|
||||
--color-liquid-lighter: #2a2e2f;
|
||||
--color-liquid-white: #edf6f7;
|
||||
--color-liquid-red: #f13e5f;
|
||||
--color-liquid-green: #10be59;
|
||||
--color-liquid-light: #576466;
|
||||
--color-liquid-orange: #ff951b;
|
||||
}
|
||||
}
|
||||
32
src/styles/toast.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.Toastify__progress-bar--success {
|
||||
background: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success .Toastify__toast-icon svg path {
|
||||
fill: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__progress-bar--error {
|
||||
background: #f13e5f !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--error .Toastify__toast-icon svg path {
|
||||
fill: #f13e5f !important;
|
||||
}
|
||||
|
||||
.Toastify__progress-bar--success {
|
||||
background: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success .Toastify__toast-icon svg path {
|
||||
fill: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast {
|
||||
background: #292929 !important;
|
||||
color: var(--color-liquid-white);
|
||||
}
|
||||
|
||||
.Toastify__toast > button > svg {
|
||||
fill: var(--color-liquid-white);
|
||||
}
|
||||
66
src/views/article/Header.tsx
Normal 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;
|
||||
300
src/views/articleeditor/Editor.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import axios from '../../axios';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
import MarkdownPreview from './MarckDownPreview';
|
||||
|
||||
export const MarkDownPattern = `# 🌙 Добро пожаловать в 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}\`;
|
||||
}
|
||||
|
||||
consol.log(greet({ name: "Ты", role: "Разработчик" }));
|
||||
\`\`\`
|
||||
|
||||
Пример **JavaScript**:
|
||||
|
||||
\`\`\`js
|
||||
const sum = (a, b) => a + b;
|
||||
consol.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-логотип:
|
||||
|
||||

|
||||
|
||||
или
|
||||
|
||||
<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-редактор!**
|
||||
|
||||
`;
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
defaultValue?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||
defaultValue,
|
||||
onChange,
|
||||
}) => {
|
||||
const [markdown, setMarkdown] = useState<string>(
|
||||
defaultValue || MarkDownPattern,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(markdown);
|
||||
}, [markdown]);
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(defaultValue || MarkDownPattern);
|
||||
}, [defaultValue]);
|
||||
|
||||
// Обработчик вставки
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
41
src/views/articleeditor/Header.tsx
Normal 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;
|
||||
50
src/views/articleeditor/MarckDownPreview.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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 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;
|
||||
82
src/views/home/account/Account.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import AccountMenu from './AccoutMenu';
|
||||
import RightPanel from './RightPanel';
|
||||
import Missions from './missions/Missions';
|
||||
import Contests from './contests/Contests';
|
||||
import ArticlesBlock from './articles/ArticlesBlock';
|
||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||
import { useEffect } from 'react';
|
||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||
import { useQuery } from '../../../hooks/useQuery';
|
||||
import {
|
||||
fetchProfile,
|
||||
fetchProfileArticles,
|
||||
fetchProfileContests,
|
||||
fetchProfileMissions,
|
||||
} from '../../../redux/slices/profile';
|
||||
|
||||
const Account = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
|
||||
const query = useQuery();
|
||||
const username = query.get('username') ?? myname ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (username == myname) {
|
||||
dispatch(setMenuActivePage('account'));
|
||||
} else {
|
||||
dispatch(setMenuActivePage(''));
|
||||
}
|
||||
dispatch(
|
||||
fetchProfileMissions({
|
||||
username: username,
|
||||
recentPageSize: 1,
|
||||
authoredPageSize: 100,
|
||||
}),
|
||||
);
|
||||
dispatch(fetchProfileArticles({ username: username, pageSize: 100 }));
|
||||
dispatch(
|
||||
fetchProfileContests({
|
||||
username: username,
|
||||
pastPageSize: 100,
|
||||
minePageSize: 100,
|
||||
upcomingPageSize: 100,
|
||||
}),
|
||||
);
|
||||
dispatch(fetchProfile(username));
|
||||
}, [username]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,430px] 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={<Missions />} />
|
||||
<Route
|
||||
path="articles"
|
||||
element={<ArticlesBlock />}
|
||||
/>
|
||||
<Route path="contests" element={<Contests />} />
|
||||
<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;
|
||||
92
src/views/home/account/AccoutMenu.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
);
|
||||
|
||||
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;
|
||||
143
src/views/home/account/RightPanel.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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';
|
||||
import { useQuery } from '../../../hooks/useQuery';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatDate = (isoDate?: string): string => {
|
||||
if (!isoDate) return '';
|
||||
const date = new Date(isoDate);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${day}.${month}.${year}`;
|
||||
};
|
||||
|
||||
const RightPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: profileData } = useAppSelector(
|
||||
(state) => state.profile.profile,
|
||||
);
|
||||
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
|
||||
const query = useQuery();
|
||||
const username = query.get('username') ?? myname ?? '';
|
||||
|
||||
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">
|
||||
{profileData?.identity.username}
|
||||
</div>
|
||||
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
|
||||
{profileData?.identity.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" text-liquid-light text-[18px] leading-[30px] font-bold">
|
||||
{`Зарегистрирован ${formatDate(
|
||||
profileData?.identity.createdAt,
|
||||
)}`}
|
||||
</div>
|
||||
|
||||
{/* {username == myname && (
|
||||
<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={profileData?.solutions.totalSolved}
|
||||
countLastWeek={profileData?.solutions.solvedLast7Days}
|
||||
/>
|
||||
<StatisticItem
|
||||
icon={Cup}
|
||||
title={'Контесты'}
|
||||
count={profileData?.contests.totalParticipations}
|
||||
countLastWeek={profileData?.contests.participationsLast7Days}
|
||||
/>
|
||||
|
||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||
{'Статистика созданий'}
|
||||
</div>
|
||||
|
||||
<StatisticItem
|
||||
icon={Clipboard}
|
||||
title={'Задачи'}
|
||||
count={profileData?.creation.missions.total}
|
||||
countLastWeek={profileData?.creation.missions.last7Days}
|
||||
/>
|
||||
<StatisticItem
|
||||
icon={OpenBook}
|
||||
title={'Статьи'}
|
||||
count={profileData?.creation.articles.total}
|
||||
countLastWeek={profileData?.creation.articles.last7Days}
|
||||
/>
|
||||
<StatisticItem
|
||||
icon={Cup}
|
||||
title={'Контесты'}
|
||||
count={profileData?.creation.contests.total}
|
||||
countLastWeek={profileData?.creation.contests.last7Days}
|
||||
/>
|
||||
|
||||
{username == myname && (
|
||||
<ReverseButton
|
||||
className="absolute bottom-[20px] right-[20px]"
|
||||
onClick={() => {
|
||||
dispatch(logout());
|
||||
}}
|
||||
text="Выход"
|
||||
color="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
148
src/views/home/account/articles/ArticlesBlock.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
|
||||
export interface ArticleItemProps {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const formatDate = (isoDate?: string): string => {
|
||||
if (!isoDate) return '';
|
||||
const date = new Date(isoDate);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${day}.${month}.${year}`;
|
||||
};
|
||||
|
||||
const ArticleItem: FC<ArticleItemProps> = ({ id, name, createdAt }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
|
||||
'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-[18px] flex text-liquid-light gap-[10px] mt-[20px]">
|
||||
{`Опубликована ${formatDate(createdAt)}`}
|
||||
</div>
|
||||
|
||||
<img
|
||||
className="absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
||||
src={Edit}
|
||||
alt="Редактировать"
|
||||
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 [active, setActive] = useState<boolean>(true);
|
||||
|
||||
const { data: articleData } = useAppSelector(
|
||||
(state) => state.profile.articles,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActiveProfilePage('articles'));
|
||||
}, [dispatch]);
|
||||
|
||||
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}
|
||||
alt="toggle"
|
||||
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">
|
||||
{status === 'loading' && (
|
||||
<div className="text-liquid-light">
|
||||
Загрузка статей...
|
||||
</div>
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<div className="text-liquid-red">Ошибка: </div>
|
||||
)}
|
||||
{status === 'successful' &&
|
||||
articleData?.articles.items.length === 0 && (
|
||||
<div className="text-liquid-light">
|
||||
У вас пока нет статей
|
||||
</div>
|
||||
)}
|
||||
{articleData?.articles.items.map((v, i) => (
|
||||
<ArticleItem
|
||||
key={i}
|
||||
id={v.articleId}
|
||||
name={v.title}
|
||||
createdAt={v.createdAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticlesBlock;
|
||||
62
src/views/home/account/contests/Contests.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||
import ContestsBlock from './ContestsBlock';
|
||||
|
||||
const Contests = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: constestData } = useAppSelector(
|
||||
(state) => state.profile.contests,
|
||||
);
|
||||
|
||||
// При загрузке страницы — выставляем вкладку и подгружаем контесты
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActiveProfilePage('contests'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative flex flex-col text-[60px] font-bold p-[20px] gap-[20px]">
|
||||
{/* Контесты, в которых я участвую */}
|
||||
<div>
|
||||
<ContestsBlock
|
||||
className="mb-[20px]"
|
||||
title="Предстоящие контесты"
|
||||
type="upcoming"
|
||||
contests={constestData?.upcoming.items
|
||||
.filter((v) => v.role != 'Organizer')
|
||||
.filter((v) => v.scheduleType != 'AlwaysOpen')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ContestsBlock
|
||||
className="mb-[20px]"
|
||||
title="Прошедшие контесты"
|
||||
type="past"
|
||||
contests={[
|
||||
...(constestData?.past.items.filter(
|
||||
(v) => v.role != 'Organizer',
|
||||
) ?? []),
|
||||
...(constestData?.upcoming.items
|
||||
.filter((v) => v.role != 'Organizer')
|
||||
.filter((v) => v.scheduleType == 'AlwaysOpen') ??
|
||||
[]),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Контесты, которые я создал */}
|
||||
<div>
|
||||
<ContestsBlock
|
||||
className="mb-[20px]"
|
||||
title="Созданные контесты"
|
||||
type="edit"
|
||||
contests={constestData?.mine.items}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contests;
|
||||
97
src/views/home/account/contests/ContestsBlock.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, FC } from 'react';
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||
import { ContestItem } from '../../../../redux/slices/profile';
|
||||
import PastContestItem from './PastContestItem';
|
||||
import UpcoingContestItem from './UpcomingContestItem';
|
||||
import EditContestItem from './EditContestItem';
|
||||
|
||||
interface ContestsBlockProps {
|
||||
contests?: ContestItem[];
|
||||
title: string;
|
||||
className?: string;
|
||||
type?: 'edit' | 'upcoming' | 'past';
|
||||
}
|
||||
|
||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||
contests,
|
||||
title,
|
||||
className,
|
||||
type = 'edit',
|
||||
}) => {
|
||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
' h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300',
|
||||
active && 'border-b-liquid-lighter',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
>
|
||||
<span className=" select-none">{title}</span>
|
||||
<img
|
||||
src={ChevroneDown}
|
||||
className={cn(
|
||||
'transition-all duration-300 select-none',
|
||||
active && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
|
||||
active && 'grid-rows-[1fr] opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pb-[10px] pt-[20px]">
|
||||
{contests?.map((v, i) => {
|
||||
if (type == 'past') {
|
||||
return (
|
||||
<PastContestItem
|
||||
key={i}
|
||||
{...v}
|
||||
type={i % 2 ? 'second' : 'first'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type == 'upcoming') {
|
||||
return (
|
||||
<UpcoingContestItem
|
||||
key={i}
|
||||
{...v}
|
||||
type={i % 2 ? 'second' : 'first'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type == 'edit') {
|
||||
return (
|
||||
<EditContestItem
|
||||
key={i}
|
||||
{...v}
|
||||
type={i % 2 ? 'second' : 'first'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestsBlock;
|
||||
146
src/views/home/account/contests/EditContestItem.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../../../redux/hooks';
|
||||
import { useQuery } from '../../../../hooks/useQuery';
|
||||
import { toastWarning } from '../../../../lib/toastNotification';
|
||||
import { Edit } from '../../../../assets/icons/input';
|
||||
|
||||
export interface EditContestItemProps {
|
||||
name: string;
|
||||
contestId: number;
|
||||
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||
visibility: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
attemptDurationMinutes: number;
|
||||
role: string;
|
||||
type: 'first' | 'second';
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatDurationTime(minutes: number): string {
|
||||
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 EditContestItem: React.FC<EditContestItemProps> = ({
|
||||
name,
|
||||
contestId,
|
||||
scheduleType,
|
||||
startsAt,
|
||||
endsAt,
|
||||
attemptDurationMinutes,
|
||||
type,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
|
||||
const query = useQuery();
|
||||
const username = query.get('username') ?? myname ?? '';
|
||||
|
||||
const started = new Date(startsAt) <= new Date();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
|
||||
type == 'first'
|
||||
? ' bg-liquid-lighter'
|
||||
: ' bg-liquid-background',
|
||||
username == myname
|
||||
? 'grid-cols-[1fr,150px,190px,110px,130px,24px]'
|
||||
: 'grid-cols-[1fr,150px,190px,110px,130px]',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!started && username != myname) {
|
||||
toastWarning('Контест еще не начался');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
back: '/home/account/contests',
|
||||
});
|
||||
navigate(`/contest/${contestId}?${params}`);
|
||||
}}
|
||||
>
|
||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
||||
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
||||
{username}
|
||||
</div>
|
||||
{scheduleType == 'AlwaysOpen' ? (
|
||||
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
|
||||
Всегда открыт
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-[5px] text-[14px]">
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(startsAt)}
|
||||
</div>
|
||||
<div>-</div>
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(endsAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
{formatDurationTime(attemptDurationMinutes)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
|
||||
{new Date() < new Date(startsAt) ? (
|
||||
<>{'Не начался'}</>
|
||||
) : (
|
||||
<>
|
||||
{scheduleType == 'AlwaysOpen'
|
||||
? 'Открыт'
|
||||
: new Date() < new Date(endsAt)
|
||||
? 'Идет'
|
||||
: 'Завершен'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{username == myname && (
|
||||
<img
|
||||
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
||||
src={Edit}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/contest/create?back=/home/account/contests&contestId=${contestId}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditContestItem;
|
||||
112
src/views/home/account/contests/PastContestItem.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../../../redux/hooks';
|
||||
import { useQuery } from '../../../../hooks/useQuery';
|
||||
|
||||
export interface PastContestItemProps {
|
||||
name: string;
|
||||
contestId: number;
|
||||
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||
visibility: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
attemptDurationMinutes: number;
|
||||
role: string;
|
||||
type: 'first' | 'second';
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatDurationTime(minutes: number): string {
|
||||
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 PastContestItem: React.FC<PastContestItemProps> = ({
|
||||
name,
|
||||
contestId,
|
||||
scheduleType,
|
||||
startsAt,
|
||||
endsAt,
|
||||
attemptDurationMinutes,
|
||||
type,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
|
||||
const query = useQuery();
|
||||
const username = query.get('username') ?? myname ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,150px,190px,120px,150px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
|
||||
type == 'first'
|
||||
? ' bg-liquid-lighter'
|
||||
: ' bg-liquid-background',
|
||||
)}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams({
|
||||
back: '/home/account/contests',
|
||||
});
|
||||
navigate(`/contest/${contestId}?${params}`);
|
||||
}}
|
||||
>
|
||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
||||
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
||||
{username}
|
||||
</div>
|
||||
{scheduleType == 'AlwaysOpen' ? (
|
||||
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
|
||||
Всегда открыт
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-[5px] text-[14px]">
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(startsAt)}
|
||||
</div>
|
||||
<div>-</div>
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(endsAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
{formatDurationTime(attemptDurationMinutes)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
|
||||
{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PastContestItem;
|
||||
160
src/views/home/account/contests/UpcomingContestItem.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../../../redux/hooks';
|
||||
import { useQuery } from '../../../../hooks/useQuery';
|
||||
import { toastWarning } from '../../../../lib/toastNotification';
|
||||
|
||||
export interface UpcoingContestItemProps {
|
||||
name: string;
|
||||
contestId: number;
|
||||
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||
visibility: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
attemptDurationMinutes: number;
|
||||
role: string;
|
||||
type: 'first' | 'second';
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatDurationTime(minutes: number): string {
|
||||
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} мин`;
|
||||
}
|
||||
}
|
||||
|
||||
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 UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
|
||||
name,
|
||||
contestId,
|
||||
scheduleType,
|
||||
startsAt,
|
||||
endsAt,
|
||||
attemptDurationMinutes,
|
||||
type,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
|
||||
const query = useQuery();
|
||||
const username = query.get('username') ?? myname ?? '';
|
||||
|
||||
const started = new Date(startsAt) <= new Date();
|
||||
const finished = new Date(endsAt) <= new Date();
|
||||
const waitTime = !started
|
||||
? new Date(startsAt).getTime() - new Date().getTime()
|
||||
: new Date(endsAt).getTime() - new Date().getTime();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,150px,190px,110px,110px,130px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
|
||||
type == 'first'
|
||||
? ' bg-liquid-lighter'
|
||||
: ' bg-liquid-background',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!started) {
|
||||
toastWarning('Контест еще не начался');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
back: '/home/account/contests',
|
||||
});
|
||||
navigate(`/contest/${contestId}?${params}`);
|
||||
}}
|
||||
>
|
||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
||||
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
||||
{username}
|
||||
</div>
|
||||
{scheduleType == 'AlwaysOpen' ? (
|
||||
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
|
||||
Всегда открыт
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-[5px] text-[14px]">
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(startsAt)}
|
||||
</div>
|
||||
<div>-</div>
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(endsAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
{formatDurationTime(attemptDurationMinutes)}
|
||||
</div>
|
||||
|
||||
{!started ? (
|
||||
<div className="text-center whitespace-pre-line ">
|
||||
{'До начала\n' + formatWaitTime(waitTime)}
|
||||
</div>
|
||||
) : (
|
||||
!finished && (
|
||||
<div className="text-center whitespace-pre-line ">
|
||||
{'До конца\n' + formatWaitTime(waitTime)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
|
||||
{new Date() < new Date(startsAt) ? (
|
||||
<>{'Не начался'}</>
|
||||
) : (
|
||||
<>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcoingContestItem;
|
||||
141
src/views/home/account/missions/Missions.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import MissionsBlock from './MissionsBlock';
|
||||
import {
|
||||
deleteMission,
|
||||
setMissionsStatus,
|
||||
} from '../../../../redux/slices/missions';
|
||||
import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
||||
import { fetchProfileMissions } from '../../../../redux/slices/profile';
|
||||
import { useQuery } from '../../../../hooks/useQuery';
|
||||
|
||||
interface ItemProps {
|
||||
count: number;
|
||||
totalCount: number;
|
||||
title: string;
|
||||
color?: 'default' | 'red' | 'green' | 'orange';
|
||||
}
|
||||
|
||||
const Item: FC<ItemProps> = ({
|
||||
count,
|
||||
totalCount,
|
||||
title,
|
||||
color = 'default',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row rounded-full bg-liquid-lighter px-[16px] py-[8px] gap-[10px] text-[14px]',
|
||||
color == 'default' && 'text-liquid-light',
|
||||
color == 'red' && 'text-liquid-red',
|
||||
color == 'green' && 'text-liquid-green',
|
||||
color == 'orange' && 'text-liquid-orange',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{count}/{totalCount}
|
||||
</div>
|
||||
<div>{title}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Missions = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [modalDeleteTask, setModalDeleteTask] = useState<boolean>(false);
|
||||
const [taskdeleteId, setTaskDeleteId] = useState<number>(0);
|
||||
|
||||
const { data: missionData } = useAppSelector(
|
||||
(state) => state.profile.missions,
|
||||
);
|
||||
|
||||
const myname = useAppSelector((state) => state.auth.username);
|
||||
const query = useQuery();
|
||||
const username = query.get('username') ?? myname ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActiveProfilePage('missions'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setMissionsStatus({ key: 'fetchMy', status: 'idle' }));
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative overflow-y-scroll medium-scrollbar">
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="p-[20px] flex flex-col gap-[20px]">
|
||||
<div className="text-[24px] font-bold text-liquid-white">
|
||||
Решенные задачи
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-start">
|
||||
<div className="flex gap-[10px]">
|
||||
<Item
|
||||
count={missionData?.summary?.total?.solved ?? 0}
|
||||
totalCount={
|
||||
missionData?.summary?.total?.total ?? 0
|
||||
}
|
||||
title={
|
||||
missionData?.summary?.total?.label ??
|
||||
'Задачи'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-[20px]">
|
||||
{missionData?.summary?.buckets?.map((bucket) => (
|
||||
<Item
|
||||
key={bucket.key}
|
||||
count={bucket.solved}
|
||||
totalCount={bucket.total}
|
||||
title={bucket.label}
|
||||
color={
|
||||
bucket.key === 'easy'
|
||||
? 'green'
|
||||
: bucket.key === 'medium'
|
||||
? 'orange'
|
||||
: 'red'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-[20px]">
|
||||
<MissionsBlock
|
||||
missions={missionData?.authored.items ?? []}
|
||||
title="Мои миссии"
|
||||
setTastDeleteId={setTaskDeleteId}
|
||||
setDeleteModalActive={setModalDeleteTask}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
active={modalDeleteTask}
|
||||
setActive={setModalDeleteTask}
|
||||
title="Подтвердите действия"
|
||||
message={`Вы действительно хотите удалить задачу #${taskdeleteId}?`}
|
||||
confirmColor="error"
|
||||
confirmText="Удалить"
|
||||
onConfirmClick={() => {
|
||||
dispatch(deleteMission(taskdeleteId))
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
dispatch(
|
||||
fetchProfileMissions({
|
||||
username: username,
|
||||
recentPageSize: 1,
|
||||
authoredPageSize: 100,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Missions;
|
||||
78
src/views/home/account/missions/MissionsBlock.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, FC } from 'react';
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||
import MyMissionItem from './MyMissionItem';
|
||||
import { MissionItem } from '../../../../redux/slices/profile';
|
||||
|
||||
interface MissionsBlockProps {
|
||||
missions: MissionItem[];
|
||||
title: string;
|
||||
className?: string;
|
||||
setTastDeleteId: (v: number) => void;
|
||||
setDeleteModalActive: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const MissionsBlock: FC<MissionsBlockProps> = ({
|
||||
missions,
|
||||
title,
|
||||
className,
|
||||
|
||||
setTastDeleteId,
|
||||
setDeleteModalActive,
|
||||
}) => {
|
||||
const [active, setActive] = useState<boolean>(true);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
' h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300',
|
||||
active && 'border-b-liquid-lighter',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<img
|
||||
src={ChevroneDown}
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
active && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
|
||||
active && 'grid-rows-[1fr] opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pb-[10px] pt-[20px]">
|
||||
{missions.map((v, i) => (
|
||||
<MyMissionItem
|
||||
key={i}
|
||||
id={v.missionId}
|
||||
name={v.missionName}
|
||||
timeLimit={v.timeLimitMilliseconds}
|
||||
memoryLimit={v.memoryLimitBytes}
|
||||
difficulty={v.difficultyValue}
|
||||
type={i % 2 ? 'second' : 'first'}
|
||||
setTastDeleteId={setTastDeleteId}
|
||||
setDeleteModalActive={setDeleteModalActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissionsBlock;
|
||||
108
src/views/home/account/missions/MyMissionItem.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Trash } from '../../../../assets/icons/input';
|
||||
import { useAppSelector } from '../../../../redux/hooks';
|
||||
|
||||
export interface MissionItemProps {
|
||||
id: number;
|
||||
authorId?: number;
|
||||
name: string;
|
||||
difficulty: number;
|
||||
tags?: string[];
|
||||
timeLimit?: number;
|
||||
memoryLimit?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
type?: 'first' | 'second';
|
||||
status?: 'empty' | 'success' | 'error';
|
||||
setTastDeleteId: (v: number) => void;
|
||||
setDeleteModalActive: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function formatMilliseconds(ms: number): string {
|
||||
const rounded = Math.round(ms) / 1000;
|
||||
const formatted = rounded.toString().replace(/\.?0+$/, '');
|
||||
return `${formatted} c`;
|
||||
}
|
||||
|
||||
export function formatBytesToMB(bytes: number): string {
|
||||
const megabytes = Math.floor(bytes / (1024 * 1024));
|
||||
return `${megabytes} МБ`;
|
||||
}
|
||||
|
||||
const MissionItem: React.FC<MissionItemProps> = ({
|
||||
id,
|
||||
name,
|
||||
difficulty,
|
||||
timeLimit = 1000,
|
||||
memoryLimit = 256 * 1024 * 1024,
|
||||
type,
|
||||
status,
|
||||
setTastDeleteId,
|
||||
setDeleteModalActive,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const calcDifficulty = (d: number) => {
|
||||
if (d <= 1200) return 'Easy';
|
||||
if (d <= 2000) return 'Medium';
|
||||
return 'Hard';
|
||||
};
|
||||
const difficultyString = calcDifficulty(difficulty);
|
||||
const deleteStatus = useAppSelector(
|
||||
(state) => state.missions.statuses.delete,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-[44px] w-full relative rounded-[10px] text-liquid-white py-[8px]',
|
||||
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
|
||||
'grid grid-cols-[80px,2fr,3fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center',
|
||||
status == 'error' &&
|
||||
'border-l-[11px] border-l-liquid-red pl-[9px]',
|
||||
status == 'success' &&
|
||||
'border-l-[11px] border-l-liquid-green pl-[9px]',
|
||||
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||
)}
|
||||
onClick={() => {
|
||||
navigate(`/mission/${id}?back=/home/account/missions`);
|
||||
}}
|
||||
>
|
||||
<div className="text-[18px] font-bold">#{id}</div>
|
||||
<div className="text-[18px] font-bold">{name}</div>
|
||||
<div className="text-[12px] text-right">
|
||||
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
|
||||
{formatBytesToMB(memoryLimit)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-center text-[18px]',
|
||||
difficultyString == 'Hard' && 'text-liquid-red',
|
||||
difficultyString == 'Medium' && 'text-liquid-orange',
|
||||
difficultyString == 'Easy' && 'text-liquid-green',
|
||||
)}
|
||||
>
|
||||
{difficultyString}
|
||||
</div>
|
||||
<div className="h-[24px] w-[24px]">
|
||||
<img
|
||||
src={Trash}
|
||||
className={cn(
|
||||
'hover:bg-liquid-light rounded-[8px] transition-all duration-300',
|
||||
deleteStatus == 'loading' &&
|
||||
'cursor-default pointer-events-none hover:bg-transparent opacity-35',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (deleteStatus != 'loading') {
|
||||
setTastDeleteId(id);
|
||||
setDeleteModalActive(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissionItem;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '../../../lib/cn';
|
||||
import { useAppSelector } from '../../../redux/hooks';
|
||||
|
||||
export interface ArticleItemProps {
|
||||
id: number;
|
||||
@@ -6,34 +8,102 @@ export interface ArticleItemProps {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const ArticleItem: React.FC<ArticleItemProps> = ({
|
||||
id, name, tags
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("w-full relative rounded-[10px] text-liquid-white mb-[20px]",
|
||||
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
||||
"gap-[20px] px-[20px] py-[10px] box-border ",
|
||||
"border-b-[1px] border-b-liquid-lighter",
|
||||
)}>
|
||||
<div className="h-[23px] flex ">
|
||||
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const filterTags = useAppSelector(
|
||||
(state) => state.store.articles.articleTagFilter,
|
||||
);
|
||||
const nameFilter = useAppSelector(
|
||||
(state) => state.store.articles.filterName,
|
||||
);
|
||||
|
||||
const highlightZ = (name: string, filter: string) => {
|
||||
if (!filter) return name;
|
||||
|
||||
const s = filter.toLowerCase();
|
||||
const t = name.toLowerCase();
|
||||
const n = t.length;
|
||||
const m = s.length;
|
||||
|
||||
const mark = Array(n).fill(false);
|
||||
|
||||
// Проходимся с конца и ставим отметки
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
if (i + m <= n && t.slice(i, i + m) === s) {
|
||||
for (let j = i; j < i + m; j++) {
|
||||
if (mark[j]) break;
|
||||
mark[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Формируем единые жёлтые блоки ===
|
||||
const result: any[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < n) {
|
||||
if (!mark[i]) {
|
||||
// обычный символ
|
||||
result.push(name[i]);
|
||||
i++;
|
||||
} else {
|
||||
// начинаем жёлтый блок
|
||||
let j = i;
|
||||
while (j < n && mark[j]) j++;
|
||||
|
||||
const chunk = name.slice(i, j);
|
||||
result.push(
|
||||
<span
|
||||
key={i}
|
||||
className="bg-yellow-400 text-black rounded px-1"
|
||||
>
|
||||
{chunk}
|
||||
</span>,
|
||||
);
|
||||
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
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}`);
|
||||
}}
|
||||
>
|
||||
<div className="h-[23px] flex ">
|
||||
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
|
||||
#{id}
|
||||
</div>
|
||||
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
||||
{name}
|
||||
{highlightZ(name, nameFilter)}
|
||||
</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")}>
|
||||
{tags.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
||||
v == 'Sertificated' && 'text-liquid-green',
|
||||
filterTags.includes(v) &&
|
||||
'border-liquid-brightmain border-[1px] border-solid text-liquid-brightmain',
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,167 +1,84 @@
|
||||
import { useEffect } from "react";
|
||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
||||
import { useAppDispatch } from "../../../redux/hooks";
|
||||
import ArticleItem from "./ArticleItem";
|
||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
||||
|
||||
|
||||
export interface Article {
|
||||
id: number;
|
||||
name: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||
import ArticleItem from './ArticleItem';
|
||||
import {
|
||||
setArticlesNameFilter,
|
||||
setArticlesTagFilter,
|
||||
setMenuActivePage,
|
||||
} from '../../../redux/slices/store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchArticles } from '../../../redux/slices/articles';
|
||||
import Filters from './Filter';
|
||||
|
||||
const Articles = () => {
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const articles: Article[] = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Todo List App",
|
||||
"tags": ["Sertificated", "state", "list"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Search Filter Component",
|
||||
"tags": ["filter", "props", "hooks"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "User Card List",
|
||||
"tags": ["components", "props", "array"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Theme Switcher",
|
||||
"tags": ["Sertificated", "theme", "hooks"],
|
||||
}
|
||||
];
|
||||
// ✅ Берём данные из нового состояния
|
||||
const articles = useAppSelector(
|
||||
(state) => state.articles.fetchArticles.articles,
|
||||
);
|
||||
const tagsFilter = useAppSelector(
|
||||
(state) => state.store.articles.articleTagFilter,
|
||||
);
|
||||
const nameFilter = useAppSelector(
|
||||
(state) => state.store.articles.filterName,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActivePage("articles"))
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActivePage('articles'));
|
||||
dispatch(fetchArticles({ tags: tagsFilter }));
|
||||
}, []);
|
||||
|
||||
const filterTagsHandler = (value: string[]) => {
|
||||
dispatch(setArticlesTagFilter(value));
|
||||
dispatch(fetchArticles({ tags: value }));
|
||||
};
|
||||
|
||||
// ========================
|
||||
// Основной контент
|
||||
// ========================
|
||||
return (
|
||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||
<div className="h-full w-full box-border p-[20px]">
|
||||
<div className="h-full box-border">
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="relative flex items-center mb-[20px]">
|
||||
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
||||
Статьи
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => { }}
|
||||
onClick={() => navigate('/article/create')}
|
||||
text="Создать статью"
|
||||
className="absolute right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
||||
{/* Фильтры */}
|
||||
<Filters
|
||||
onChangeTags={(value: string[]) => {
|
||||
filterTagsHandler(value);
|
||||
}}
|
||||
onChangeName={(value: string) => {
|
||||
dispatch(setArticlesNameFilter(value));
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
{articles.map((v, i) => (
|
||||
<ArticleItem key={i} {...v} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
pages
|
||||
{/* Список статей */}
|
||||
<div className="mt-[20px]">
|
||||
{articles.length === 0 ? (
|
||||
<div className="text-liquid-light text-[16px]">
|
||||
Пока нет статей
|
||||
</div>
|
||||
) : (
|
||||
articles
|
||||
.filter((v) =>
|
||||
v.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(nameFilter.toLocaleLowerCase()),
|
||||
)
|
||||
.map((v) => <ArticleItem key={v.id} {...v} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||