Compare commits
50 Commits
04da2b565a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 58f99122ab | |||
| 79b146786b | |||
| 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 |
2
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -23,3 +24,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
|||||||
1434
package-lock.json
generated
@@ -18,13 +18,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.9.0",
|
"framer-motion": "^11.9.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.53.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"react-toastify": "^11.0.5",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
@@ -45,6 +46,6 @@
|
|||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.0.1",
|
"typescript-eslint": "^8.0.1",
|
||||||
"vite": "^5.4.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 |
14
src/App.tsx
@@ -8,18 +8,28 @@ import Home from './pages/Home';
|
|||||||
import Mission from './pages/Mission';
|
import Mission from './pages/Mission';
|
||||||
import ArticleEditor from './pages/ArticleEditor';
|
import ArticleEditor from './pages/ArticleEditor';
|
||||||
import Article from './pages/Article';
|
import Article from './pages/Article';
|
||||||
|
import ContestEditor from './pages/ContestEditor';
|
||||||
|
import ProtectedRoute from './components/router/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-liquid-background flex justify-center">
|
<div className="w-full h-full bg-liquid-background flex justify-center">
|
||||||
<div className="relative w-full max-w-[1600px] h-full ">
|
<div className="relative w-full max-w-[1600px] h-full ">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/home/*" element={<Home />} />
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="/mission/:missionId" element={<Mission />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/article/create/*"
|
path="/article/create/*"
|
||||||
element={<ArticleEditor />}
|
element={<ArticleEditor />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/contest/create/*"
|
||||||
|
element={<ContestEditor />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/home/*" element={<Home />} />
|
||||||
|
<Route path="/mission/:missionId" element={<Mission />} />
|
||||||
|
|
||||||
<Route path="/article/:articleId" element={<Article />} />
|
<Route path="/article/:articleId" element={<Article />} />
|
||||||
<Route path="*" element={<Home />} />
|
<Route path="*" element={<Home />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
39
src/api/missionsUpload.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export interface UploadMissionRequest {
|
||||||
|
missionFile: File;
|
||||||
|
difficulty: number;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildUploadMissionFormData = (
|
||||||
|
request: UploadMissionRequest,
|
||||||
|
): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('missionFile', request.missionFile);
|
||||||
|
formData.append('difficulty', request.difficulty.toString());
|
||||||
|
|
||||||
|
// tags:
|
||||||
|
// - undefined => fromArchive (do not include tags key at all)
|
||||||
|
// - [] => empty list (not reliably representable via multipart without backend support)
|
||||||
|
// - [..] => custom tags, repeated keys
|
||||||
|
if (Array.isArray(request.tags)) {
|
||||||
|
request.tags.forEach((tag) => formData.append('tags', tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProblemXmlMissingNameMessage = (responseData: unknown) => {
|
||||||
|
const asText =
|
||||||
|
typeof responseData === 'string'
|
||||||
|
? responseData
|
||||||
|
: responseData == null
|
||||||
|
? ''
|
||||||
|
: JSON.stringify(responseData);
|
||||||
|
|
||||||
|
if (/problem\.xml/i.test(asText) || /Mission name was not found/i.test(asText)) {
|
||||||
|
return 'В архиве отсутствует имя в problem.xml';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
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 |
@@ -5,4 +5,5 @@ import Edit from './edit.svg';
|
|||||||
import UserAdd from './user-profile-add.svg';
|
import UserAdd from './user-profile-add.svg';
|
||||||
import ChevroneDown from './chevron-down.svg';
|
import ChevroneDown from './chevron-down.svg';
|
||||||
|
|
||||||
|
|
||||||
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };
|
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import upload from './upload.svg';
|
|||||||
import chevroneDropDownList from './chevron-drop-down.svg';
|
import chevroneDropDownList from './chevron-drop-down.svg';
|
||||||
import checkMark from './check-mark.svg';
|
import checkMark from './check-mark.svg';
|
||||||
import Edit from './edit.svg';
|
import Edit from './edit.svg';
|
||||||
|
import Send from './send.svg';
|
||||||
|
import Trash from './trash.svg';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -14,4 +16,6 @@ export {
|
|||||||
upload,
|
upload,
|
||||||
chevroneDropDownList,
|
chevroneDropDownList,
|
||||||
checkMark,
|
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 |
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 };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ interface ButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,9 @@ export const PrimaryButton: React.FC<ButtonProps> = ({
|
|||||||
disabled && 'pointer-events-none',
|
disabled && 'pointer-events-none',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Основной контейнер, */}
|
{/* Основной контейнер, */}
|
||||||
<div
|
<div
|
||||||
@@ -60,10 +63,8 @@ export const PrimaryButton: React.FC<ButtonProps> = ({
|
|||||||
'[&:focus-visible+*]:outline-liquid-brightmain',
|
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={(
|
onClick={() => {
|
||||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
onClick();
|
||||||
) => {
|
|
||||||
onClick(e);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ interface ButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,9 @@ export const ReverseButton: React.FC<ButtonProps> = ({
|
|||||||
disabled && 'pointer-events-none',
|
disabled && 'pointer-events-none',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Основной контейнер, */}
|
{/* Основной контейнер, */}
|
||||||
<div
|
<div
|
||||||
@@ -61,10 +64,8 @@ export const ReverseButton: React.FC<ButtonProps> = ({
|
|||||||
'[&:focus-visible+*]:outline-liquid-brightmain',
|
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={(
|
onClick={() => {
|
||||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
onClick();
|
||||||
) => {
|
|
||||||
onClick(e);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ interface ButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ export const SecondaryButton: React.FC<ButtonProps> = ({
|
|||||||
disabled && 'pointer-events-none',
|
disabled && 'pointer-events-none',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Основной контейнер, */}
|
{/* Основной контейнер, */}
|
||||||
<div
|
<div
|
||||||
@@ -41,8 +44,8 @@ export const SecondaryButton: React.FC<ButtonProps> = ({
|
|||||||
'[&:focus-visible+*]:outline-liquid-brightmain',
|
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
onClick(e);
|
onClick();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'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],
|
sizeVariants[size],
|
||||||
radiusVraiants[radius],
|
radiusVraiants[radius],
|
||||||
active && borderColorsVariants[color],
|
active && borderColorsVariants[color],
|
||||||
|
|||||||
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;
|
||||||
@@ -27,7 +27,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={startValue}
|
value={startValue}
|
||||||
onChange={(e) => onChange('startsAt', e.target.value)}
|
onChange={(e) => onChange('startsAt', e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
className="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -38,7 +38,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={endValue}
|
value={endValue}
|
||||||
onChange={(e) => onChange('endsAt', e.target.value)}
|
onChange={(e) => onChange('endsAt', e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
className="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
|
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
|
||||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
@@ -14,6 +14,7 @@ interface DropDownListProps {
|
|||||||
onChange: (state: string) => void;
|
onChange: (state: string) => void;
|
||||||
defaultState?: DropDownListItem;
|
defaultState?: DropDownListItem;
|
||||||
items: DropDownListItem[];
|
items: DropDownListItem[];
|
||||||
|
weight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropDownList: React.FC<DropDownListProps> = ({
|
export const DropDownList: React.FC<DropDownListProps> = ({
|
||||||
@@ -22,6 +23,7 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
defaultState,
|
defaultState,
|
||||||
items = [{ text: '', value: '' }],
|
items = [{ text: '', value: '' }],
|
||||||
|
weight = 'w-[180px]',
|
||||||
}) => {
|
}) => {
|
||||||
if (items.length == 0) items.push({ text: '', value: '' });
|
if (items.length == 0) items.push({ text: '', value: '' });
|
||||||
|
|
||||||
@@ -30,21 +32,24 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
);
|
);
|
||||||
const [active, setActive] = React.useState<boolean>(false);
|
const [active, setActive] = React.useState<boolean>(false);
|
||||||
|
|
||||||
React.useEffect(() => onChange(value.value), [value]);
|
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useClickOutside(ref, () => {
|
useClickOutside(ref, () => {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(defaultState != undefined ? defaultState : items[0]);
|
||||||
|
}, [defaultState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)} ref={ref}>
|
<div className={cn('relative', className)} ref={ref}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]',
|
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px]',
|
||||||
'text-[18px] font-bold cursor-pointer select-none',
|
'text-[18px] font-bold cursor-pointer select-none',
|
||||||
'transitin-all active:scale-95 duration-300',
|
'transitin-all active:scale-95 duration-300',
|
||||||
|
weight,
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActive(!active);
|
setActive(!active);
|
||||||
@@ -56,21 +61,22 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
<img
|
<img
|
||||||
src={chevroneDropDownList}
|
src={chevroneDropDownList}
|
||||||
className={cn(
|
className={cn(
|
||||||
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none',
|
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none select-none',
|
||||||
active && ' rotate-180',
|
active && ' rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
' absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300',
|
' absolute rounded-[10px] bg-liquid-lighter left-0 top-[48px] z-50 transition-all duration-300',
|
||||||
'grid overflow-hidden',
|
'grid overflow-hidden',
|
||||||
|
weight,
|
||||||
active
|
active
|
||||||
? 'grid-rows-[1fr] opacity-100'
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
: 'grid-rows-[0fr] opacity-0',
|
: 'grid-rows-[0fr] opacity-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className=" overflow-hidden p-[8px]">
|
<div className=" overflow-hidden p-[8px] border-liquid-background border-solid border-[1px] rounded-[10px]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
||||||
@@ -89,6 +95,7 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
|
onChange(v.value);
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -11,6 +11,7 @@ interface inputProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
onChange: (state: string) => void;
|
onChange: (state: string) => void;
|
||||||
defaultState?: string;
|
defaultState?: string;
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
@@ -25,6 +26,7 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
label = '',
|
label = '',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
className = '',
|
className = '',
|
||||||
|
inputClassName = '',
|
||||||
onChange,
|
onChange,
|
||||||
defaultState = '',
|
defaultState = '',
|
||||||
name = '',
|
name = '',
|
||||||
@@ -52,10 +54,11 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
||||||
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
||||||
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
name={name}
|
name={name}
|
||||||
autoComplete={autocomplete}
|
autoComplete={autocomplete || undefined}
|
||||||
type={
|
type={
|
||||||
type == 'password'
|
type == 'password'
|
||||||
? visible
|
? visible
|
||||||
@@ -84,7 +87,7 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-liquid-red text-[14px] h-[18px] text-right mt-[5px]',
|
'text-liquid-red text-[14px] h-auto text-right mt-[5px] whitespace-pre-line ',
|
||||||
error == '' && 'h-0 mt-0',
|
error == '' && 'h-0 mt-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
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;
|
||||||
@@ -15,8 +15,8 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modalbgVariants = {
|
const modalbgVariants = {
|
||||||
closed: { opacity: 0 },
|
closed: { opacity: 0, backdropFilter: 'blur(0px)' },
|
||||||
open: { opacity: 1 },
|
open: { opacity: 1, backdropFilter: 'blur(6px)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const modalVariants = {
|
const modalVariants = {
|
||||||
@@ -47,13 +47,15 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
exit={modalbgVariants.closed}
|
exit={modalbgVariants.closed}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50',
|
'fixed top-0 left-0 h-svh w-svw z-50',
|
||||||
backdrop == 'blur' && open && 'backdrop-blur-sm',
|
backdrop === 'opaque' && 'bg-[#00000055]',
|
||||||
backdrop == 'opaque' &&
|
|
||||||
open &&
|
|
||||||
'bg-[#00000055] pointer-events-none',
|
|
||||||
)}
|
)}
|
||||||
></motion.div>
|
style={
|
||||||
|
backdrop === 'blur'
|
||||||
|
? undefined
|
||||||
|
: { backdropFilter: 'none' }
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
|
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// src/routes/ProtectedRoute.tsx
|
// src/routes/ProtectedRoute.tsx
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useAppSelector } from '../../redux/hooks';
|
import { useAppSelector } from '../../redux/hooks';
|
||||||
|
|
||||||
export default function ProtectedRoute() {
|
export default function ProtectedRoute() {
|
||||||
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
|
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/home/login" replace />;
|
return <Navigate to="/home/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,11 +6,13 @@ import './styles/palette/theme-light.css';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { store } from './redux/store';
|
import { store } from './redux/store';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<App />
|
<App />
|
||||||
|
<ToastContainer />
|
||||||
</Provider>
|
</Provider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect } from 'react';
|
|||||||
import { fetchArticleById } from '../redux/slices/articles';
|
import { fetchArticleById } from '../redux/slices/articles';
|
||||||
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
|
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
|
||||||
import { useQuery } from '../hooks/useQuery';
|
import { useQuery } from '../hooks/useQuery';
|
||||||
|
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
||||||
|
|
||||||
const Article = () => {
|
const Article = () => {
|
||||||
// Получаем параметры из URL
|
// Получаем параметры из URL
|
||||||
@@ -19,8 +20,12 @@ const Article = () => {
|
|||||||
return <Navigate to="/home" replace />;
|
return <Navigate to="/home" replace />;
|
||||||
}
|
}
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const article = useAppSelector((state) => state.articles.currentArticle);
|
const article = useAppSelector(
|
||||||
const status = useAppSelector((state) => state.articles.statuses.fetchById);
|
(state) => state.articles.fetchArticleById.article,
|
||||||
|
);
|
||||||
|
const status = useAppSelector(
|
||||||
|
(state) => state.articles.fetchArticleById.status,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchArticleById(articleIdNumber));
|
dispatch(fetchArticleById(articleIdNumber));
|
||||||
@@ -65,7 +70,7 @@ const Article = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className=""></div>
|
<ArticlesRightPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../redux/slices/articles';
|
} from '../redux/slices/articles';
|
||||||
import { useQuery } from '../hooks/useQuery';
|
import { useQuery } from '../hooks/useQuery';
|
||||||
import { ReverseButton } from '../components/button/ReverseButton';
|
import { ReverseButton } from '../components/button/ReverseButton';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
const ArticleEditor = () => {
|
const ArticleEditor = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -23,26 +24,34 @@ const ArticleEditor = () => {
|
|||||||
const query = useQuery();
|
const query = useQuery();
|
||||||
const back = query.get('back') ?? undefined;
|
const back = query.get('back') ?? undefined;
|
||||||
const articleId = Number(query.get('articleId') ?? undefined);
|
const articleId = Number(query.get('articleId') ?? undefined);
|
||||||
const article = useAppSelector((state) => state.articles.currentArticle);
|
const refactor = articleId && !isNaN(articleId);
|
||||||
const refactor = articleId != undefined && !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 [code, setCode] = useState<string>(article?.content || '');
|
||||||
const [name, setName] = useState<string>(article?.name || '');
|
const [name, setName] = useState<string>(article?.name || '');
|
||||||
const [tagInput, setTagInput] = useState<string>('');
|
const [tagInput, setTagInput] = useState<string>('');
|
||||||
const [tags, setTags] = useState<string[]>(article?.tags || []);
|
const [tags, setTags] = useState<string[]>(article?.tags || []);
|
||||||
|
|
||||||
const [activeEditor, setActiveEditor] = useState<boolean>(false);
|
const [activeEditor, setActiveEditor] = useState<boolean>(false);
|
||||||
|
|
||||||
const statusCreate = useAppSelector(
|
// ==========================
|
||||||
(state) => state.articles.statuses.create,
|
// Теги
|
||||||
);
|
// ==========================
|
||||||
const statusUpdate = useAppSelector(
|
|
||||||
(state) => state.articles.statuses.update,
|
|
||||||
);
|
|
||||||
const statusDelete = useAppSelector(
|
|
||||||
(state) => state.articles.statuses.delete,
|
|
||||||
);
|
|
||||||
|
|
||||||
const addTag = () => {
|
const addTag = () => {
|
||||||
const newTag = tagInput.trim();
|
const newTag = tagInput.trim();
|
||||||
if (newTag && !tags.includes(newTag)) {
|
if (newTag && !tags.includes(newTag)) {
|
||||||
@@ -54,54 +63,76 @@ const ArticleEditor = () => {
|
|||||||
const removeTag = (tagToRemove: string) => {
|
const removeTag = (tagToRemove: string) => {
|
||||||
setTags(tags.filter((tag) => tag !== tagToRemove));
|
setTags(tags.filter((tag) => tag !== tagToRemove));
|
||||||
};
|
};
|
||||||
|
// ==========================
|
||||||
|
// Эффекты по статусам
|
||||||
|
// ==========================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusCreate == 'successful') {
|
if (statusCreate === 'successful') {
|
||||||
dispatch(setArticlesStatus({ key: 'create', status: 'idle' }));
|
dispatch(
|
||||||
navigate(back ? back : '/home/articles');
|
setArticlesStatus({ key: 'createArticle', status: 'idle' }),
|
||||||
|
);
|
||||||
|
navigate(back ?? '/home/articles');
|
||||||
}
|
}
|
||||||
}, [statusCreate]);
|
}, [statusCreate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusDelete == 'successful') {
|
if (statusUpdate === 'successful') {
|
||||||
dispatch(setArticlesStatus({ key: 'delete', status: 'idle' }));
|
dispatch(
|
||||||
navigate(back ? back : '/home/articles');
|
setArticlesStatus({ key: 'updateArticle', status: 'idle' }),
|
||||||
}
|
);
|
||||||
}, [statusDelete]);
|
navigate(back ?? '/home/articles');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (statusUpdate == 'successful') {
|
|
||||||
dispatch(setArticlesStatus({ key: 'update', status: 'idle' }));
|
|
||||||
navigate(back ? back : '/home/articles');
|
|
||||||
}
|
}
|
||||||
}, [statusUpdate]);
|
}, [statusUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (statusDelete === 'successful') {
|
||||||
|
dispatch(
|
||||||
|
setArticlesStatus({ key: 'deleteArticle', status: 'idle' }),
|
||||||
|
);
|
||||||
|
navigate(back ?? '/home/articles');
|
||||||
|
}
|
||||||
|
}, [statusDelete]);
|
||||||
|
|
||||||
|
// ==========================
|
||||||
|
// Получение статьи
|
||||||
|
// ==========================
|
||||||
|
useEffect(() => {
|
||||||
|
setClickSubmit(false);
|
||||||
if (articleId) {
|
if (articleId) {
|
||||||
dispatch(fetchArticleById(articleId));
|
dispatch(fetchArticleById(articleId));
|
||||||
}
|
}
|
||||||
}, [articleId]);
|
}, [articleId]);
|
||||||
|
|
||||||
|
// Обновление локального состояния после загрузки статьи
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (article && refactor) {
|
if (article && refactor) {
|
||||||
setCode(article?.content || '');
|
setCode(article.content || '');
|
||||||
setName(article?.name || '');
|
setName(article.name || '');
|
||||||
setTags(article?.tags || []);
|
setTags(article.tags || []);
|
||||||
}
|
}
|
||||||
}, [article]);
|
}, [article]);
|
||||||
|
|
||||||
|
const getNameErrorMessage = (): string => {
|
||||||
|
if (!clickSubmit) return '';
|
||||||
|
if (name == '') return 'Поле не может быть пустым';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentErrorMessage = (): string => {
|
||||||
|
if (!clickSubmit) return '';
|
||||||
|
if (code == '') return 'Поле не может быть пустым';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================
|
||||||
|
// Рендер
|
||||||
|
// ==========================
|
||||||
return (
|
return (
|
||||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||||
{activeEditor ? (
|
{activeEditor ? (
|
||||||
<Header
|
<Header backClick={() => setActiveEditor(false)} />
|
||||||
backClick={() => {
|
|
||||||
setActiveEditor(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Header
|
<Header backClick={() => navigate(back ?? '/home/articles')} />
|
||||||
backClick={() => navigate(back ? back : '/home/articles')}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeEditor ? (
|
{activeEditor ? (
|
||||||
@@ -113,11 +144,14 @@ const ArticleEditor = () => {
|
|||||||
? `Редактирование статьи: \"${article?.name}\"`
|
? `Редактирование статьи: \"${article?.name}\"`
|
||||||
: 'Создание статьи'}
|
: 'Создание статьи'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
<div>
|
<div>
|
||||||
{refactor ? (
|
{refactor ? (
|
||||||
<div className="flex gap-[20px]">
|
<div className="flex gap-[20px]">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setClickSubmit(true);
|
||||||
dispatch(
|
dispatch(
|
||||||
updateArticle({
|
updateArticle({
|
||||||
articleId,
|
articleId,
|
||||||
@@ -129,21 +163,22 @@ const ArticleEditor = () => {
|
|||||||
}}
|
}}
|
||||||
text="Обновить"
|
text="Обновить"
|
||||||
className="mt-[20px]"
|
className="mt-[20px]"
|
||||||
disabled={statusUpdate == 'loading'}
|
disabled={statusUpdate === 'loading'}
|
||||||
/>
|
/>
|
||||||
<ReverseButton
|
<ReverseButton
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
dispatch(deleteArticle(articleId));
|
dispatch(deleteArticle(articleId))
|
||||||
}}
|
}
|
||||||
color="error"
|
color="error"
|
||||||
text="Удалить"
|
text="Удалить"
|
||||||
className="mt-[20px]"
|
className="mt-[20px]"
|
||||||
disabled={statusDelete == 'loading'}
|
disabled={statusDelete === 'loading'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setClickSubmit(true);
|
||||||
dispatch(
|
dispatch(
|
||||||
createArticle({
|
createArticle({
|
||||||
name,
|
name,
|
||||||
@@ -154,11 +189,12 @@ const ArticleEditor = () => {
|
|||||||
}}
|
}}
|
||||||
text="Опубликовать"
|
text="Опубликовать"
|
||||||
className="mt-[20px]"
|
className="mt-[20px]"
|
||||||
disabled={statusCreate == 'loading'}
|
disabled={statusCreate === 'loading'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Название */}
|
||||||
<Input
|
<Input
|
||||||
defaultState={name}
|
defaultState={name}
|
||||||
name="articleName"
|
name="articleName"
|
||||||
@@ -166,13 +202,12 @@ const ArticleEditor = () => {
|
|||||||
className="mt-[20px] max-w-[600px]"
|
className="mt-[20px] max-w-[600px]"
|
||||||
type="text"
|
type="text"
|
||||||
label="Название"
|
label="Название"
|
||||||
onChange={(v) => {
|
onChange={setName}
|
||||||
setName(v);
|
|
||||||
}}
|
|
||||||
placeholder="Новая статья"
|
placeholder="Новая статья"
|
||||||
|
error={getNameErrorMessage()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Блок для тегов */}
|
{/* Теги */}
|
||||||
<div className="mt-[20px] max-w-[600px]">
|
<div className="mt-[20px] max-w-[600px]">
|
||||||
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -181,14 +216,11 @@ const ArticleEditor = () => {
|
|||||||
className="mt-[20px] max-w-[600px]"
|
className="mt-[20px] max-w-[600px]"
|
||||||
type="text"
|
type="text"
|
||||||
label="Теги"
|
label="Теги"
|
||||||
onChange={(v) => {
|
onChange={setTagInput}
|
||||||
setTagInput(v);
|
|
||||||
}}
|
|
||||||
defaultState={tagInput}
|
defaultState={tagInput}
|
||||||
placeholder="arrays"
|
placeholder="arrays"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
console.log(e.key);
|
if (e.key === 'Enter') addTag();
|
||||||
if (e.key == 'Enter') addTag();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -215,14 +247,23 @@ const ArticleEditor = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Просмотр и переход в редактор */}
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => setActiveEditor(true)}
|
onClick={() => setActiveEditor(true)}
|
||||||
text="Редактировать текст"
|
text="Редактировать текст"
|
||||||
className="mt-[20px]"
|
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
|
<MarkdownPreview
|
||||||
content={code}
|
content={code}
|
||||||
className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]"
|
className="bg-transparent border-liquid-lighter border-[3px] rounded-[20px] mt-[20px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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,23 +1,25 @@
|
|||||||
// import React from "react";
|
// src/pages/Home.tsx
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import Login from '../views/home/auth/Login';
|
import Login from '../views/home/auth/Login';
|
||||||
import Register from '../views/home/auth/Register';
|
import Register from '../views/home/auth/Register';
|
||||||
import Menu from '../views/home/menu/Menu';
|
import Menu from '../views/home/menu/Menu';
|
||||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { fetchWhoAmI, logout } from '../redux/slices/auth';
|
import { fetchWhoAmI } from '../redux/slices/auth';
|
||||||
import Missions from '../views/home/missions/Missions';
|
import Missions from '../views/home/missions/Missions';
|
||||||
import Articles from '../views/home/articles/Articles';
|
import Articles from '../views/home/articles/Articles';
|
||||||
import Groups from '../views/home/groups/Groups';
|
import Groups from '../views/home/groups/Groups';
|
||||||
import Contests from '../views/home/contests/Contests';
|
import Contests from '../views/home/contests/Contests';
|
||||||
import { PrimaryButton } from '../components/button/PrimaryButton';
|
import Group from '../views/home/group/Group';
|
||||||
import Group from '../views/home/groups/Group';
|
|
||||||
import Contest from '../views/home/contest/Contest';
|
import Contest from '../views/home/contest/Contest';
|
||||||
import Account from '../views/home/account/Account';
|
import Account from '../views/home/account/Account';
|
||||||
import ProtectedRoute from '../components/router/ProtectedRoute';
|
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 Home = () => {
|
||||||
const name = useAppSelector((state) => state.auth.username);
|
|
||||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
const jwt = useAppSelector((state) => state.auth.jwt);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -34,45 +36,34 @@ const Home = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="account/*" element={<Account />} />
|
<Route path="account/*" element={<Account />} />
|
||||||
|
<Route
|
||||||
|
path="group-invite/*"
|
||||||
|
element={<GroupInvite />}
|
||||||
|
/>
|
||||||
|
<Route path="group/:groupId/*" element={<Group />} />
|
||||||
|
<Route path="groups/*" element={<Groups />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="login" element={<Login />} />
|
<Route path="login" element={<Login />} />
|
||||||
<Route path="register" element={<Register />} />
|
<Route path="register" element={<Register />} />
|
||||||
<Route path="missions/*" element={<Missions />} />
|
<Route path="missions/*" element={<Missions />} />
|
||||||
<Route path="articles/*" element={<Articles />} />
|
<Route path="articles/*" element={<Articles />} />
|
||||||
<Route path="group/:groupId" element={<Group />} />
|
|
||||||
<Route path="groups/*" element={<Groups />} />
|
|
||||||
<Route path="contests/*" element={<Contests />} />
|
<Route path="contests/*" element={<Contests />} />
|
||||||
<Route path="contest/:contestId/*" element={<Contest />} />
|
<Route path="contest/:contestId/*" element={<Contest />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={<Navigate to="/home/account" replace />}
|
||||||
<>
|
|
||||||
<p>{jwt}</p>
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
if (jwt)
|
|
||||||
navigator.clipboard.writeText(jwt);
|
|
||||||
}}
|
|
||||||
text="скопировать токен"
|
|
||||||
className="pt-[20px]"
|
|
||||||
/>
|
|
||||||
<p className="py-[20px]">{name}</p>
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(logout());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
выйти
|
|
||||||
</PrimaryButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="articles/*" element={<div></div>} />
|
<Route path="articles/*" element={<ArticlesRightPanel />} />
|
||||||
|
<Route path="missions/*" element={<MissionsRightPanel />} />
|
||||||
|
<Route
|
||||||
|
path="group/:groupId/*"
|
||||||
|
element={<GroupRightPanel />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,35 @@
|
|||||||
import { useParams, Navigate } from 'react-router-dom';
|
import { useParams, Navigate, useNavigate } from 'react-router-dom';
|
||||||
import CodeEditor from '../views/mission/codeeditor/CodeEditor';
|
import CodeEditor from '../views/mission/codeeditor/CodeEditor';
|
||||||
import Statement from '../views/mission/statement/Statement';
|
import Statement from '../views/mission/statement/Statement';
|
||||||
import { PrimaryButton } from '../components/button/PrimaryButton';
|
import { PrimaryButton } from '../components/button/PrimaryButton';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
|
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 Header from '../views/mission/statement/Header';
|
||||||
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
||||||
import { useQuery } from '../hooks/useQuery';
|
import { useQuery } from '../hooks/useQuery';
|
||||||
|
import { fetchMyAttemptsInContest } from '../redux/slices/contests';
|
||||||
|
|
||||||
const Mission = () => {
|
const Mission = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Получаем параметры из URL
|
// Получаем параметры из URL
|
||||||
const { missionId } = useParams<{ missionId: string }>();
|
const { missionId } = useParams<{ missionId: string }>();
|
||||||
const mission = useAppSelector((state) => state.missions.currentMission);
|
const mission = useAppSelector((state) => state.missions.currentMission);
|
||||||
|
const missionStatus = useAppSelector(
|
||||||
|
(state) => state.missions.statuses.fetchById,
|
||||||
|
);
|
||||||
|
const attempt = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
|
||||||
|
);
|
||||||
|
|
||||||
const missionIdNumber = Number(missionId);
|
const missionIdNumber = Number(missionId);
|
||||||
|
|
||||||
const query = useQuery();
|
const query = useQuery();
|
||||||
const back = query.get('back') ?? undefined;
|
const back = query.get('back') ?? undefined;
|
||||||
|
const contestId = Number(query.get('contestId') ?? undefined);
|
||||||
|
|
||||||
if (!missionId || isNaN(missionIdNumber)) {
|
if (!missionId || isNaN(missionIdNumber)) {
|
||||||
if (back) return <Navigate to={back} replace />;
|
if (back) return <Navigate to={back} replace />;
|
||||||
@@ -39,6 +49,9 @@ const Mission = () => {
|
|||||||
if (pollingRef.current) return;
|
if (pollingRef.current) return;
|
||||||
|
|
||||||
pollingRef.current = setInterval(async () => {
|
pollingRef.current = setInterval(async () => {
|
||||||
|
if (contestId) {
|
||||||
|
dispatch(fetchMyAttemptsInContest(contestId));
|
||||||
|
}
|
||||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||||
|
|
||||||
const hasWaiting = submissionsRef.current.some(
|
const hasWaiting = submissionsRef.current.some(
|
||||||
@@ -58,6 +71,12 @@ const Mission = () => {
|
|||||||
}, 5000); // 10 секунд
|
}, 5000); // 10 секунд
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contestId) {
|
||||||
|
dispatch(fetchMyAttemptsInContest(contestId));
|
||||||
|
}
|
||||||
|
}, [contestId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchMissionById(missionIdNumber));
|
dispatch(fetchMissionById(missionIdNumber));
|
||||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||||
@@ -73,6 +92,12 @@ const Mission = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (missionStatus == 'failed') {
|
||||||
|
setMissionsStatus({ key: 'fetchById', status: 'idle' });
|
||||||
|
navigate(back ?? '/home/missions');
|
||||||
|
}
|
||||||
|
}, [missionStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
submissionsRef.current = submissions;
|
submissionsRef.current = submissions;
|
||||||
@@ -148,9 +173,7 @@ const Mission = () => {
|
|||||||
html: htmlStatement.statementTexts['problem.html'],
|
html: htmlStatement.statementTexts['problem.html'],
|
||||||
mediaFiles: latexStatement.mediaFiles,
|
mediaFiles: latexStatement.mediaFiles,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
console.error('Ошибка парсинга statementTexts:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||||
@@ -185,7 +208,8 @@ const Mission = () => {
|
|||||||
language: language,
|
language: language,
|
||||||
languageVersion: 'latest',
|
languageVersion: 'latest',
|
||||||
sourceCode: code,
|
sourceCode: code,
|
||||||
contestId: null,
|
contestAttemptId:
|
||||||
|
attempt?.attemptId,
|
||||||
}),
|
}),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -198,7 +222,10 @@ const Mission = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-full w-full ">
|
<div className="h-full w-full ">
|
||||||
<MissionSubmissions missionId={missionIdNumber} />
|
<MissionSubmissions
|
||||||
|
missionId={missionIdNumber}
|
||||||
|
contestId={contestId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
|
import { toastError } from '../../lib/toastNotification';
|
||||||
|
|
||||||
// ─── Типы ────────────────────────────────────────────
|
// =====================
|
||||||
|
// Типы
|
||||||
|
// =====================
|
||||||
|
|
||||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
|
|
||||||
@@ -15,39 +18,184 @@ export interface Article {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArticlesState {
|
interface ArticlesResponse {
|
||||||
articles: Article[];
|
|
||||||
currentArticle?: Article;
|
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
statuses: {
|
articles: Article[];
|
||||||
create: Status;
|
}
|
||||||
update: Status;
|
|
||||||
delete: Status;
|
// =====================
|
||||||
fetchAll: Status;
|
// Состояние
|
||||||
fetchById: Status;
|
// =====================
|
||||||
|
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
error: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ArticlesState = {
|
const initialState: ArticlesState = {
|
||||||
|
fetchArticles: {
|
||||||
articles: [],
|
articles: [],
|
||||||
currentArticle: undefined,
|
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
statuses: {
|
status: 'idle',
|
||||||
create: 'idle',
|
error: undefined,
|
||||||
update: 'idle',
|
},
|
||||||
delete: 'idle',
|
fetchNewArticles: {
|
||||||
fetchAll: 'idle',
|
articles: [],
|
||||||
fetchById: 'idle',
|
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,
|
||||||
},
|
},
|
||||||
error: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Async Thunks ─────────────────────────────────────
|
// =====================
|
||||||
|
// Async Thunks
|
||||||
|
// =====================
|
||||||
|
|
||||||
// POST /articles
|
// Новые статьи
|
||||||
|
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(
|
export const createArticle = createAsyncThunk(
|
||||||
'articles/createArticle',
|
'articles/create',
|
||||||
async (
|
async (
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
@@ -57,23 +205,21 @@ export const createArticle = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/articles', {
|
const response = await axios.post<Article>('/articles', {
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
});
|
});
|
||||||
return response.data as Article;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при создании статьи',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// PUT /articles/{articleId}
|
// Обновление статьи
|
||||||
export const updateArticle = createAsyncThunk(
|
export const updateArticle = createAsyncThunk(
|
||||||
'articles/updateArticle',
|
'articles/update',
|
||||||
async (
|
async (
|
||||||
{
|
{
|
||||||
articleId,
|
articleId,
|
||||||
@@ -84,215 +230,221 @@ export const updateArticle = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`/articles/${articleId}`, {
|
const response = await axios.put<Article>(
|
||||||
|
`/articles/${articleId}`,
|
||||||
|
{
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
});
|
},
|
||||||
return response.data as Article;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(
|
|
||||||
err.response?.data?.message || 'Ошибка при обновлении статьи',
|
|
||||||
);
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err.response?.data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /articles/{articleId}
|
// Удаление статьи
|
||||||
export const deleteArticle = createAsyncThunk(
|
export const deleteArticle = createAsyncThunk(
|
||||||
'articles/deleteArticle',
|
'articles/delete',
|
||||||
async (articleId: number, { rejectWithValue }) => {
|
async (articleId: number, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/articles/${articleId}`);
|
await axios.delete(`/articles/${articleId}`);
|
||||||
return articleId;
|
return articleId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при удалении статьи',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /articles
|
// =====================
|
||||||
export const fetchArticles = createAsyncThunk(
|
// Slice
|
||||||
'articles/fetchArticles',
|
// =====================
|
||||||
async (
|
|
||||||
{
|
|
||||||
page = 0,
|
|
||||||
pageSize = 10,
|
|
||||||
tags,
|
|
||||||
}: { page?: number; pageSize?: number; tags?: string[] },
|
|
||||||
{ rejectWithValue },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const params: any = { page, pageSize };
|
|
||||||
if (tags && tags.length > 0) params.tags = tags;
|
|
||||||
const response = await axios.get('/articles', { params });
|
|
||||||
return response.data as {
|
|
||||||
hasNextPage: boolean;
|
|
||||||
articles: Article[];
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(
|
|
||||||
err.response?.data?.message || 'Ошибка при получении статей',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /articles/{articleId}
|
|
||||||
export const fetchArticleById = createAsyncThunk(
|
|
||||||
'articles/fetchArticleById',
|
|
||||||
async (articleId: number, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`/articles/${articleId}`);
|
|
||||||
return response.data as Article;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(
|
|
||||||
err.response?.data?.message || 'Ошибка при получении статьи',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Slice ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const articlesSlice = createSlice({
|
const articlesSlice = createSlice({
|
||||||
name: 'articles',
|
name: 'articles',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentArticle: (state) => {
|
|
||||||
state.currentArticle = undefined;
|
|
||||||
},
|
|
||||||
setArticlesStatus: (
|
setArticlesStatus: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{ key: keyof ArticlesState; status: Status }>,
|
||||||
key: keyof ArticlesState['statuses'];
|
|
||||||
status: Status;
|
|
||||||
}>,
|
|
||||||
) => {
|
) => {
|
||||||
const { key, status } = action.payload;
|
const { key, status } = action.payload;
|
||||||
state.statuses[key] = status;
|
if (state[key]) {
|
||||||
|
(state[key] as any).status = status;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// ─── CREATE ARTICLE ───
|
// fetchArticles
|
||||||
builder.addCase(createArticle.pending, (state) => {
|
|
||||||
state.statuses.create = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
createArticle.fulfilled,
|
|
||||||
(state, action: PayloadAction<Article>) => {
|
|
||||||
state.statuses.create = 'successful';
|
|
||||||
state.articles.push(action.payload);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
createArticle.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.create = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── UPDATE ARTICLE ───
|
|
||||||
builder.addCase(updateArticle.pending, (state) => {
|
|
||||||
state.statuses.update = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
updateArticle.fulfilled,
|
|
||||||
(state, action: PayloadAction<Article>) => {
|
|
||||||
state.statuses.update = 'successful';
|
|
||||||
const index = state.articles.findIndex(
|
|
||||||
(a) => a.id === action.payload.id,
|
|
||||||
);
|
|
||||||
if (index !== -1) state.articles[index] = action.payload;
|
|
||||||
if (state.currentArticle?.id === action.payload.id)
|
|
||||||
state.currentArticle = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
updateArticle.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.update = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── DELETE ARTICLE ───
|
|
||||||
builder.addCase(deleteArticle.pending, (state) => {
|
|
||||||
state.statuses.delete = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
deleteArticle.fulfilled,
|
|
||||||
(state, action: PayloadAction<number>) => {
|
|
||||||
state.statuses.delete = 'successful';
|
|
||||||
state.articles = state.articles.filter(
|
|
||||||
(a) => a.id !== action.payload,
|
|
||||||
);
|
|
||||||
if (state.currentArticle?.id === action.payload)
|
|
||||||
state.currentArticle = undefined;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
deleteArticle.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.delete = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── FETCH ARTICLES ───
|
|
||||||
builder.addCase(fetchArticles.pending, (state) => {
|
builder.addCase(fetchArticles.pending, (state) => {
|
||||||
state.statuses.fetchAll = 'loading';
|
state.fetchArticles.status = 'loading';
|
||||||
state.error = null;
|
state.fetchArticles.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchArticles.fulfilled,
|
fetchArticles.fulfilled,
|
||||||
(
|
(state, action: PayloadAction<ArticlesResponse>) => {
|
||||||
state,
|
state.fetchArticles.status = 'successful';
|
||||||
action: PayloadAction<{
|
state.fetchArticles.articles = action.payload.articles;
|
||||||
hasNextPage: boolean;
|
state.fetchArticles.hasNextPage = action.payload.hasNextPage;
|
||||||
articles: Article[];
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
state.statuses.fetchAll = 'successful';
|
|
||||||
state.articles = action.payload.articles;
|
|
||||||
state.hasNextPage = action.payload.hasNextPage;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchArticles.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.fetchAll = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── FETCH ARTICLE BY ID ───
|
// 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) => {
|
builder.addCase(fetchArticleById.pending, (state) => {
|
||||||
state.statuses.fetchById = 'loading';
|
state.fetchArticleById.status = 'loading';
|
||||||
state.error = null;
|
state.fetchArticleById.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchArticleById.fulfilled,
|
fetchArticleById.fulfilled,
|
||||||
(state, action: PayloadAction<Article>) => {
|
(state, action: PayloadAction<Article>) => {
|
||||||
state.statuses.fetchById = 'successful';
|
state.fetchArticleById.status = 'successful';
|
||||||
state.currentArticle = action.payload;
|
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(
|
builder.addCase(
|
||||||
fetchArticleById.rejected,
|
createArticle.fulfilled,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<Article>) => {
|
||||||
state.statuses.fetchById = 'failed';
|
state.createArticle.status = 'successful';
|
||||||
state.error = action.payload;
|
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 { clearCurrentArticle, setArticlesStatus } = articlesSlice.actions;
|
export const { setArticlesStatus } = articlesSlice.actions;
|
||||||
export const articlesReducer = articlesSlice.reducer;
|
export const articlesReducer = articlesSlice.reducer;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
|
import { toastError } from '../../lib/toastNotification';
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
|
|
||||||
// 🔹 Декодирование JWT
|
// 🔹 Декодирование JWT
|
||||||
function decodeJwt(token: string) {
|
function decodeJwt(token: string) {
|
||||||
@@ -15,8 +18,12 @@ interface AuthState {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
status: 'idle' | 'loading' | 'successful' | 'failed';
|
status: Status;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
register: {
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
status: Status;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
|
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
|
||||||
@@ -31,6 +38,9 @@ const initialState: AuthState = {
|
|||||||
id: null,
|
id: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
|
register: {
|
||||||
|
status: 'idle',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Если токен есть, подставляем в axios и декодируем
|
// Если токен есть, подставляем в axios и декодируем
|
||||||
@@ -76,9 +86,7 @@ export const registerUser = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data?.errors ? err.response?.data?.errors : {"error" : [err.response?.data]});
|
||||||
err.response?.data?.message || 'Registration failed',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -121,11 +129,26 @@ export const refreshToken = createAsyncThunk(
|
|||||||
|
|
||||||
export const fetchWhoAmI = createAsyncThunk(
|
export const fetchWhoAmI = createAsyncThunk(
|
||||||
'auth/whoami',
|
'auth/whoami',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { dispatch, getState, rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/authentication/whoami');
|
const response = await axios.get('/authentication/whoami');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
const state: any = getState();
|
||||||
|
const refresh = state.auth.refreshToken;
|
||||||
|
|
||||||
|
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(
|
return rejectWithValue(
|
||||||
err.response?.data?.message || 'Failed to fetch user info',
|
err.response?.data?.message || 'Failed to fetch user info',
|
||||||
);
|
);
|
||||||
@@ -150,6 +173,15 @@ const authSlice = createSlice({
|
|||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
delete axios.defaults.headers.common['Authorization'];
|
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) => {
|
extraReducers: (builder) => {
|
||||||
// ----------------- Register -----------------
|
// ----------------- Register -----------------
|
||||||
@@ -184,7 +216,12 @@ const authSlice = createSlice({
|
|||||||
});
|
});
|
||||||
builder.addCase(registerUser.rejected, (state, action) => {
|
builder.addCase(registerUser.rejected, (state, action) => {
|
||||||
state.status = 'failed';
|
state.status = 'failed';
|
||||||
state.error = action.payload as string;
|
state.register.errors = action.payload as Record<string, string[]>;
|
||||||
|
Object.values(state.register.errors).forEach((messages) => {
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
toastError(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------- Login -----------------
|
// ----------------- Login -----------------
|
||||||
@@ -269,9 +306,25 @@ const authSlice = createSlice({
|
|||||||
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
|
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
|
||||||
state.status = 'failed';
|
state.status = 'failed';
|
||||||
state.error = action.payload as string;
|
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;
|
export const authReducer = authSlice.reducer;
|
||||||
|
|||||||
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;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
|
import { toastError } from '../../lib/toastNotification';
|
||||||
|
|
||||||
// ─── Типы ────────────────────────────────────────────
|
// =====================
|
||||||
|
// Типы
|
||||||
|
// =====================
|
||||||
|
|
||||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
|
|
||||||
@@ -19,39 +22,106 @@ export interface Group {
|
|||||||
contests: any[];
|
contests: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Состояние
|
||||||
|
// =====================
|
||||||
|
|
||||||
interface GroupsState {
|
interface GroupsState {
|
||||||
|
fetchMyGroups: {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
currentGroup: Group | null;
|
status: Status;
|
||||||
statuses: {
|
error?: string;
|
||||||
create: Status;
|
};
|
||||||
update: Status;
|
fetchGroupById: {
|
||||||
delete: Status;
|
group?: Group;
|
||||||
fetchMy: Status;
|
status: Status;
|
||||||
fetchById: Status;
|
error?: string;
|
||||||
addMember: Status;
|
};
|
||||||
removeMember: Status;
|
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;
|
||||||
};
|
};
|
||||||
error: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: GroupsState = {
|
const initialState: GroupsState = {
|
||||||
|
fetchMyGroups: {
|
||||||
groups: [],
|
groups: [],
|
||||||
currentGroup: null,
|
status: 'idle',
|
||||||
statuses: {
|
error: undefined,
|
||||||
create: 'idle',
|
},
|
||||||
update: 'idle',
|
fetchGroupById: {
|
||||||
delete: 'idle',
|
group: undefined,
|
||||||
fetchMy: 'idle',
|
status: 'idle',
|
||||||
fetchById: 'idle',
|
error: undefined,
|
||||||
addMember: 'idle',
|
},
|
||||||
removeMember: 'idle',
|
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,
|
||||||
},
|
},
|
||||||
error: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Async Thunks ─────────────────────────────────────
|
// =====================
|
||||||
|
// Async Thunks
|
||||||
|
// =====================
|
||||||
|
|
||||||
// POST /groups
|
|
||||||
export const createGroup = createAsyncThunk(
|
export const createGroup = createAsyncThunk(
|
||||||
'groups/createGroup',
|
'groups/createGroup',
|
||||||
async (
|
async (
|
||||||
@@ -62,14 +132,11 @@ export const createGroup = createAsyncThunk(
|
|||||||
const response = await axios.post('/groups', { name, description });
|
const response = await axios.post('/groups', { name, description });
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при создании группы',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// PUT /groups/{groupId}
|
|
||||||
export const updateGroup = createAsyncThunk(
|
export const updateGroup = createAsyncThunk(
|
||||||
'groups/updateGroup',
|
'groups/updateGroup',
|
||||||
async (
|
async (
|
||||||
@@ -87,14 +154,11 @@ export const updateGroup = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при обновлении группы',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /groups/{groupId}
|
|
||||||
export const deleteGroup = createAsyncThunk(
|
export const deleteGroup = createAsyncThunk(
|
||||||
'groups/deleteGroup',
|
'groups/deleteGroup',
|
||||||
async (groupId: number, { rejectWithValue }) => {
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
@@ -102,14 +166,11 @@ export const deleteGroup = createAsyncThunk(
|
|||||||
await axios.delete(`/groups/${groupId}`);
|
await axios.delete(`/groups/${groupId}`);
|
||||||
return groupId;
|
return groupId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при удалении группы',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /groups/my
|
|
||||||
export const fetchMyGroups = createAsyncThunk(
|
export const fetchMyGroups = createAsyncThunk(
|
||||||
'groups/fetchMyGroups',
|
'groups/fetchMyGroups',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { rejectWithValue }) => {
|
||||||
@@ -117,14 +178,11 @@ export const fetchMyGroups = createAsyncThunk(
|
|||||||
const response = await axios.get('/groups/my');
|
const response = await axios.get('/groups/my');
|
||||||
return response.data.groups as Group[];
|
return response.data.groups as Group[];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при получении групп',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /groups/{groupId}
|
|
||||||
export const fetchGroupById = createAsyncThunk(
|
export const fetchGroupById = createAsyncThunk(
|
||||||
'groups/fetchGroupById',
|
'groups/fetchGroupById',
|
||||||
async (groupId: number, { rejectWithValue }) => {
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
@@ -132,33 +190,33 @@ export const fetchGroupById = createAsyncThunk(
|
|||||||
const response = await axios.get(`/groups/${groupId}`);
|
const response = await axios.get(`/groups/${groupId}`);
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при получении группы',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /groups/members
|
|
||||||
export const addGroupMember = createAsyncThunk(
|
export const addGroupMember = createAsyncThunk(
|
||||||
'groups/addGroupMember',
|
'groups/addGroupMember',
|
||||||
async (
|
async (
|
||||||
{ userId, role }: { userId: number; role: string },
|
{
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
}: { groupId: number; userId: number; role: string },
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/groups/members', { userId, role });
|
const response = await axios.post(`/groups/${groupId}/members`, {
|
||||||
return { userId, role };
|
userId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message ||
|
|
||||||
'Ошибка при добавлении участника',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /groups/{groupId}/members/{memberId}
|
|
||||||
export const removeGroupMember = createAsyncThunk(
|
export const removeGroupMember = createAsyncThunk(
|
||||||
'groups/removeGroupMember',
|
'groups/removeGroupMember',
|
||||||
async (
|
async (
|
||||||
@@ -169,154 +227,204 @@ export const removeGroupMember = createAsyncThunk(
|
|||||||
await axios.delete(`/groups/${groupId}/members/${memberId}`);
|
await axios.delete(`/groups/${groupId}/members/${memberId}`);
|
||||||
return { groupId, memberId };
|
return { groupId, memberId };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при удалении участника',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Slice ────────────────────────────────────────────
|
// =====================
|
||||||
|
// Новые 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({
|
const groupsSlice = createSlice({
|
||||||
name: 'groups',
|
name: 'groups',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentGroup: (state) => {
|
setGroupsStatus: (
|
||||||
state.currentGroup = null;
|
state,
|
||||||
|
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
|
||||||
|
) => {
|
||||||
|
const { key, status } = action.payload;
|
||||||
|
if (state[key]) {
|
||||||
|
(state[key] as any).status = status;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// ─── CREATE GROUP ───
|
// fetchMyGroups
|
||||||
builder.addCase(createGroup.pending, (state) => {
|
|
||||||
state.statuses.create = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
createGroup.fulfilled,
|
|
||||||
(state, action: PayloadAction<Group>) => {
|
|
||||||
state.statuses.create = 'successful';
|
|
||||||
state.groups.push(action.payload);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
createGroup.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.create = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── UPDATE GROUP ───
|
|
||||||
builder.addCase(updateGroup.pending, (state) => {
|
|
||||||
state.statuses.update = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
updateGroup.fulfilled,
|
|
||||||
(state, action: PayloadAction<Group>) => {
|
|
||||||
state.statuses.update = 'successful';
|
|
||||||
const index = state.groups.findIndex(
|
|
||||||
(g) => g.id === action.payload.id,
|
|
||||||
);
|
|
||||||
if (index !== -1) state.groups[index] = action.payload;
|
|
||||||
if (state.currentGroup?.id === action.payload.id) {
|
|
||||||
state.currentGroup = action.payload;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
updateGroup.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.update = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── DELETE GROUP ───
|
|
||||||
builder.addCase(deleteGroup.pending, (state) => {
|
|
||||||
state.statuses.delete = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
deleteGroup.fulfilled,
|
|
||||||
(state, action: PayloadAction<number>) => {
|
|
||||||
state.statuses.delete = 'successful';
|
|
||||||
state.groups = state.groups.filter(
|
|
||||||
(g) => g.id !== action.payload,
|
|
||||||
);
|
|
||||||
if (state.currentGroup?.id === action.payload)
|
|
||||||
state.currentGroup = null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
deleteGroup.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.delete = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── FETCH MY GROUPS ───
|
|
||||||
builder.addCase(fetchMyGroups.pending, (state) => {
|
builder.addCase(fetchMyGroups.pending, (state) => {
|
||||||
state.statuses.fetchMy = 'loading';
|
state.fetchMyGroups.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchMyGroups.fulfilled,
|
fetchMyGroups.fulfilled,
|
||||||
(state, action: PayloadAction<Group[]>) => {
|
(state, action: PayloadAction<Group[]>) => {
|
||||||
state.statuses.fetchMy = 'successful';
|
state.fetchMyGroups.status = 'successful';
|
||||||
state.groups = action.payload;
|
state.fetchMyGroups.groups = action.payload;
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchMyGroups.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.fetchMy = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
|
||||||
|
state.fetchMyGroups.status = 'failed';
|
||||||
|
|
||||||
// ─── FETCH GROUP BY ID ───
|
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) => {
|
builder.addCase(fetchGroupById.pending, (state) => {
|
||||||
state.statuses.fetchById = 'loading';
|
state.fetchGroupById.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchGroupById.fulfilled,
|
fetchGroupById.fulfilled,
|
||||||
(state, action: PayloadAction<Group>) => {
|
(state, action: PayloadAction<Group>) => {
|
||||||
state.statuses.fetchById = 'successful';
|
state.fetchGroupById.status = 'successful';
|
||||||
state.currentGroup = action.payload;
|
state.fetchGroupById.group = action.payload;
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchGroupById.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.fetchById = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
|
||||||
|
state.fetchGroupById.status = 'failed';
|
||||||
|
|
||||||
// ─── ADD MEMBER ───
|
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) => {
|
builder.addCase(addGroupMember.pending, (state) => {
|
||||||
state.statuses.addMember = 'loading';
|
state.addGroupMember.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(addGroupMember.fulfilled, (state) => {
|
builder.addCase(addGroupMember.fulfilled, (state) => {
|
||||||
state.statuses.addMember = 'successful';
|
state.addGroupMember.status = 'successful';
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(addGroupMember.rejected, (state, action: any) => {
|
||||||
addGroupMember.rejected,
|
state.addGroupMember.status = 'failed';
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.addMember = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── REMOVE MEMBER ───
|
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) => {
|
builder.addCase(removeGroupMember.pending, (state) => {
|
||||||
state.statuses.removeMember = 'loading';
|
state.removeGroupMember.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
removeGroupMember.fulfilled,
|
removeGroupMember.fulfilled,
|
||||||
@@ -324,27 +432,78 @@ const groupsSlice = createSlice({
|
|||||||
state,
|
state,
|
||||||
action: PayloadAction<{ groupId: number; memberId: number }>,
|
action: PayloadAction<{ groupId: number; memberId: number }>,
|
||||||
) => {
|
) => {
|
||||||
state.statuses.removeMember = 'successful';
|
state.removeGroupMember.status = 'successful';
|
||||||
if (
|
if (
|
||||||
state.currentGroup &&
|
state.fetchGroupById.group &&
|
||||||
state.currentGroup.id === action.payload.groupId
|
state.fetchGroupById.group.id === action.payload.groupId
|
||||||
) {
|
) {
|
||||||
state.currentGroup.members =
|
state.fetchGroupById.group.members =
|
||||||
state.currentGroup.members.filter(
|
state.fetchGroupById.group.members.filter(
|
||||||
(m) => m.userId !== action.payload.memberId,
|
(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(
|
builder.addCase(
|
||||||
removeGroupMember.rejected,
|
fetchGroupJoinLink.fulfilled,
|
||||||
(state, action: PayloadAction<any>) => {
|
(
|
||||||
state.statuses.removeMember = 'failed';
|
state,
|
||||||
state.error = action.payload;
|
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 { clearCurrentGroup } = groupsSlice.actions;
|
export const { setGroupsStatus } = groupsSlice.actions;
|
||||||
export const groupsReducer = groupsSlice.reducer;
|
export const groupsReducer = groupsSlice.reducer;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
|
import { toastError } from '../../lib/toastNotification';
|
||||||
|
import {
|
||||||
|
buildUploadMissionFormData,
|
||||||
|
getProblemXmlMissingNameMessage,
|
||||||
|
UploadMissionRequest,
|
||||||
|
} from '../../api/missionsUpload';
|
||||||
|
|
||||||
// ─── Типы ────────────────────────────────────────────
|
// ─── Типы ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -20,17 +26,25 @@ export interface Mission {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
timeLimit: number;
|
||||||
|
memoryLimit: number;
|
||||||
statements?: Statement[];
|
statements?: Statement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MissionsState {
|
interface MissionsState {
|
||||||
missions: Mission[];
|
missions: Mission[];
|
||||||
|
newMissions: Mission[];
|
||||||
currentMission: Mission | null;
|
currentMission: Mission | null;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
|
create: {
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
};
|
||||||
statuses: {
|
statuses: {
|
||||||
fetchList: Status;
|
fetchList: Status;
|
||||||
fetchById: Status;
|
fetchById: Status;
|
||||||
upload: Status;
|
upload: Status;
|
||||||
|
fetchMy: Status;
|
||||||
|
delete: Status;
|
||||||
};
|
};
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -39,12 +53,16 @@ interface MissionsState {
|
|||||||
|
|
||||||
const initialState: MissionsState = {
|
const initialState: MissionsState = {
|
||||||
missions: [],
|
missions: [],
|
||||||
|
newMissions: [],
|
||||||
currentMission: null,
|
currentMission: null,
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
|
create: {},
|
||||||
statuses: {
|
statuses: {
|
||||||
fetchList: 'idle',
|
fetchList: 'idle',
|
||||||
fetchById: 'idle',
|
fetchById: 'idle',
|
||||||
upload: 'idle',
|
upload: 'idle',
|
||||||
|
fetchMy: 'idle',
|
||||||
|
delete: 'idle',
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -54,6 +72,33 @@ const initialState: MissionsState = {
|
|||||||
// GET /missions
|
// GET /missions
|
||||||
export const fetchMissions = createAsyncThunk(
|
export const fetchMissions = createAsyncThunk(
|
||||||
'missions/fetchMissions',
|
'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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /missions
|
||||||
|
export const fetchNewMissions = createAsyncThunk(
|
||||||
|
'missions/fetchNewMissions',
|
||||||
async (
|
async (
|
||||||
{
|
{
|
||||||
page = 0,
|
page = 0,
|
||||||
@@ -65,12 +110,15 @@ export const fetchMissions = createAsyncThunk(
|
|||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (tags.length) params.tags = tags;
|
if (tags.length) params.tags = tags;
|
||||||
const response = await axios.get('/missions', { params });
|
const response = await axios.get('/missions', {
|
||||||
|
params,
|
||||||
|
paramsSerializer: {
|
||||||
|
indexes: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
return response.data; // { missions, hasNextPage }
|
return response.data; // { missions, hasNextPage }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при получении миссий',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -83,9 +131,20 @@ export const fetchMissionById = createAsyncThunk(
|
|||||||
const response = await axios.get(`/missions/${id}`);
|
const response = await axios.get(`/missions/${id}`);
|
||||||
return response.data; // Mission
|
return response.data; // Mission
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(err.response?.data);
|
||||||
err.response?.data?.message || 'Ошибка при получении миссии',
|
}
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ 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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -94,29 +153,58 @@ export const fetchMissionById = createAsyncThunk(
|
|||||||
export const uploadMission = createAsyncThunk(
|
export const uploadMission = createAsyncThunk(
|
||||||
'missions/uploadMission',
|
'missions/uploadMission',
|
||||||
async (
|
async (
|
||||||
{
|
request: UploadMissionRequest,
|
||||||
file,
|
|
||||||
name,
|
|
||||||
difficulty,
|
|
||||||
tags,
|
|
||||||
}: { file: File; name: string; difficulty: number; tags: string[] },
|
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = buildUploadMissionFormData(request);
|
||||||
formData.append('MissionFile', file);
|
|
||||||
formData.append('Name', name);
|
|
||||||
formData.append('Difficulty', difficulty.toString());
|
|
||||||
tags.forEach((tag) => formData.append('Tags', tag));
|
|
||||||
|
|
||||||
const response = await axios.post('/missions/upload', formData, {
|
const response = await axios.post('/missions/upload', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
return response.data; // Mission
|
return response.data; // Mission
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
const status = err?.response?.status;
|
||||||
err.response?.data?.message || 'Ошибка при загрузке миссии',
|
const responseData = err?.response?.data;
|
||||||
);
|
|
||||||
|
if (status === 400) {
|
||||||
|
const msg = getProblemXmlMissingNameMessage(responseData);
|
||||||
|
if (msg) {
|
||||||
|
return rejectWithValue({
|
||||||
|
errors: {
|
||||||
|
missionFile: [msg],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData?.errors) {
|
||||||
|
return rejectWithValue(responseData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback =
|
||||||
|
typeof responseData === 'string'
|
||||||
|
? responseData
|
||||||
|
: 'Не удалось загрузить миссию';
|
||||||
|
|
||||||
|
return rejectWithValue({
|
||||||
|
errors: {
|
||||||
|
general: [fallback],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -127,9 +215,6 @@ const missionsSlice = createSlice({
|
|||||||
name: 'missions',
|
name: 'missions',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentMission: (state) => {
|
|
||||||
state.currentMission = null;
|
|
||||||
},
|
|
||||||
setMissionsStatus: (
|
setMissionsStatus: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
@@ -165,7 +250,52 @@ const missionsSlice = createSlice({
|
|||||||
fetchMissions.rejected,
|
fetchMissions.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.fetchList = 'failed';
|
state.statuses.fetchList = 'failed';
|
||||||
state.error = action.payload;
|
|
||||||
|
const errors = action.payload.errors as Record<
|
||||||
|
string,
|
||||||
|
string[]
|
||||||
|
>;
|
||||||
|
Object.values(errors).forEach((messages) => {
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
toastError(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,7 +315,45 @@ const missionsSlice = createSlice({
|
|||||||
fetchMissionById.rejected,
|
fetchMissionById.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.fetchById = 'failed';
|
state.statuses.fetchById = 'failed';
|
||||||
state.error = action.payload;
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -205,11 +373,59 @@ const missionsSlice = createSlice({
|
|||||||
uploadMission.rejected,
|
uploadMission.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.upload = 'failed';
|
state.statuses.upload = 'failed';
|
||||||
state.error = action.payload;
|
|
||||||
|
const errors = action.payload?.errors as
|
||||||
|
| Record<string, string[]>
|
||||||
|
| undefined;
|
||||||
|
if (errors) {
|
||||||
|
Object.values(errors).forEach((messages) => {
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
toastError(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
state.create.errors = errors;
|
||||||
|
} else {
|
||||||
|
toastError('Не удалось загрузить миссию');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 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 { clearCurrentMission, setMissionsStatus } = missionsSlice.actions;
|
export const { setMissionsStatus } = missionsSlice.actions;
|
||||||
export const missionsReducer = missionsSlice.reducer;
|
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;
|
||||||
@@ -5,6 +5,22 @@ interface StorState {
|
|||||||
menu: {
|
menu: {
|
||||||
activePage: string;
|
activePage: string;
|
||||||
activeProfilePage: string;
|
activeProfilePage: string;
|
||||||
|
activeGroupPage: string;
|
||||||
|
};
|
||||||
|
group: {
|
||||||
|
groupFilter: string;
|
||||||
|
};
|
||||||
|
articles: {
|
||||||
|
articleTagFilter: string[];
|
||||||
|
filterName: string;
|
||||||
|
};
|
||||||
|
contests: {
|
||||||
|
contestsTagFilter: string[];
|
||||||
|
filterName: string;
|
||||||
|
};
|
||||||
|
missions: {
|
||||||
|
missionsTagFilter: string[];
|
||||||
|
filterName: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +29,22 @@ const initialState: StorState = {
|
|||||||
menu: {
|
menu: {
|
||||||
activePage: '',
|
activePage: '',
|
||||||
activeProfilePage: '',
|
activeProfilePage: '',
|
||||||
|
activeGroupPage: '',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
groupFilter: '',
|
||||||
|
},
|
||||||
|
articles: {
|
||||||
|
articleTagFilter: [],
|
||||||
|
filterName: '',
|
||||||
|
},
|
||||||
|
contests: {
|
||||||
|
contestsTagFilter: [],
|
||||||
|
filterName: '',
|
||||||
|
},
|
||||||
|
missions: {
|
||||||
|
missionsTagFilter: [],
|
||||||
|
filterName: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,18 +53,63 @@ const storeSlice = createSlice({
|
|||||||
name: 'store',
|
name: 'store',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
|
setMenuActivePage: (state, action: PayloadAction<string>) => {
|
||||||
state.menu.activePage = activePage.payload;
|
state.menu.activePage = action.payload;
|
||||||
},
|
},
|
||||||
setMenuActiveProfilePage: (
|
setMenuActiveProfilePage: (state, action: PayloadAction<string>) => {
|
||||||
state,
|
state.menu.activeProfilePage = action.payload;
|
||||||
activeProfilePage: PayloadAction<string>,
|
},
|
||||||
) => {
|
setMenuActiveGroupPage: (state, action: PayloadAction<string>) => {
|
||||||
state.menu.activeProfilePage = activeProfilePage.payload;
|
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, setMenuActiveProfilePage } =
|
export const {
|
||||||
storeSlice.actions;
|
// menu
|
||||||
|
setMenuActivePage,
|
||||||
|
setMenuActiveProfilePage,
|
||||||
|
setMenuActiveGroupPage,
|
||||||
|
setGroupFilter,
|
||||||
|
|
||||||
|
// articles
|
||||||
|
setArticlesTagFilter,
|
||||||
|
setArticlesNameFilter,
|
||||||
|
|
||||||
|
// contests
|
||||||
|
setContestsTagFilter,
|
||||||
|
setContestsNameFilter,
|
||||||
|
|
||||||
|
// missions
|
||||||
|
setMissionsTagFilter,
|
||||||
|
setMissionsNameFilter,
|
||||||
|
} = storeSlice.actions;
|
||||||
|
|
||||||
export const storeReducer = storeSlice.reducer;
|
export const storeReducer = storeSlice.reducer;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Submit {
|
|||||||
language: string;
|
language: string;
|
||||||
languageVersion: string;
|
languageVersion: string;
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
contestId: number | null;
|
contestAttemptId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Solution {
|
export interface Solution {
|
||||||
@@ -30,8 +30,8 @@ export interface MissionSubmit {
|
|||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
solution: Solution;
|
solution: Solution;
|
||||||
contestId: number | null;
|
contestId?: number;
|
||||||
contestName: string | null;
|
contestName?: string;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ interface SubmitState {
|
|||||||
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
|
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
|
||||||
currentSubmit?: Submit;
|
currentSubmit?: Submit;
|
||||||
status: 'idle' | 'loading' | 'successful' | 'failed';
|
status: 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
error: string | null;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Начальное состояние
|
// Начальное состояние
|
||||||
@@ -49,7 +49,7 @@ const initialState: SubmitState = {
|
|||||||
submitsById: {}, // ✅ инициализация
|
submitsById: {}, // ✅ инициализация
|
||||||
currentSubmit: undefined,
|
currentSubmit: undefined,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// AsyncThunk: Отправка решения
|
// AsyncThunk: Отправка решения
|
||||||
@@ -123,7 +123,7 @@ const submitSlice = createSlice({
|
|||||||
clearCurrentSubmit: (state) => {
|
clearCurrentSubmit: (state) => {
|
||||||
state.currentSubmit = undefined;
|
state.currentSubmit = undefined;
|
||||||
state.status = 'idle';
|
state.status = 'idle';
|
||||||
state.error = null;
|
state.error = undefined;
|
||||||
},
|
},
|
||||||
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
|
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
|
||||||
delete state.submitsById[action.payload];
|
delete state.submitsById[action.payload];
|
||||||
@@ -133,7 +133,7 @@ const submitSlice = createSlice({
|
|||||||
// Отправка решения
|
// Отправка решения
|
||||||
builder.addCase(submitMission.pending, (state) => {
|
builder.addCase(submitMission.pending, (state) => {
|
||||||
state.status = 'loading';
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
submitMission.fulfilled,
|
submitMission.fulfilled,
|
||||||
@@ -153,7 +153,7 @@ const submitSlice = createSlice({
|
|||||||
// Получить все свои отправки
|
// Получить все свои отправки
|
||||||
builder.addCase(fetchMySubmits.pending, (state) => {
|
builder.addCase(fetchMySubmits.pending, (state) => {
|
||||||
state.status = 'loading';
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchMySubmits.fulfilled,
|
fetchMySubmits.fulfilled,
|
||||||
@@ -173,7 +173,7 @@ const submitSlice = createSlice({
|
|||||||
// Получить отправку по ID
|
// Получить отправку по ID
|
||||||
builder.addCase(fetchSubmitById.pending, (state) => {
|
builder.addCase(fetchSubmitById.pending, (state) => {
|
||||||
state.status = 'loading';
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchSubmitById.fulfilled,
|
fetchSubmitById.fulfilled,
|
||||||
@@ -193,7 +193,7 @@ const submitSlice = createSlice({
|
|||||||
// ✅ Получить отправки по миссии
|
// ✅ Получить отправки по миссии
|
||||||
builder.addCase(fetchMySubmitsByMission.pending, (state) => {
|
builder.addCase(fetchMySubmitsByMission.pending, (state) => {
|
||||||
state.status = 'loading';
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchMySubmitsByMission.fulfilled,
|
fetchMySubmitsByMission.fulfilled,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { submitReducer } from './slices/submit';
|
|||||||
import { contestsReducer } from './slices/contests';
|
import { contestsReducer } from './slices/contests';
|
||||||
import { groupsReducer } from './slices/groups';
|
import { groupsReducer } from './slices/groups';
|
||||||
import { articlesReducer } from './slices/articles';
|
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';
|
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
@@ -25,6 +28,9 @@ export const store = configureStore({
|
|||||||
contests: contestsReducer,
|
contests: contestsReducer,
|
||||||
groups: groupsReducer,
|
groups: groupsReducer,
|
||||||
articles: articlesReducer,
|
articles: articlesReducer,
|
||||||
|
groupfeed: groupFeedReducer,
|
||||||
|
groupchat: groupChatReducer,
|
||||||
|
profile: profileReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@import 'tailwindcss/utilities';
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
@import './latex-container.css';
|
@import './latex-container.css';
|
||||||
|
@import './toast.css';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
|
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -4,18 +4,7 @@ import 'highlight.js/styles/github-dark.css';
|
|||||||
|
|
||||||
import MarkdownPreview from './MarckDownPreview';
|
import MarkdownPreview from './MarckDownPreview';
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
export const MarkDownPattern = `# 🌙 Добро пожаловать в Markdown-редактор
|
||||||
defaultValue?: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
|
||||||
defaultValue,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const [markdown, setMarkdown] = useState<string>(
|
|
||||||
defaultValue ||
|
|
||||||
`# 🌙 Добро пожаловать в Markdown-редактор
|
|
||||||
|
|
||||||
Добро пожаловать в **Markdown-редактор**!
|
Добро пожаловать в **Markdown-редактор**!
|
||||||
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
|
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
|
||||||
@@ -79,14 +68,14 @@ function greet(user: User) {
|
|||||||
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
|
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(greet({ name: "Ты", role: "Разработчик" }));
|
consol.log(greet({ name: "Ты", role: "Разработчик" }));
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Пример **JavaScript**:
|
Пример **JavaScript**:
|
||||||
|
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
const sum = (a, b) => a + b;
|
const sum = (a, b) => a + b;
|
||||||
console.log(sum(2, 3)); // 5
|
consol.log(sum(2, 3)); // 5
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Пример **Python**:
|
Пример **Python**:
|
||||||
@@ -209,13 +198,29 @@ print(greet("Мир"))
|
|||||||
|
|
||||||
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
|
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
|
||||||
|
|
||||||
`,
|
`;
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [markdown, setMarkdown] = useState<string>(
|
||||||
|
defaultValue || MarkDownPattern,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange(markdown);
|
onChange(markdown);
|
||||||
}, [markdown]);
|
}, [markdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMarkdown(defaultValue || MarkDownPattern);
|
||||||
|
}, [defaultValue]);
|
||||||
|
|
||||||
// Обработчик вставки
|
// Обработчик вставки
|
||||||
const handlePaste = async (
|
const handlePaste = async (
|
||||||
e: React.ClipboardEvent<HTMLTextAreaElement>,
|
e: React.ClipboardEvent<HTMLTextAreaElement>,
|
||||||
@@ -251,9 +256,7 @@ print(greet("Мир"))
|
|||||||
markdown.slice(cursorPos);
|
markdown.slice(cursorPos);
|
||||||
|
|
||||||
setMarkdown(newText);
|
setMarkdown(newText);
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
console.error('Ошибка загрузки изображения:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ const MarkdownPreview: FC<MarkdownPreviewProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('flex-1 bg-[#161b22] rounded-lg p-6', className)}>
|
||||||
className={cn(
|
|
||||||
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
|
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
|||||||
@@ -1,22 +1,54 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import AccountMenu from './AccoutMenu';
|
import AccountMenu from './AccoutMenu';
|
||||||
import RightPanel from './RightPanel';
|
import RightPanel from './RightPanel';
|
||||||
import MissionsBlock from './MissionsBlock';
|
import Missions from './missions/Missions';
|
||||||
import ContestsBlock from './ContestsBlock';
|
import Contests from './contests/Contests';
|
||||||
import ArticlesBlock from './ArticlesBlock';
|
import ArticlesBlock from './articles/ArticlesBlock';
|
||||||
import { useAppDispatch } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
import {
|
||||||
|
fetchProfile,
|
||||||
|
fetchProfileArticles,
|
||||||
|
fetchProfileContests,
|
||||||
|
fetchProfileMissions,
|
||||||
|
} from '../../../redux/slices/profile';
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (username == myname) {
|
||||||
dispatch(setMenuActivePage('account'));
|
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 (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,520px] relative divide-x-[1px] divide-liquid-lighter">
|
<div className="h-full 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 min-h-0 flex flex-col">
|
||||||
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
@@ -24,18 +56,12 @@ const Account = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px] ">
|
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px] ">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="missions" element={<Missions />} />
|
||||||
path="missions"
|
|
||||||
element={<MissionsBlock />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="articles"
|
path="articles"
|
||||||
element={<ArticlesBlock />}
|
element={<ArticlesBlock />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="contests" element={<Contests />} />
|
||||||
path="contests"
|
|
||||||
element={<ContestsBlock />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ const AccountMenu = () => {
|
|||||||
(state) => state.store.menu.activeProfilePage,
|
(state) => state.store.menu.activeProfilePage,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('active', [activeProfilePage]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative flex p-[20px] gap-[10px]">
|
<div className="h-full w-full relative flex p-[20px] gap-[10px]">
|
||||||
{menuItems.map((v, i) => (
|
{menuItems.map((v, i) => (
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|
||||||
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
|
|
||||||
import { cn } from '../../../lib/cn';
|
|
||||||
import { ChevroneDown, Edit } from '../../../assets/icons/groups';
|
|
||||||
import { fetchArticles } from '../../../redux/slices/articles';
|
|
||||||
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
|
|
||||||
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
|
||||||
'gap-[20px] px-[20px] py-[10px] box-border ',
|
|
||||||
'border-b-[1px] border-b-liquid-lighter cursor-pointer hover:bg-liquid-lighter transition-all duration-300',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/article/${id}?back=/home/account/articles`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="h-[23px] flex ">
|
|
||||||
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
|
|
||||||
#{id}
|
|
||||||
</div>
|
|
||||||
<div className="text-[18px] font-bold flex items-center">
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
|
||||||
{tags.map((v, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
|
||||||
v == 'Sertificated' && 'text-liquid-green',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
|
||||||
className=" absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
|
||||||
src={Edit}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(
|
|
||||||
`/article/create?back=/home/account/articles&articleId=${id}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ArticlesBlockProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const articles = useAppSelector((state) => state.articles.articles);
|
|
||||||
const [active, setActive] = useState<boolean>(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(setMenuActiveProfilePage('articles'));
|
|
||||||
dispatch(fetchArticles({}));
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full relative p-[20px]">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
' h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300',
|
|
||||||
active && ' border-b-liquid-lighter',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setActive(!active);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>Мои статьи</span>
|
|
||||||
<img
|
|
||||||
src={ChevroneDown}
|
|
||||||
className={cn(
|
|
||||||
'transition-all duration-300',
|
|
||||||
active && 'rotate-180',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
|
|
||||||
active && 'grid-rows-[1fr] opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="grid gap-[20px] pt-[20px] pb-[20px] box-border">
|
|
||||||
{articles.map((v, i) => (
|
|
||||||
<ArticleItem key={i} {...v} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ArticlesBlock;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useAppDispatch } from '../../../redux/hooks';
|
|
||||||
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
|
|
||||||
|
|
||||||
const ContestsBlock = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(setMenuActiveProfilePage('contests'));
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full relative flex items-center justify-center text-[60px] font-bold">
|
|
||||||
Пока пусто :(
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContestsBlock;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useAppDispatch } from '../../../redux/hooks';
|
|
||||||
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
|
|
||||||
|
|
||||||
const MissionsBlock = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(setMenuActiveProfilePage('missions'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full relative flex items-center justify-center text-[60px] font-bold">
|
|
||||||
Пока пусто :(
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MissionsBlock;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
|
||||||
import { ReverseButton } from '../../../components/button/ReverseButton';
|
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { logout } from '../../../redux/slices/auth';
|
import { logout } from '../../../redux/slices/auth';
|
||||||
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
|
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
|
||||||
interface StatisticItemProps {
|
interface StatisticItemProps {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -34,32 +34,55 @@ const StatisticItem: FC<StatisticItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 RightPanel = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const name = useAppSelector((state) => state.auth.username);
|
|
||||||
const email = useAppSelector((state) => state.auth.email);
|
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 (
|
return (
|
||||||
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
|
<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="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
|
||||||
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
|
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
|
||||||
<div className=" relative">
|
<div className=" relative">
|
||||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||||
{name}
|
{profileData?.identity.username}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
|
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
|
||||||
{email}
|
{profileData?.identity.email}
|
||||||
</div>
|
|
||||||
<div className=" absolute bottom-0 text-liquid-light text-[24px] leading-[30px] font-bold">
|
|
||||||
Топ 50%
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className=" text-liquid-light text-[18px] leading-[30px] font-bold">
|
||||||
|
{`Зарегистрирован ${formatDate(
|
||||||
|
profileData?.identity.createdAt,
|
||||||
|
)}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* {username == myname && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
text="Редактировать"
|
text="Редактировать"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||||
|
|
||||||
@@ -70,14 +93,14 @@ const RightPanel = () => {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Clipboard}
|
icon={Clipboard}
|
||||||
title={'Задачи'}
|
title={'Задачи'}
|
||||||
count={14}
|
count={profileData?.solutions.totalSolved}
|
||||||
countLastWeek={5}
|
countLastWeek={profileData?.solutions.solvedLast7Days}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Cup}
|
icon={Cup}
|
||||||
title={'Контесты'}
|
title={'Контесты'}
|
||||||
count={8}
|
count={profileData?.contests.totalParticipations}
|
||||||
countLastWeek={2}
|
countLastWeek={profileData?.contests.participationsLast7Days}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||||
@@ -87,22 +110,23 @@ const RightPanel = () => {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Clipboard}
|
icon={Clipboard}
|
||||||
title={'Задачи'}
|
title={'Задачи'}
|
||||||
count={4}
|
count={profileData?.creation.missions.total}
|
||||||
countLastWeek={2}
|
countLastWeek={profileData?.creation.missions.last7Days}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={OpenBook}
|
icon={OpenBook}
|
||||||
title={'Статьи'}
|
title={'Статьи'}
|
||||||
count={12}
|
count={profileData?.creation.articles.total}
|
||||||
countLastWeek={4}
|
countLastWeek={profileData?.creation.articles.last7Days}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Cup}
|
icon={Cup}
|
||||||
title={'Контесты'}
|
title={'Контесты'}
|
||||||
count={2}
|
count={profileData?.creation.contests.total}
|
||||||
countLastWeek={0}
|
countLastWeek={profileData?.creation.contests.last7Days}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{username == myname && (
|
||||||
<ReverseButton
|
<ReverseButton
|
||||||
className="absolute bottom-[20px] right-[20px]"
|
className="absolute bottom-[20px] right-[20px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -111,6 +135,7 @@ const RightPanel = () => {
|
|||||||
text="Выход"
|
text="Выход"
|
||||||
color="error"
|
color="error"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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,5 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { useAppSelector } from '../../../redux/hooks';
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
export interface ArticleItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,6 +10,65 @@ export interface ArticleItemProps {
|
|||||||
|
|
||||||
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -26,7 +86,7 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
#{id}
|
#{id}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
||||||
{name}
|
{highlightZ(name, nameFilter)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
||||||
@@ -36,6 +96,8 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
||||||
v == 'Sertificated' && 'text-liquid-green',
|
v == 'Sertificated' && 'text-liquid-green',
|
||||||
|
filterTags.includes(v) &&
|
||||||
|
'border-liquid-brightmain border-[1px] border-solid text-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{v}
|
{v}
|
||||||
|
|||||||
@@ -2,55 +2,84 @@ import { useEffect } from 'react';
|
|||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ArticleItem from './ArticleItem';
|
import ArticleItem from './ArticleItem';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import {
|
||||||
|
setArticlesNameFilter,
|
||||||
|
setArticlesTagFilter,
|
||||||
|
setMenuActivePage,
|
||||||
|
} from '../../../redux/slices/store';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { fetchArticles } from '../../../redux/slices/articles';
|
import { fetchArticles } from '../../../redux/slices/articles';
|
||||||
|
import Filters from './Filter';
|
||||||
export interface Article {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Articles = () => {
|
const Articles = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const articles = useAppSelector((state) => state.articles.articles);
|
// ✅ Берём данные из нового состояния
|
||||||
const status = useAppSelector((state) => state.articles.statuses.fetchAll);
|
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(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('articles'));
|
dispatch(setMenuActivePage('articles'));
|
||||||
dispatch(fetchArticles({}));
|
dispatch(fetchArticles({ tags: tagsFilter }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (status == 'loading') return <div>Загрузка...</div>;
|
const filterTagsHandler = (value: string[]) => {
|
||||||
|
dispatch(setArticlesTagFilter(value));
|
||||||
|
dispatch(fetchArticles({ tags: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Основной контент
|
||||||
|
// ========================
|
||||||
return (
|
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="h-full box-border">
|
||||||
|
{/* Заголовок */}
|
||||||
<div className="relative flex items-center mb-[20px]">
|
<div className="relative flex items-center mb-[20px]">
|
||||||
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
||||||
Статьи
|
Статьи
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {
|
onClick={() => navigate('/article/create')}
|
||||||
navigate('/article/create');
|
|
||||||
}}
|
|
||||||
text="Создать статью"
|
text="Создать статью"
|
||||||
className="absolute right-0"
|
className="absolute right-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
|
{/* Фильтры */}
|
||||||
|
<Filters
|
||||||
|
onChangeTags={(value: string[]) => {
|
||||||
|
filterTagsHandler(value);
|
||||||
|
}}
|
||||||
|
onChangeName={(value: string) => {
|
||||||
|
dispatch(setArticlesNameFilter(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
{/* Список статей */}
|
||||||
{articles.map((v, i) => (
|
<div className="mt-[20px]">
|
||||||
<ArticleItem key={i} {...v} />
|
{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>pages</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
28
src/views/home/articles/Filter.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { TagFilter } from '../../../components/filters/TagFilter';
|
||||||
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
|
interface ArticleFiltersProps {
|
||||||
|
onChangeTags: (value: string[]) => void;
|
||||||
|
onChangeName: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Filters: FC<ArticleFiltersProps> = ({ onChangeTags, onChangeName }) => {
|
||||||
|
return (
|
||||||
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
|
<SearchInput
|
||||||
|
onChange={(value: string) => {
|
||||||
|
onChangeName(value);
|
||||||
|
}}
|
||||||
|
placeholder="Поиск статьи"
|
||||||
|
/>
|
||||||
|
<TagFilter
|
||||||
|
onChange={(value: string[]) => {
|
||||||
|
onChangeTags(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Filters;
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
|
// src/views/home/auth/Login.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { Input } from '../../../components/input/Input';
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { loginUser } from '../../../redux/slices/auth';
|
import { loginUser } from '../../../redux/slices/auth';
|
||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Balloon } from '../../../assets/icons/auth';
|
import { Balloon } from '../../../assets/icons/auth';
|
||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
|
||||||
import { googleLogo } from '../../../assets/icons/input';
|
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [username, setUsername] = useState<string>('');
|
const [username, setUsername] = useState<string>('');
|
||||||
const [password, setPassword] = useState<string>('');
|
const [password, setPassword] = useState<string>('');
|
||||||
@@ -25,12 +25,13 @@ const Login = () => {
|
|||||||
// После успешного логина
|
// После успешного логина
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('account'));
|
dispatch(setMenuActivePage('account'));
|
||||||
console.log(submitClicked);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
navigate('/home/account'); // или другая страница после входа
|
const from = location.state?.from;
|
||||||
|
const path = from ? from.pathname + from.search : '/home/account';
|
||||||
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
}, [jwt]);
|
}, [jwt]);
|
||||||
|
|
||||||
@@ -43,6 +44,21 @@ const Login = () => {
|
|||||||
dispatch(loginUser({ username, password }));
|
dispatch(loginUser({ username, password }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getErrorLoginMessage = (): string => {
|
||||||
|
if (!submitClicked) return '';
|
||||||
|
if (username == '') return 'Поле не может быть пустым';
|
||||||
|
if (password == '') return '';
|
||||||
|
if (status === 'failed')
|
||||||
|
return 'Неверное имя пользователя и/или пароль';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorPasswordMessage = (): string => {
|
||||||
|
if (!submitClicked) return '';
|
||||||
|
if (password == '') return 'Поле не может быть пустым';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
||||||
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
||||||
@@ -51,7 +67,7 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className=" relative pointer-events-auto">
|
<div className=" relative pointer-events-auto">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
|
<div className="text-[35px] text-liquid-white font-bold h-[50px]">
|
||||||
С возвращением
|
С возвращением
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
||||||
@@ -69,6 +85,7 @@ const Login = () => {
|
|||||||
setUsername(v);
|
setUsername(v);
|
||||||
}}
|
}}
|
||||||
placeholder="login"
|
placeholder="login"
|
||||||
|
error={getErrorLoginMessage()}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="password"
|
name="password"
|
||||||
@@ -80,17 +97,11 @@ const Login = () => {
|
|||||||
setPassword(v);
|
setPassword(v);
|
||||||
}}
|
}}
|
||||||
placeholder="abCD1234"
|
placeholder="abCD1234"
|
||||||
|
error={getErrorPasswordMessage()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end mt-[10px]">
|
<div className="flex justify-end mt-[10px] h-[20px]">
|
||||||
<Link
|
|
||||||
to={''}
|
|
||||||
className={
|
|
||||||
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Забыли пароль?
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[10px]">
|
<div className="mt-[10px]">
|
||||||
@@ -100,15 +111,6 @@ const Login = () => {
|
|||||||
text={status === 'loading' ? 'Вход...' : 'Вход'}
|
text={status === 'loading' ? 'Вход...' : 'Вход'}
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
/>
|
/>
|
||||||
<SecondaryButton className="w-full" onClick={() => {}}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src={googleLogo}
|
|
||||||
className="h-[24px] w-[24px] mr-[15px]"
|
|
||||||
/>
|
|
||||||
Вход с Google
|
|
||||||
</div>
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mt-[10px]">
|
<div className="flex justify-center mt-[10px]">
|
||||||
|
|||||||
@@ -1,51 +1,140 @@
|
|||||||
|
// src/views/home/auth/Register.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { Input } from '../../../components/input/Input';
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { registerUser } from '../../../redux/slices/auth';
|
import { registerUser } from '../../../redux/slices/auth';
|
||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Balloon } from '../../../assets/icons/auth';
|
import { Balloon } from '../../../assets/icons/auth';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
// import { Checkbox } from '../../../components/checkbox/Checkbox';
|
||||||
import { Checkbox } from '../../../components/checkbox/Checkbox';
|
|
||||||
import { googleLogo } from '../../../assets/icons/input';
|
function isValidEmail(email: string): boolean {
|
||||||
|
const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return pattern.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLogin(login: string): boolean {
|
||||||
|
return login.length >= 4 && login.length <= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidatePassword(password: string): string {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (password.length < 8 || password.length > 255) {
|
||||||
|
errors.push('Пароль должен содержать от 8 до 255 символов');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[A-Z]/.test(password)) {
|
||||||
|
errors.push('Пароль должен содержать хотя бы одну заглавную букву');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[a-z]/.test(password)) {
|
||||||
|
errors.push('Пароль должен содержать хотя бы одну строчную букву');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[0-9]/.test(password)) {
|
||||||
|
errors.push('Пароль должен содержать хотя бы одну цифру');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [username, setUsername] = useState<string>('');
|
const [username, setUsername] = useState<string>('');
|
||||||
const [email, setEmail] = useState<string>('');
|
const [email, setEmail] = useState<string>('');
|
||||||
const [password, setPassword] = useState<string>('');
|
const [password, setPassword] = useState<string>('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||||
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
||||||
|
const [politicChecked, setPoliticChecked] = useState<boolean>(true);
|
||||||
|
|
||||||
const { status, jwt } = useAppSelector((state) => state.auth);
|
const { status, jwt } = useAppSelector((state) => state.auth);
|
||||||
|
// const { errors } = useAppSelector((state) => state.auth.register);
|
||||||
// После успешной регистрации — переход в систему
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setPoliticChecked(true);
|
||||||
dispatch(setMenuActivePage('account'));
|
dispatch(setMenuActivePage('account'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
navigate('/home/account');
|
const from = location.state?.from;
|
||||||
|
const path = from ? from.pathname + from.search : '/home/account';
|
||||||
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
console.log(submitClicked);
|
|
||||||
}, [jwt]);
|
}, [jwt]);
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
setSubmitClicked(true);
|
setSubmitClicked(true);
|
||||||
|
|
||||||
|
if (!politicChecked) return;
|
||||||
if (!username || !email || !password || !confirmPassword) return;
|
if (!username || !email || !password || !confirmPassword) return;
|
||||||
if (password !== confirmPassword) return;
|
if (password !== confirmPassword) return;
|
||||||
|
if (
|
||||||
|
!isValidEmail(email) ||
|
||||||
|
!isValidLogin(username) ||
|
||||||
|
isValidatePassword(password) != ''
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
dispatch(registerUser({ username, email, password }));
|
dispatch(registerUser({ username, email, password }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getErrorEmailMessage = (): string => {
|
||||||
|
if (!submitClicked) return '';
|
||||||
|
if (email == '') return 'Поле не может быть пустым';
|
||||||
|
if (!isValidEmail(email)) return 'Почта не валидна';
|
||||||
|
if (!username || !email || !password || !confirmPassword) return '';
|
||||||
|
if (password !== confirmPassword) return '';
|
||||||
|
// if (errors?.Email) {
|
||||||
|
// return errors.Email.join('\n');
|
||||||
|
// }
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorLoginMessage = (): string => {
|
||||||
|
if (!submitClicked) return '';
|
||||||
|
if (username == '') return 'Поле не может быть пустым';
|
||||||
|
if (!isValidLogin(username))
|
||||||
|
return 'Логин дложен быть длиной от 4 до 128 символов';
|
||||||
|
if (!username || !email || !password || !confirmPassword) return '';
|
||||||
|
if (password !== confirmPassword) return '';
|
||||||
|
// if (errors?.Username) {
|
||||||
|
// return errors.Username.join('\n');
|
||||||
|
// }
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorPasswordMessage = (): string => {
|
||||||
|
if (!submitClicked) return '';
|
||||||
|
if (password == '') return 'Поле не может быть пустым';
|
||||||
|
const val = isValidatePassword(password);
|
||||||
|
if (val != '') return val;
|
||||||
|
if (confirmPassword != password) return 'Пароли не совпадают';
|
||||||
|
if (!username || !email || !password || !confirmPassword) return '';
|
||||||
|
// if (errors?.Password) {
|
||||||
|
// return errors.Password.join('\n');
|
||||||
|
// }
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorConfirmPasswordMessage = (): string => {
|
||||||
|
if (!submitClicked) return '';
|
||||||
|
if (confirmPassword == '') return 'Поле не может быть пустым';
|
||||||
|
const val = isValidatePassword(confirmPassword);
|
||||||
|
if (val != '') return val;
|
||||||
|
if (confirmPassword != password) return 'Пароли не совпадают';
|
||||||
|
if (!username || !email || !password || !confirmPassword) return '';
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
||||||
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
||||||
@@ -54,7 +143,7 @@ const Register = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className=" relative pointer-events-auto">
|
<div className=" relative pointer-events-auto">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
|
<div className="text-[35px] text-liquid-white font-bold h-[50px]">
|
||||||
Добро пожаловать
|
Добро пожаловать
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
||||||
@@ -72,6 +161,7 @@ const Register = () => {
|
|||||||
setEmail(v);
|
setEmail(v);
|
||||||
}}
|
}}
|
||||||
placeholder="example@gmail.com"
|
placeholder="example@gmail.com"
|
||||||
|
error={getErrorEmailMessage()}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="login"
|
name="login"
|
||||||
@@ -83,6 +173,7 @@ const Register = () => {
|
|||||||
setUsername(v);
|
setUsername(v);
|
||||||
}}
|
}}
|
||||||
placeholder="login"
|
placeholder="login"
|
||||||
|
error={getErrorLoginMessage()}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="password"
|
name="password"
|
||||||
@@ -94,6 +185,7 @@ const Register = () => {
|
|||||||
setPassword(v);
|
setPassword(v);
|
||||||
}}
|
}}
|
||||||
placeholder="abCD1234"
|
placeholder="abCD1234"
|
||||||
|
error={getErrorPasswordMessage()}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="confirm-password"
|
name="confirm-password"
|
||||||
@@ -105,16 +197,21 @@ const Register = () => {
|
|||||||
setConfirmPassword(v);
|
setConfirmPassword(v);
|
||||||
}}
|
}}
|
||||||
placeholder="abCD1234"
|
placeholder="abCD1234"
|
||||||
|
error={getErrorConfirmPasswordMessage()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className=" flex items-center mt-[10px] h-[24px]">
|
<div className=" flex items-center mt-[10px] h-[24px]">
|
||||||
<Checkbox
|
{/* <Checkbox
|
||||||
onChange={(value: boolean) => {
|
onChange={(value: boolean) => {
|
||||||
value;
|
setPoliticChecked(value);
|
||||||
}}
|
}}
|
||||||
className="p-0 w-fit m-[2.75px]"
|
className="p-0 w-fit m-[2.75px]"
|
||||||
size="md"
|
size="md"
|
||||||
color="secondary"
|
color={
|
||||||
|
politicChecked || !submitClicked
|
||||||
|
? 'secondary'
|
||||||
|
: 'danger'
|
||||||
|
}
|
||||||
variant="default"
|
variant="default"
|
||||||
/>
|
/>
|
||||||
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
|
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
|
||||||
@@ -122,7 +219,10 @@ const Register = () => {
|
|||||||
<Link to={'/home'} className={' underline'}>
|
<Link to={'/home'} className={' underline'}>
|
||||||
политику конфиденциальности
|
политику конфиденциальности
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className={' underline cursor-pointer'}>
|
||||||
|
политику конфиденциальности
|
||||||
</span>
|
</span>
|
||||||
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[10px]">
|
<div className="mt-[10px]">
|
||||||
@@ -136,15 +236,6 @@ const Register = () => {
|
|||||||
}
|
}
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
/>
|
/>
|
||||||
<SecondaryButton className="w-full" onClick={() => {}}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src={googleLogo}
|
|
||||||
className="h-[24px] w-[24px] mr-[15px]"
|
|
||||||
/>
|
|
||||||
Регистрация с Google
|
|
||||||
</div>
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mt-[10px]">
|
<div className="flex justify-center mt-[10px]">
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { useEffect } from 'react';
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||||
import { fetchContestById } from '../../../redux/slices/contests';
|
import {
|
||||||
|
fetchContestById,
|
||||||
|
fetchMyAttemptsInContest,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
import ContestMissions from './Missions';
|
import ContestMissions from './Missions';
|
||||||
|
import Submissions from './Submissions';
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,11 +19,13 @@ const Contest = () => {
|
|||||||
const { contestId } = useParams<{ contestId: string }>();
|
const { contestId } = useParams<{ contestId: string }>();
|
||||||
const contestIdNumber =
|
const contestIdNumber =
|
||||||
contestId && /^\d+$/.test(contestId) ? parseInt(contestId, 10) : null;
|
contestId && /^\d+$/.test(contestId) ? parseInt(contestId, 10) : null;
|
||||||
if (contestIdNumber === null) {
|
if (!contestIdNumber) {
|
||||||
return <Navigate to="/home/contests" replace />;
|
return <Navigate to="/home/contests" replace />;
|
||||||
}
|
}
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const contest = useAppSelector((state) => state.contests.selectedContest);
|
const contest = useAppSelector(
|
||||||
|
(state) => state.contests.fetchContestById.contest,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('contest'));
|
dispatch(setMenuActivePage('contest'));
|
||||||
@@ -27,11 +33,16 @@ const Contest = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchContestById(contestIdNumber));
|
dispatch(fetchContestById(contestIdNumber));
|
||||||
|
dispatch(fetchMyAttemptsInContest(contestIdNumber));
|
||||||
}, [contestIdNumber]);
|
}, [contestIdNumber]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-full h-full">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="submissions"
|
||||||
|
element={<Submissions contest={contest} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<ContestMissions contest={contest} />}
|
element={<ContestMissions contest={contest} />}
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { cn } from '../../../lib/cn';
|
|||||||
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { toastWarning } from '../../../lib/toastNotification';
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
|
contestId: number;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
timeLimit?: number;
|
timeLimit?: number;
|
||||||
memoryLimit?: number;
|
memoryLimit?: number;
|
||||||
type?: 'first' | 'second';
|
type?: 'first' | 'second';
|
||||||
status?: 'empty' | 'success' | 'error';
|
status?: 'success' | 'error';
|
||||||
|
attemptsStarted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMilliseconds(ms: number): string {
|
export function formatMilliseconds(ms: number): string {
|
||||||
@@ -24,12 +27,14 @@ export function formatBytesToMB(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MissionItem: React.FC<MissionItemProps> = ({
|
const MissionItem: React.FC<MissionItemProps> = ({
|
||||||
|
contestId,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
timeLimit = 1000,
|
timeLimit = 1000,
|
||||||
memoryLimit = 256 * 1024 * 1024,
|
memoryLimit = 256 * 1024 * 1024,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
|
attemptsStarted,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -48,7 +53,12 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/mission/${id}?back=${path}`);
|
if (attemptsStarted){
|
||||||
|
navigate(`/mission/${id}?back=${path}&contestId=${contestId}`);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
toastWarning("Нужно начать попытку")
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[18px] font-bold">#{id}</div>
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { FC } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import MissionItem from './MissionItem';
|
import MissionItem from './MissionItem';
|
||||||
import { Contest } from '../../../redux/slices/contests';
|
import {
|
||||||
|
Contest,
|
||||||
|
fetchMyAttemptsInContest,
|
||||||
|
fetchMySubmissions,
|
||||||
|
setContestStatus,
|
||||||
|
startContestAttempt,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { arrowLeft } from '../../../assets/icons/header';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,31 +20,192 @@ export interface Article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ContestMissionsProps {
|
interface ContestMissionsProps {
|
||||||
contest: Contest | null;
|
contest?: Contest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const url = query.get('back') ?? '/home/contests';
|
||||||
|
|
||||||
|
const { status } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMySubmissions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const attempts = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyAttemptsInContest.attempts,
|
||||||
|
);
|
||||||
|
const submissions = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.contests.fetchMyAttemptsInContest.attempts[0]?.submissions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [attemptsStarted, setAttemptsStarted] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [time, setTime] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const calc = (time: string) => {
|
||||||
|
return time != '' && new Date() <= new Date(time);
|
||||||
|
};
|
||||||
|
if (attempts.length && calc(attempts[0].expiresAt)) {
|
||||||
|
setAttemptsStarted(true);
|
||||||
|
|
||||||
|
const diffMs =
|
||||||
|
new Date(attempts[0].expiresAt).getTime() -
|
||||||
|
new Date().getTime();
|
||||||
|
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
|
||||||
|
setTime(seconds);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTime((t) => {
|
||||||
|
if (t <= 1) {
|
||||||
|
clearInterval(interval); // остановка таймера
|
||||||
|
setAttemptsStarted(false); // можно закрыть попытку или уведомить пользователя
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return t - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
} else setAttemptsStarted(false);
|
||||||
|
}, [attempts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contest) dispatch(fetchMySubmissions(contest.id));
|
||||||
|
}, [contest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status == 'successful') {
|
||||||
|
dispatch(
|
||||||
|
setContestStatus({ key: 'fetchMySubmissions', status: 'idle' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
if (!contest) {
|
if (!contest) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const solvedCount = (contest.missions ?? []).filter((mission) =>
|
||||||
|
submissions?.some(
|
||||||
|
(s) =>
|
||||||
|
s.solution.missionId === mission.id &&
|
||||||
|
s.solution.status === 'Accepted: All tests passed',
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const totalCount = contest.missions?.length ?? 0;
|
||||||
|
|
||||||
|
// форматирование: mm:ss
|
||||||
|
const minutes = String(Math.floor(time / 60)).padStart(2, '0');
|
||||||
|
const seconds = String(time % 60).padStart(2, '0');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-screen grid grid-rows-[74px,1fr] p-[20px] gap-[20px]">
|
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]">
|
||||||
<div className=""></div>
|
<div className="">
|
||||||
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
|
<div className="h-[50px] text-[40px] text-liquid-white font-bold">
|
||||||
<div className="h-[40px] w-ufll ">
|
{contest.name}
|
||||||
{contest?.name} {contest.id}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between h-[24px] items-center gap-[10px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={arrowLeft}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(url);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-liquid-light font-bold text-[18px]">
|
||||||
|
Контест #{contest.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-liquid-light font-bold text-[18px]">
|
||||||
|
{attemptsStarted
|
||||||
|
? `${minutes}:${seconds}`
|
||||||
|
: `Длительность попытки: ${
|
||||||
|
contest.attemptDurationMinutes ?? 0
|
||||||
|
} минут. Осталось попыток ${
|
||||||
|
(contest.maxAttempts ?? 0) -
|
||||||
|
(attempts?.length ?? 0)
|
||||||
|
}/${contest.maxAttempts ?? 0}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
|
||||||
|
<div className="flex gap-[20px]">
|
||||||
|
{attempts.length == 0 || !attemptsStarted ? (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(startContestAttempt(contest.id))
|
||||||
|
.unwrap()
|
||||||
|
.then(() => {
|
||||||
|
dispatch(
|
||||||
|
fetchMyAttemptsInContest(
|
||||||
|
contest.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
text="Начать попытку"
|
||||||
|
disabled={
|
||||||
|
(contest.maxAttempts ?? 0) -
|
||||||
|
(attempts?.length ?? 0) <=
|
||||||
|
0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}{' '}
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/contest/${contest.id}/submissions`);
|
||||||
|
}}
|
||||||
|
text="Мои посылки"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{contest.missions.map((v, i) => (
|
{(contest.missions ?? []).map((v, i) => {
|
||||||
|
const missionSubmissions = submissions?.filter(
|
||||||
|
(s) => s.solution.missionId === v.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSuccess = missionSubmissions?.some(
|
||||||
|
(s) =>
|
||||||
|
s.solution.status ==
|
||||||
|
'Accepted: All tests passed',
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = hasSuccess
|
||||||
|
? 'success'
|
||||||
|
: missionSubmissions?.length &&
|
||||||
|
missionSubmissions.length > 0
|
||||||
|
? 'error'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
<MissionItem
|
<MissionItem
|
||||||
|
attemptsStarted={attemptsStarted}
|
||||||
|
contestId={contest.id}
|
||||||
|
key={i}
|
||||||
id={v.id}
|
id={v.id}
|
||||||
name={v.name}
|
name={v.name}
|
||||||
timeLimit={v.timeLimitMilliseconds}
|
timeLimit={v.timeLimitMilliseconds}
|
||||||
memoryLimit={v.memoryLimitBytes}
|
memoryLimit={v.memoryLimitBytes}
|
||||||
|
status={status}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
94
src/views/home/contest/SubmissionItem.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { cn } from '../../../lib/cn';
|
||||||
|
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
||||||
|
// import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export interface SubmissionItemProps {
|
||||||
|
id: number;
|
||||||
|
datetime: string;
|
||||||
|
missionId: number;
|
||||||
|
language: string;
|
||||||
|
verdict: string;
|
||||||
|
duration: number;
|
||||||
|
memory: number;
|
||||||
|
type: 'first' | 'second';
|
||||||
|
status?: 'success' | 'wronganswer' | 'timelimit';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMilliseconds(ms: number): string {
|
||||||
|
const rounded = Math.round(ms) / 1000;
|
||||||
|
const formatted = rounded.toString().replace(/\.?0+$/, '');
|
||||||
|
return `${formatted} c`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytesToMB(bytes: number): string {
|
||||||
|
const megabytes = Math.floor(bytes / (1024 * 1024));
|
||||||
|
return `${megabytes} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionItem: React.FC<SubmissionItemProps> = ({
|
||||||
|
id,
|
||||||
|
datetime,
|
||||||
|
missionId,
|
||||||
|
language,
|
||||||
|
verdict,
|
||||||
|
duration,
|
||||||
|
memory,
|
||||||
|
type,
|
||||||
|
status
|
||||||
|
}) => {
|
||||||
|
// const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' w-full relative rounded-[10px] text-liquid-white text-center text-bold text-[16px] py-[8px]',
|
||||||
|
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
|
||||||
|
'grid grid-cols-7 grid-flow-col gap-[20px] px-[20px] box-border items-center',
|
||||||
|
status == 'wronganswer' &&
|
||||||
|
'border-l-[11px] border-l-liquid-red pl-[9px]',
|
||||||
|
status == 'timelimit' &&
|
||||||
|
'border-l-[11px] border-l-liquid-orange pl-[9px]',
|
||||||
|
status == 'success' &&
|
||||||
|
'border-l-[11px] border-l-liquid-green pl-[9px]',
|
||||||
|
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||||
|
)}
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
|
<div className="text-[18px] font-bold text-center">
|
||||||
|
{formatDate(datetime)}
|
||||||
|
</div>
|
||||||
|
<div>{missionId} </div>
|
||||||
|
<div className="text-[18px] font-bold text-center">{language}</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-[18px] font-bold text-center',
|
||||||
|
status == 'wronganswer' && 'text-liquid-red',
|
||||||
|
status == 'timelimit' && 'text-liquid-orange',
|
||||||
|
status == 'success' && 'text-liquid-green',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{verdict}
|
||||||
|
</div>
|
||||||
|
<div>{formatMilliseconds(duration)}</div>
|
||||||
|
<div>
|
||||||
|
{formatBytesToMB(memory)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubmissionItem;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { Contest, fetchMySubmissions } from '../../../redux/slices/contests';
|
||||||
|
import { arrowLeft } from '../../../assets/icons/header';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import SubmissionsBlock from './SubmissionsBlock';
|
||||||
|
|
||||||
|
export interface Mission {
|
||||||
|
id: number;
|
||||||
|
authorId: number;
|
||||||
|
name: string;
|
||||||
|
difficulty: 'Easy' | 'Medium' | 'Hard';
|
||||||
|
tags: string[];
|
||||||
|
timeLimit: number;
|
||||||
|
memoryLimit: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionsProps {
|
||||||
|
contest: Contest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Submissions: FC<SubmissionsProps> = ({ contest }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const attempts = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyAttemptsInContest.attempts,
|
||||||
|
);
|
||||||
|
const submissions = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.contests.fetchMyAttemptsInContest.attempts[0]?.submissions,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contest && contest.id) dispatch(fetchMySubmissions(contest.id));
|
||||||
|
}, [contest]);
|
||||||
|
|
||||||
|
const solvedCount = (contest.missions ?? []).filter((mission) =>
|
||||||
|
submissions?.some(
|
||||||
|
(s) =>
|
||||||
|
s.solution.missionId === mission.id &&
|
||||||
|
s.solution.status === 'Accepted: All tests passed',
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const totalCount = contest.missions?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-[calc(100%+250px)] box-border overflow-y-scroll overflow-x-hidden thin-scrollbar p-[20px] flex flex-col gap-[20px]">
|
||||||
|
<div className="">
|
||||||
|
<div className="h-[50px] text-[40px] text-liquid-white font-bold">
|
||||||
|
{contest.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between h-[24px] items-center gap-[10px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={arrowLeft}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/contest/${contest.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-liquid-light font-bold text-[18px]">
|
||||||
|
Контест #{contest.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-full overflow-y-scroll medium-scrollbar pr-[20px]">
|
||||||
|
{attempts?.map((v, i) => (
|
||||||
|
<SubmissionsBlock key={i} attempt={v} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Submissions;
|
||||||
|
|||||||
75
src/views/home/contest/SubmissionsBlock.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import SubmissionItem from './SubmissionItem';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { Attempt } from '../../../redux/slices/contests';
|
||||||
|
|
||||||
|
interface SubmissionsBlockProps {
|
||||||
|
attempt: Attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionsBlock: FC<SubmissionsBlockProps> = ({ attempt }) => {
|
||||||
|
const submissions = attempt?.submissions;
|
||||||
|
const isFinished = new Date(attempt.expiresAt) < new Date();
|
||||||
|
|
||||||
|
const checkStatus = (status: string) => {
|
||||||
|
if (status == 'IncorrectAnswer') return 'wronganswer';
|
||||||
|
if (status == 'TimeLimitError') return 'timelimit';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-[50px]">
|
||||||
|
<div className="flex items-center justify-center text-liquid-white font-bold text-[20px]">{`Попытка #${attempt.attemptId}`}</div>
|
||||||
|
{!submissions || submissions.length == 0 ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-7 text-center items-center h-[43px] mb-[10px] text-[16px] font-bold text-liquid-white">
|
||||||
|
<div>Посылка</div>
|
||||||
|
<div>Когда</div>
|
||||||
|
<div>Задача</div>
|
||||||
|
<div>Язык</div>
|
||||||
|
<div>Вердикт</div>
|
||||||
|
<div>Время</div>
|
||||||
|
<div>Память</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!submissions || submissions.length == 0 ? (
|
||||||
|
<div className="text-liquid-brightmain text-[16px] font-medium text-center">
|
||||||
|
{isFinished
|
||||||
|
? 'Вы ничего не посылали в этот сеанс'
|
||||||
|
: 'Вы еще ничего не отсылали'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{submissions.map((v, i) => (
|
||||||
|
<SubmissionItem
|
||||||
|
key={i}
|
||||||
|
id={v.id ?? 0}
|
||||||
|
datetime={v.solution.time}
|
||||||
|
missionId={v.solution.missionId}
|
||||||
|
language={v.solution.language}
|
||||||
|
verdict={
|
||||||
|
v.solution.testerMessage?.includes(
|
||||||
|
'Compilation failed',
|
||||||
|
)
|
||||||
|
? 'Compilation failed'
|
||||||
|
: v.solution.testerMessage
|
||||||
|
}
|
||||||
|
duration={1000}
|
||||||
|
memory={256 * 1024 * 1024}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
status={
|
||||||
|
v.solution.testerMessage == 'All tests passed'
|
||||||
|
? 'success'
|
||||||
|
: checkStatus(v.solution.testerErrorCode)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="h-[1px] bg-liquid-lighter mt-[50px]"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubmissionsBlock;
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { cn } from '../../../lib/cn';
|
|
||||||
import { Account } from '../../../assets/icons/auth';
|
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
|
||||||
import { ReverseButton } from '../../../components/button/ReverseButton';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export interface ContestItemProps {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
startAt: string;
|
|
||||||
duration: number;
|
|
||||||
members: number;
|
|
||||||
statusRegister: 'reg' | 'nonreg';
|
|
||||||
type: 'first' | 'second';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
|
|
||||||
const day = date.getDate().toString().padStart(2, '0');
|
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
||||||
const year = date.getFullYear();
|
|
||||||
|
|
||||||
const hours = date.getHours().toString().padStart(2, '0');
|
|
||||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
||||||
|
|
||||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWaitTime(ms: number): string {
|
|
||||||
const minutes = Math.floor(ms / 60000);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
const remainder = days % 10;
|
|
||||||
let suffix = 'дней';
|
|
||||||
if (remainder === 1 && days !== 11) suffix = 'день';
|
|
||||||
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
|
|
||||||
suffix = 'дня';
|
|
||||||
return `${days} ${suffix}`;
|
|
||||||
} else if (hours > 0) {
|
|
||||||
const mins = minutes % 60;
|
|
||||||
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
|
|
||||||
} else {
|
|
||||||
return `${minutes} мин`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContestItem: React.FC<ContestItemProps> = ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
startAt,
|
|
||||||
duration,
|
|
||||||
members,
|
|
||||||
statusRegister,
|
|
||||||
type,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const waitTime = new Date(startAt).getTime() - now.getTime();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer',
|
|
||||||
waitTime <= 0 ? 'grid grid-cols-6' : 'grid grid-cols-7',
|
|
||||||
'items-center font-bold text-liquid-white',
|
|
||||||
type == 'first'
|
|
||||||
? ' bg-liquid-lighter'
|
|
||||||
: ' bg-liquid-background',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/contest/${id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
|
||||||
<div className="text-center text-liquid-brightmain font-normal ">
|
|
||||||
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
|
|
||||||
valavshonok
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-nowrap whitespace-pre-line">
|
|
||||||
{formatDate(startAt)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">{formatWaitTime(duration)}</div>
|
|
||||||
{waitTime > 0 && (
|
|
||||||
<div className="text-center whitespace-pre-line ">
|
|
||||||
{'До начала\n' + formatWaitTime(waitTime)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
|
|
||||||
<div>{members}</div>
|
|
||||||
<img src={Account} className="h-[24px] w-[24px]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
{statusRegister == 'reg' ? (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
text="Регистрация"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<ReverseButton
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
text="Вы записаны"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContestItem;
|
|
||||||
@@ -3,37 +3,42 @@ import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ContestsBlock from './ContestsBlock';
|
import ContestsBlock from './ContestsBlock';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import {
|
||||||
import { fetchContests } from '../../../redux/slices/contests';
|
setContestsNameFilter,
|
||||||
|
setMenuActivePage,
|
||||||
|
} from '../../../redux/slices/store';
|
||||||
|
import {
|
||||||
|
fetchContests,
|
||||||
|
fetchMyContests,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
import ModalCreateContest from './ModalCreate';
|
import ModalCreateContest from './ModalCreate';
|
||||||
|
import Filters from './Filter';
|
||||||
|
import { toastWarning } from '../../../lib/toastNotification';
|
||||||
|
|
||||||
const Contests = () => {
|
const Contests = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const [modalActive, setModalActive] = useState<boolean>(false);
|
const [modalActive, setModalActive] = useState<boolean>(false);
|
||||||
|
|
||||||
// Берём данные из Redux
|
const jwt = useAppSelector((state) => state.auth.jwt);
|
||||||
const contests = useAppSelector((state) => state.contests.contests);
|
|
||||||
const status = useAppSelector((state) => state.contests.statuses.create);
|
const { contests, status } = useAppSelector(
|
||||||
const error = useAppSelector((state) => state.contests.error);
|
(state) => state.contests.fetchContests,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.contests.filterName,
|
||||||
|
);
|
||||||
|
|
||||||
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('contests'));
|
dispatch(setMenuActivePage('contests'));
|
||||||
dispatch(fetchContests({}));
|
dispatch(fetchContests({}));
|
||||||
|
dispatch(fetchParticipatingContests({ pageSize: 100 }));
|
||||||
|
dispatch(fetchMyContests());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (status == 'loading') {
|
|
||||||
return (
|
|
||||||
<div className="text-liquid-white p-4">Загрузка контестов...</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="text-red-500 p-4">Ошибка: {error}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
||||||
<div className="h-full box-border">
|
<div className="h-full box-border">
|
||||||
@@ -47,6 +52,10 @@ const Contests = () => {
|
|||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!jwt){
|
||||||
|
toastWarning("Для создания контеста необходимо авторизоваться")
|
||||||
|
return;
|
||||||
|
}
|
||||||
setModalActive(true);
|
setModalActive(true);
|
||||||
}}
|
}}
|
||||||
text="Создать контест"
|
text="Создать контест"
|
||||||
@@ -54,25 +63,49 @@ const Contests = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]" />
|
<Filters
|
||||||
|
onChangeName={(v: string) => {
|
||||||
|
dispatch(setContestsNameFilter(v));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{status == 'loading' && (
|
||||||
|
<div className="text-liquid-white p-4">
|
||||||
|
Загрузка контестов...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status == 'successful' && (
|
||||||
|
<>
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Текущие"
|
title="Текущие"
|
||||||
contests={contests.filter((contest) => {
|
contests={contests
|
||||||
const endTime = new Date(contest.endsAt).getTime();
|
.filter((v) =>
|
||||||
return endTime >= now.getTime();
|
v.name
|
||||||
})}
|
.toLocaleLowerCase()
|
||||||
|
.includes(
|
||||||
|
nameFilter.toLocaleLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((c) => c.scheduleType != 'AlwaysOpen')}
|
||||||
|
type="upcoming"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Прошедшие"
|
title="Постоянные"
|
||||||
contests={contests.filter((contest) => {
|
contests={contests
|
||||||
const endTime = new Date(contest.endsAt).getTime();
|
.filter((v) =>
|
||||||
return endTime < now.getTime();
|
v.name
|
||||||
})}
|
.toLocaleLowerCase()
|
||||||
|
.includes(
|
||||||
|
nameFilter.toLocaleLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((c) => c.scheduleType == 'AlwaysOpen')}
|
||||||
|
type="past"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalCreateContest
|
<ModalCreateContest
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useState, FC } from 'react';
|
import { useState, FC } from 'react';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../assets/icons/groups';
|
||||||
import ContestItem from './ContestItem';
|
|
||||||
import { Contest } from '../../../redux/slices/contests';
|
import { Contest } from '../../../redux/slices/contests';
|
||||||
|
import PastContestItem from './PastContestItem';
|
||||||
|
import UpcoingContestItem from './UpcomingContestItem';
|
||||||
|
|
||||||
interface ContestsBlockProps {
|
interface ContestsBlockProps {
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
type: 'upcoming' | 'past';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
contests,
|
contests,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
@@ -33,11 +36,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
setActive(!active);
|
setActive(!active);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{title}</span>
|
<span className=" select-none">{title}</span>
|
||||||
<img
|
<img
|
||||||
src={ChevroneDown}
|
src={ChevroneDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300',
|
'transition-all duration-300 select-none',
|
||||||
active && 'rotate-180',
|
active && 'rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -50,21 +53,51 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-[10px] pt-[20px]">
|
<div className="pb-[10px] pt-[20px]">
|
||||||
{contests.map((v, i) => (
|
{contests.map((v, i) => {
|
||||||
<ContestItem
|
if (type == 'past') {
|
||||||
|
return (
|
||||||
|
<PastContestItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
contestId={v.id}
|
||||||
|
scheduleType={v.scheduleType}
|
||||||
name={v.name}
|
name={v.name}
|
||||||
startAt={v.startsAt}
|
startsAt={
|
||||||
statusRegister={'reg'}
|
v.startsAt ?? new Date().toString()
|
||||||
duration={
|
}
|
||||||
new Date(v.endsAt).getTime() -
|
endsAt={
|
||||||
new Date(v.startsAt).getTime()
|
v.endsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
attemptDurationMinutes={
|
||||||
|
v.attemptDurationMinutes ?? 0
|
||||||
}
|
}
|
||||||
members={v.members.length}
|
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'upcoming') {
|
||||||
|
return (
|
||||||
|
<UpcoingContestItem
|
||||||
|
key={i}
|
||||||
|
contestId={v.id}
|
||||||
|
scheduleType={v.scheduleType}
|
||||||
|
name={v.name}
|
||||||
|
startsAt={
|
||||||
|
v.startsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
endsAt={
|
||||||
|
v.endsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
attemptDurationMinutes={
|
||||||
|
v.attemptDurationMinutes ?? 0
|
||||||
|
}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
src/views/home/contests/Filter.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
|
interface ContestFiltersProps {
|
||||||
|
onChangeName: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Filters: FC<ContestFiltersProps> = ({ onChangeName }) => {
|
||||||
|
return (
|
||||||
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
|
<SearchInput
|
||||||
|
onChange={(value: string) => {
|
||||||
|
onChangeName(value);
|
||||||
|
}}
|
||||||
|
placeholder="Поиск контеста"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Filters;
|
||||||
@@ -4,9 +4,30 @@ import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
|||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { Input } from '../../../components/input/Input';
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { createContest } from '../../../redux/slices/contests';
|
import {
|
||||||
|
createContest,
|
||||||
|
setContestStatus,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
import { CreateContestBody } from '../../../redux/slices/contests';
|
import { CreateContestBody } from '../../../redux/slices/contests';
|
||||||
import DateRangeInput from '../../../components/input/DateRangeInput';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { NumberInput } from '../../../components/input/NumberInput';
|
||||||
|
import {
|
||||||
|
DropDownList,
|
||||||
|
DropDownListItem,
|
||||||
|
} from '../../../components/input/DropDownList';
|
||||||
|
import DateInput from '../../../components/input/DateInput';
|
||||||
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { fetchMyGroups } from '../../../redux/slices/groups';
|
||||||
|
|
||||||
|
function toUtc(localDateTime?: string): string {
|
||||||
|
if (!localDateTime) return '';
|
||||||
|
|
||||||
|
// Создаём дату (она автоматически считается как локальная)
|
||||||
|
const date = new Date(localDateTime);
|
||||||
|
|
||||||
|
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
interface ModalCreateContestProps {
|
interface ModalCreateContestProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -18,39 +39,99 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
setActive,
|
setActive,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const status = useAppSelector((state) => state.contests.statuses.create);
|
const navigate = useNavigate();
|
||||||
|
const status = useAppSelector(
|
||||||
|
(state) => state.contests.createContest.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibilityItems: DropDownListItem[] = [
|
||||||
|
{ value: 'Public', text: 'Публичный' },
|
||||||
|
{ value: 'GroupPrivate', text: 'Для группы' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const scheduleTypeItems: DropDownListItem[] = [
|
||||||
|
{ value: 'AlwaysOpen', text: 'Всегда открыт' },
|
||||||
|
{ value: 'FixedWindow', text: 'Фиксированое окно' },
|
||||||
|
{ value: 'RollingWindow', text: 'Скользящее окно' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 [form, setForm] = useState<CreateContestBody>({
|
const [form, setForm] = useState<CreateContestBody>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
scheduleType: 'AlwaysOpen',
|
scheduleType: 'AlwaysOpen',
|
||||||
visibility: 'Public',
|
visibility: 'Public',
|
||||||
startsAt: null,
|
startsAt: toLocal(now),
|
||||||
endsAt: null,
|
endsAt: toLocal(plus60),
|
||||||
attemptDurationMinutes: null,
|
attemptDurationMinutes: 60,
|
||||||
maxAttempts: null,
|
maxAttempts: 1,
|
||||||
allowEarlyFinish: false,
|
allowEarlyFinish: false,
|
||||||
groupId: null,
|
missionIds: [],
|
||||||
missionIds: null,
|
articleIds: [],
|
||||||
articleIds: null,
|
|
||||||
participantIds: null,
|
|
||||||
organizerIds: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const contest = useAppSelector(
|
||||||
|
(state) => state.contests.createContest.contest,
|
||||||
|
);
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
const myGroups = useAppSelector(
|
||||||
|
(state) => state.groups.fetchMyGroups.groups,
|
||||||
|
).filter((group) =>
|
||||||
|
group.members.some(
|
||||||
|
(member) =>
|
||||||
|
member.username === myname &&
|
||||||
|
member.role.includes('Administrator'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'successful') {
|
if (status === 'successful') {
|
||||||
setActive(false);
|
dispatch(
|
||||||
|
setContestStatus({ key: 'createContest', status: 'idle' }),
|
||||||
|
);
|
||||||
|
navigate(
|
||||||
|
`/contest/create?back=/home/account/contests&contestId=${contest.id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
dispatch(fetchMyGroups());
|
||||||
|
}
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
const handleChange = (key: keyof CreateContestBody, value: any) => {
|
const handleChange = (key: keyof CreateContestBody, value: any) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
dispatch(createContest(form));
|
dispatch(
|
||||||
|
createContest({
|
||||||
|
...form,
|
||||||
|
endsAt: toUtc(form.endsAt),
|
||||||
|
startsAt: toUtc(form.startsAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
const groupItems = myGroups.map((v) => {
|
||||||
|
return {
|
||||||
|
value: '' + v.id,
|
||||||
|
text: v.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const groupIdDefaultState =
|
||||||
|
myGroups.find((g) => g.id == form?.groupId) ?? myGroups[0] ?? undefined;
|
||||||
|
|
||||||
|
console.log(groupItems, myGroups, groupIdDefaultState);
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
@@ -84,80 +165,123 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1">
|
<label className="block text-sm mb-1">
|
||||||
Тип расписания
|
Тип контеста
|
||||||
</label>
|
</label>
|
||||||
<select
|
|
||||||
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
<DropDownList
|
||||||
value={form.scheduleType}
|
items={scheduleTypeItems}
|
||||||
onChange={(e) =>
|
onChange={(v) => {
|
||||||
handleChange(
|
handleChange('scheduleType', v);
|
||||||
'scheduleType',
|
}}
|
||||||
e.target
|
weight="w-full"
|
||||||
.value as CreateContestBody['scheduleType'],
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="AlwaysOpen">Всегда открыт</option>
|
|
||||||
<option value="FixedWindow">
|
|
||||||
Фиксированные даты
|
|
||||||
</option>
|
|
||||||
<option value="RollingWindow">
|
|
||||||
Скользящее окно
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1">Видимость</label>
|
<label className="block text-sm mb-1">Видимость</label>
|
||||||
<select
|
<DropDownList
|
||||||
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
items={visibilityItems}
|
||||||
value={form.visibility}
|
onChange={(v) => {
|
||||||
onChange={(e) =>
|
handleChange('visibility', v);
|
||||||
handleChange(
|
}}
|
||||||
'visibility',
|
weight="w-full"
|
||||||
e.target
|
/>
|
||||||
.value as CreateContestBody['visibility'],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="Public">Публичный</option>
|
|
||||||
<option value="GroupPrivate">Групповой</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Даты начала и конца */}
|
<div
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
className={cn(
|
||||||
<DateRangeInput
|
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-200 mb-[10px]',
|
||||||
startValue={form.startsAt || ''}
|
form.visibility == 'GroupPrivate' &&
|
||||||
endValue={form.endsAt || ''}
|
'grid-rows-[1fr] opacity-100',
|
||||||
onChange={handleChange}
|
)}
|
||||||
className="mt-[10px]"
|
>
|
||||||
|
{groupIdDefaultState ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
form.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>
|
||||||
|
) : (
|
||||||
|
<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',
|
||||||
|
form.scheduleType != 'AlwaysOpen' &&
|
||||||
|
'grid-rows-[1fr] opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
|
<DateInput
|
||||||
|
label="Дата начала"
|
||||||
|
value={form.startsAt}
|
||||||
|
onChange={(v) => handleChange('startsAt', v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="Дата окончания"
|
||||||
|
value={form.endsAt}
|
||||||
|
onChange={(v) => handleChange('endsAt', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Продолжительность и лимиты */}
|
{/* Продолжительность и лимиты */}
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<Input
|
<NumberInput
|
||||||
|
defaultState={form.attemptDurationMinutes}
|
||||||
name="attemptDurationMinutes"
|
name="attemptDurationMinutes"
|
||||||
type="number"
|
|
||||||
label="Длительность попытки (мин)"
|
label="Длительность попытки (мин)"
|
||||||
placeholder="Например: 60"
|
placeholder="Например: 60"
|
||||||
|
minValue={1}
|
||||||
|
maxValue={365 * 24 * 60}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
handleChange('attemptDurationMinutes', Number(v))
|
handleChange('attemptDurationMinutes', Number(v))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<NumberInput
|
||||||
|
defaultState={form.maxAttempts}
|
||||||
name="maxAttempts"
|
name="maxAttempts"
|
||||||
type="number"
|
|
||||||
label="Макс. попыток"
|
label="Макс. попыток"
|
||||||
placeholder="Например: 3"
|
placeholder="Например: 3"
|
||||||
|
minValue={1}
|
||||||
|
maxValue={100}
|
||||||
onChange={(v) => handleChange('maxAttempts', Number(v))}
|
onChange={(v) => handleChange('maxAttempts', Number(v))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Разрешить раннее завершение */}
|
{/* Разрешить раннее завершение */}
|
||||||
<div className="flex items-center gap-[10px] mt-[15px]">
|
{/* <div className="flex items-center gap-[10px] mt-[15px]">
|
||||||
<input
|
<input
|
||||||
id="allowEarlyFinish"
|
id="allowEarlyFinish"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -169,12 +293,14 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
<label htmlFor="allowEarlyFinish">
|
<label htmlFor="allowEarlyFinish">
|
||||||
Разрешить раннее завершение
|
Разрешить раннее завершение
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleSubmit}
|
onClick={() => {
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
text="Создать"
|
text="Создать"
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
246
src/views/home/contests/PastContestItem.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
|
import { toastWarning } from '../../../lib/toastNotification';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
addOrUpdateContestMember,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
|
|
||||||
|
export interface PastContestItemProps {
|
||||||
|
name: string;
|
||||||
|
contestId: number;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
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} мин`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Role = 'None' | 'Participant' | 'Organizer';
|
||||||
|
|
||||||
|
const PastContestItem: React.FC<PastContestItemProps> = ({
|
||||||
|
name,
|
||||||
|
contestId,
|
||||||
|
scheduleType,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
attemptDurationMinutes,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [role, setRole] = useState<Role>('None');
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const userId = useAppSelector((state) => state.auth.id);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
const { contests: myContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyContests,
|
||||||
|
);
|
||||||
|
const { contests: participantContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchParticipating,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.contests.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRole(
|
||||||
|
(() => {
|
||||||
|
if (myContests?.some((c) => c.id === contestId)) {
|
||||||
|
return 'Organizer';
|
||||||
|
}
|
||||||
|
if (participantContests?.some((c) => c.id === contestId)) {
|
||||||
|
return 'Participant';
|
||||||
|
}
|
||||||
|
return 'None';
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}, [myContests, participantContests]);
|
||||||
|
|
||||||
|
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',
|
||||||
|
userId
|
||||||
|
? 'grid-cols-[1fr,150px,190px,120px,150px,150px]'
|
||||||
|
: 'grid-cols-[1fr,150px,190px,120px,150px]',
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (role == 'None') {
|
||||||
|
toastWarning('Нужно зарегистрироваться на контест');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">
|
||||||
|
{highlightZ(name, nameFilter)}
|
||||||
|
</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>
|
||||||
|
{userId && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{role == 'Organizer' || role == 'Participant' ? (
|
||||||
|
<ReverseButton
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
text="Войти"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(
|
||||||
|
addOrUpdateContestMember({
|
||||||
|
contestId: contestId,
|
||||||
|
member: {
|
||||||
|
userId: Number(userId),
|
||||||
|
role: 'Participant',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.then(() =>
|
||||||
|
dispatch(
|
||||||
|
fetchParticipatingContests({}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
text="Регистрация"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PastContestItem;
|
||||||
290
src/views/home/contests/UpcomingContestItem.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
import { toastWarning } from '../../../lib/toastNotification';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
|
import {
|
||||||
|
addOrUpdateContestMember,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
|
|
||||||
|
type Role = 'None' | 'Participant' | 'Organizer';
|
||||||
|
|
||||||
|
export interface UpcoingContestItemProps {
|
||||||
|
name: string;
|
||||||
|
contestId: number;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
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 dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [role, setRole] = useState<Role>('None');
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const { contests: myContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyContests,
|
||||||
|
);
|
||||||
|
const { contests: participantContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchParticipating,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.contests.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
const userId = useAppSelector((state) => state.auth.id);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRole(
|
||||||
|
(() => {
|
||||||
|
if (myContests?.some((c) => c.id === contestId)) {
|
||||||
|
return 'Organizer';
|
||||||
|
}
|
||||||
|
if (participantContests?.some((c) => c.id === contestId)) {
|
||||||
|
return 'Participant';
|
||||||
|
}
|
||||||
|
return 'None';
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}, [myContests, participantContests]);
|
||||||
|
|
||||||
|
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',
|
||||||
|
userId
|
||||||
|
? 'grid-cols-[1fr,1fr,220px,130px,130px,140px,150px]'
|
||||||
|
: 'grid-cols-[1fr,1fr,220px,130px,130px,130px]',
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!started) {
|
||||||
|
toastWarning('Контест еще не начался');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">
|
||||||
|
{highlightZ(name, nameFilter)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{userId && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{role == 'Organizer' || role == 'Participant' ? (
|
||||||
|
<ReverseButton
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
text="Войти"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(
|
||||||
|
addOrUpdateContestMember({
|
||||||
|
contestId: contestId,
|
||||||
|
member: {
|
||||||
|
userId: Number(userId),
|
||||||
|
role: 'Participant',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.then(() =>
|
||||||
|
dispatch(
|
||||||
|
fetchParticipatingContests({}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
text="Регистрация"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpcoingContestItem;
|
||||||
54
src/views/home/group/Group.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { useParams, Navigate, Routes, Route } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { fetchGroupById } from '../../../redux/slices/groups';
|
||||||
|
import GroupMenu from './GroupMenu';
|
||||||
|
import { Posts } from './posts/Posts';
|
||||||
|
import { Chat } from './chat/Chat';
|
||||||
|
import { Contests } from './contests/Contests';
|
||||||
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
|
|
||||||
|
interface GroupsBlockProps {}
|
||||||
|
|
||||||
|
const Group: FC<GroupsBlockProps> = () => {
|
||||||
|
const groupId = Number(useParams<{ groupId: string }>().groupId);
|
||||||
|
if (!groupId) {
|
||||||
|
return <Navigate to="/home/groups" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const group = useAppSelector((state) => state.groups.fetchGroupById.group);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setMenuActivePage('groups'));
|
||||||
|
dispatch(fetchGroupById(groupId));
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' h-screen w-full text-liquid-white p-[20px] flex gap-[20px] flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-bold text-[40px]">{group?.name}</div>
|
||||||
|
|
||||||
|
<GroupMenu groupId={groupId} />
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
<Route path="home" element={<Posts groupId={groupId} />} />
|
||||||
|
<Route path="chat" element={<Chat groupId={groupId} />} />
|
||||||
|
<Route
|
||||||
|
path="contests"
|
||||||
|
element={<Contests groupId={groupId} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={`/group/${groupId}/home`} />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Group;
|
||||||
96
src/views/home/group/GroupMenu.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { MessageChat, Home, Cup } from '../../../assets/icons/group';
|
||||||
|
|
||||||
|
import React, { FC } 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GroupMenuProps {
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMenu: FC<GroupMenuProps> = ({ groupId }) => {
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
text: 'Главная',
|
||||||
|
href: `/group/${groupId}/home`,
|
||||||
|
icon: Home,
|
||||||
|
page: 'group',
|
||||||
|
profilePage: 'home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Чат',
|
||||||
|
href: `/group/${groupId}/chat`,
|
||||||
|
icon: MessageChat,
|
||||||
|
page: 'group',
|
||||||
|
profilePage: 'chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Контесты',
|
||||||
|
href: `/group/${groupId}/contests`,
|
||||||
|
icon: Cup,
|
||||||
|
page: 'group',
|
||||||
|
profilePage: 'contests',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeGroupPage = useAppSelector(
|
||||||
|
(state) => state.store.menu.activeGroupPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full relative flex gap-[10px]">
|
||||||
|
{menuItems.map((v, i) => (
|
||||||
|
<MenuItem
|
||||||
|
{...v}
|
||||||
|
key={i}
|
||||||
|
active={activeGroupPage == v.profilePage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMenu;
|
||||||