Compare commits

..

4 Commits

Author SHA1 Message Date
04da2b565a Merge remote-tracking branch 'origin/dev'
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-11-05 20:56:37 +03:00
070edbfc42 Merge pull request 'dev' (#1) from dev into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 33s
Reviewed-on: #1
2025-11-02 22:02:11 +00:00
Виталий Лавшонок
a4480db444 Merge branch 'dev'
All checks were successful
Build and Push Docker Image / build (push) Successful in 46s
2025-10-27 18:01:55 +03:00
f2baf189e4 Добавлен CI
Some checks failed
Build and Push Docker Image / build (push) Failing after 39s
2025-10-27 17:45:16 +03:00
137 changed files with 8103 additions and 17133 deletions

View File

@@ -0,0 +1,35 @@
name: Build and Push Docker Image
on:
push:
branches: [ main ]
env:
REGISTRY: git.nullptr.top
IMAGE_NAME: git.nullptr.top/liquidcode/liquidcode-frontend
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: liquidcode-ci-service
password: ${{ secrets.SERVICE_ACCOUNT_TOKEN }}
- name: Build and Push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:latest,${{ env.IMAGE_NAME }}:${{ gitea.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max

2
.gitignore vendored
View File

@@ -11,7 +11,6 @@ node_modules
dist
dist-ssr
*.local
*.tsbuildinfo
# Editor directories and files
.vscode/*
@@ -24,4 +23,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runtime
WORKDIR /app
# Install a simple HTTP server to serve static files
RUN npm install -g serve
# Copy built application from build stage
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LiquidCode</title>
<title>Vite + React + TS</title>
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap" rel="stylesheet">

1434
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,14 +18,13 @@
"clsx": "^2.1.1",
"framer-motion": "^11.9.0",
"highlight.js": "^11.11.1",
"monaco-editor": "^0.53.0",
"monaco-editor": "^0.54.0",
"postcss": "^8.4.47",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -46,6 +45,6 @@
"globals": "^15.9.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^7.2.2"
"vite": "^5.4.1"
}
}

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 993 B

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -8,28 +8,18 @@ import Home from './pages/Home';
import Mission from './pages/Mission';
import ArticleEditor from './pages/ArticleEditor';
import Article from './pages/Article';
import ContestEditor from './pages/ContestEditor';
import ProtectedRoute from './components/router/ProtectedRoute';
function App() {
return (
<div className="w-full h-full bg-liquid-background flex justify-center">
<div className="relative w-full max-w-[1600px] h-full ">
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/home/*" element={<Home />} />
<Route path="/mission/:missionId" element={<Mission />} />
<Route
path="/article/create/*"
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="*" element={<Home />} />
</Routes>

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,7 +0,0 @@
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 };

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 389 B

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 324 B

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 324 B

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 469 B

View File

@@ -1,6 +0,0 @@
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 };

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 456 B

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 524 B

View File

@@ -5,5 +5,4 @@ import Edit from './edit.svg';
import UserAdd from './user-profile-add.svg';
import ChevroneDown from './chevron-down.svg';
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };

View File

@@ -5,8 +5,6 @@ import upload from './upload.svg';
import chevroneDropDownList from './chevron-drop-down.svg';
import checkMark from './check-mark.svg';
import Edit from './edit.svg';
import Send from './send.svg';
import Trash from './trash.svg';
export {
Edit,
@@ -16,6 +14,4 @@ export {
upload,
chevroneDropDownList,
checkMark,
Send,
Trash,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 472 B

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

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

View File

@@ -5,7 +5,7 @@ interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children?: React.ReactNode;
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
}
@@ -41,9 +41,6 @@ export const PrimaryButton: React.FC<ButtonProps> = ({
disabled && 'pointer-events-none',
className,
)}
onClick={(e) => {
e.stopPropagation();
}}
>
{/* Основной контейнер, */}
<div
@@ -63,8 +60,10 @@ export const PrimaryButton: React.FC<ButtonProps> = ({
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={() => {
onClick();
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
onClick(e);
}}
/>

View File

@@ -5,7 +5,7 @@ interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children?: React.ReactNode;
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
}
@@ -41,9 +41,6 @@ export const ReverseButton: React.FC<ButtonProps> = ({
disabled && 'pointer-events-none',
className,
)}
onClick={(e) => {
e.stopPropagation();
}}
>
{/* Основной контейнер, */}
<div
@@ -64,8 +61,10 @@ export const ReverseButton: React.FC<ButtonProps> = ({
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={() => {
onClick();
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
onClick(e);
}}
/>

View File

@@ -5,7 +5,7 @@ interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children?: React.ReactNode;
}
@@ -23,9 +23,6 @@ export const SecondaryButton: React.FC<ButtonProps> = ({
disabled && 'pointer-events-none',
className,
)}
onClick={(e) => {
e.stopPropagation();
}}
>
{/* Основной контейнер, */}
<div
@@ -44,8 +41,8 @@ export const SecondaryButton: React.FC<ButtonProps> = ({
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={() => {
onClick();
onClick={(e) => {
onClick(e);
}}
/>

View File

@@ -105,7 +105,6 @@ export const Checkbox: React.FC<CheckboxProps> = ({
<div
className={cn(
'group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300',
color == 'danger' && ' border-liquid-red',
sizeVariants[size],
radiusVraiants[radius],
active && borderColorsVariants[color],

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import { cn } from '../../lib/cn';
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
import { useClickOutside } from '../../hooks/useClickOutside';
@@ -14,7 +14,6 @@ interface DropDownListProps {
onChange: (state: string) => void;
defaultState?: DropDownListItem;
items: DropDownListItem[];
weight?: string;
}
export const DropDownList: React.FC<DropDownListProps> = ({
@@ -23,7 +22,6 @@ export const DropDownList: React.FC<DropDownListProps> = ({
onChange,
defaultState,
items = [{ text: '', value: '' }],
weight = 'w-[180px]',
}) => {
if (items.length == 0) items.push({ text: '', value: '' });
@@ -32,24 +30,21 @@ export const DropDownList: React.FC<DropDownListProps> = ({
);
const [active, setActive] = React.useState<boolean>(false);
React.useEffect(() => onChange(value.value), [value]);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => {
setActive(false);
});
useEffect(() => {
setValue(defaultState != undefined ? defaultState : items[0]);
}, [defaultState]);
return (
<div className={cn('relative', className)} ref={ref}>
<div
className={cn(
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px]',
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]',
'text-[18px] font-bold cursor-pointer select-none',
'transitin-all active:scale-95 duration-300',
weight,
)}
onClick={() => {
setActive(!active);
@@ -61,22 +56,21 @@ export const DropDownList: React.FC<DropDownListProps> = ({
<img
src={chevroneDropDownList}
className={cn(
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none select-none',
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none',
active && ' rotate-180',
)}
/>
<div
className={cn(
' absolute rounded-[10px] bg-liquid-lighter left-0 top-[48px] z-50 transition-all duration-300',
' absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300',
'grid overflow-hidden',
weight,
active
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
>
<div className=" overflow-hidden p-[8px] border-liquid-background border-solid border-[1px] rounded-[10px]">
<div className=" overflow-hidden p-[8px]">
<div
className={cn(
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
@@ -95,7 +89,6 @@ export const DropDownList: React.FC<DropDownListProps> = ({
)}
onClick={() => {
setValue(v);
onChange(v.value);
setActive(false);
}}
>

View File

@@ -1,125 +0,0 @@
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>
);
};

View File

@@ -1,129 +0,0 @@
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>
);
};

View File

@@ -1,161 +0,0 @@
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>
);
};

View File

@@ -1,35 +0,0 @@
// 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;

View File

@@ -27,7 +27,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
type="datetime-local"
value={startValue}
onChange={(e) => onChange('startsAt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div>
@@ -38,7 +38,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
type="datetime-local"
value={endValue}
onChange={(e) => onChange('endsAt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>

View File

@@ -11,7 +11,6 @@ interface inputProps {
label?: string;
placeholder?: string;
className?: string;
inputClassName?: string;
onChange: (state: string) => void;
defaultState?: string;
autocomplete?: string;
@@ -26,7 +25,6 @@ export const Input: React.FC<inputProps> = ({
label = '',
placeholder = '',
className = '',
inputClassName = '',
onChange,
defaultState = '',
name = '',
@@ -54,11 +52,10 @@ export const Input: React.FC<inputProps> = ({
className={cn(
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
type == 'password' ? 'h-[40px]' : 'h-[36px]',
inputClassName,
)}
value={value}
name={name}
autoComplete={autocomplete || undefined}
autoComplete={autocomplete}
type={
type == 'password'
? visible
@@ -87,7 +84,7 @@ export const Input: React.FC<inputProps> = ({
<div
className={cn(
'text-liquid-red text-[14px] h-auto text-right mt-[5px] whitespace-pre-line ',
'text-liquid-red text-[14px] h-[18px] text-right mt-[5px]',
error == '' && 'h-0 mt-0',
)}
>

View File

@@ -1,73 +0,0 @@
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>
);
};

View File

@@ -1,62 +0,0 @@
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>
);
};

View File

@@ -1,62 +0,0 @@
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;

View File

@@ -47,7 +47,7 @@ export const Modal: React.FC<ModalProps> = ({
exit={modalbgVariants.closed}
transition={{ duration: 0.15 }}
className={cn(
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-[background-color,backdrop-filter,opacity] z-50',
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50',
backdrop == 'blur' && open && 'backdrop-blur-sm',
backdrop == 'opaque' &&
open &&

View File

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

View File

@@ -1,34 +0,0 @@
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,
});
};

View File

@@ -6,13 +6,11 @@ import './styles/palette/theme-light.css';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from './redux/store';
import { ToastContainer } from 'react-toastify';
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<Provider store={store}>
<App />
<ToastContainer />
</Provider>
</BrowserRouter>,
);

View File

@@ -5,7 +5,6 @@ import { useEffect } from 'react';
import { fetchArticleById } from '../redux/slices/articles';
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
import { useQuery } from '../hooks/useQuery';
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
const Article = () => {
// Получаем параметры из URL
@@ -20,12 +19,8 @@ const Article = () => {
return <Navigate to="/home" replace />;
}
const dispatch = useAppDispatch();
const article = useAppSelector(
(state) => state.articles.fetchArticleById.article,
);
const status = useAppSelector(
(state) => state.articles.fetchArticleById.status,
);
const article = useAppSelector((state) => state.articles.currentArticle);
const status = useAppSelector((state) => state.articles.statuses.fetchById);
useEffect(() => {
dispatch(fetchArticleById(articleIdNumber));
@@ -70,7 +65,7 @@ const Article = () => {
)}
</div>
<ArticlesRightPanel />
<div className=""></div>
</div>
);
};

View File

@@ -15,7 +15,6 @@ import {
} from '../redux/slices/articles';
import { useQuery } from '../hooks/useQuery';
import { ReverseButton } from '../components/button/ReverseButton';
import { cn } from '../lib/cn';
const ArticleEditor = () => {
const navigate = useNavigate();
@@ -24,34 +23,26 @@ const ArticleEditor = () => {
const query = useQuery();
const back = query.get('back') ?? undefined;
const articleId = Number(query.get('articleId') ?? undefined);
const refactor = articleId && !isNaN(articleId);
const [clickSubmit, setClickSubmit] = useState<boolean>(false);
const article = useAppSelector((state) => state.articles.currentArticle);
const refactor = articleId != undefined && !isNaN(articleId);
// Достаём данные из redux
const article = useAppSelector(
(state) => state.articles.fetchArticleById.article,
);
const statusCreate = useAppSelector(
(state) => state.articles.createArticle.status,
);
const statusUpdate = useAppSelector(
(state) => state.articles.updateArticle.status,
);
const statusDelete = useAppSelector(
(state) => state.articles.deleteArticle.status,
);
// Локальные состояния
const [code, setCode] = useState<string>(article?.content || '');
const [name, setName] = useState<string>(article?.name || '');
const [tagInput, setTagInput] = useState<string>('');
const [tags, setTags] = useState<string[]>(article?.tags || []);
const [activeEditor, setActiveEditor] = useState<boolean>(false);
// ==========================
// Теги
// ==========================
const statusCreate = useAppSelector(
(state) => state.articles.statuses.create,
);
const statusUpdate = useAppSelector(
(state) => state.articles.statuses.update,
);
const statusDelete = useAppSelector(
(state) => state.articles.statuses.delete,
);
const addTag = () => {
const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) {
@@ -63,76 +54,54 @@ const ArticleEditor = () => {
const removeTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove));
};
// ==========================
// Эффекты по статусам
// ==========================
useEffect(() => {
if (statusCreate === 'successful') {
dispatch(
setArticlesStatus({ key: 'createArticle', status: 'idle' }),
);
navigate(back ?? '/home/articles');
if (statusCreate == 'successful') {
dispatch(setArticlesStatus({ key: 'create', status: 'idle' }));
navigate(back ? back : '/home/articles');
}
}, [statusCreate]);
useEffect(() => {
if (statusUpdate === 'successful') {
dispatch(
setArticlesStatus({ key: 'updateArticle', status: 'idle' }),
);
navigate(back ?? '/home/articles');
if (statusDelete == 'successful') {
dispatch(setArticlesStatus({ key: 'delete', status: 'idle' }));
navigate(back ? back : '/home/articles');
}
}, [statusDelete]);
useEffect(() => {
if (statusUpdate == 'successful') {
dispatch(setArticlesStatus({ key: 'update', status: 'idle' }));
navigate(back ? back : '/home/articles');
}
}, [statusUpdate]);
useEffect(() => {
if (statusDelete === 'successful') {
dispatch(
setArticlesStatus({ key: 'deleteArticle', status: 'idle' }),
);
navigate(back ?? '/home/articles');
}
}, [statusDelete]);
// ==========================
// Получение статьи
// ==========================
useEffect(() => {
setClickSubmit(false);
if (articleId) {
dispatch(fetchArticleById(articleId));
}
}, [articleId]);
// Обновление локального состояния после загрузки статьи
useEffect(() => {
if (article && refactor) {
setCode(article.content || '');
setName(article.name || '');
setTags(article.tags || []);
setCode(article?.content || '');
setName(article?.name || '');
setTags(article?.tags || []);
}
}, [article]);
const getNameErrorMessage = (): string => {
if (!clickSubmit) return '';
if (name == '') return 'Поле не может быть пустым';
return '';
};
const getContentErrorMessage = (): string => {
if (!clickSubmit) return '';
if (code == '') return 'Поле не может быть пустым';
return '';
};
// ==========================
// Рендер
// ==========================
return (
<div className="h-screen grid grid-rows-[60px,1fr]">
{activeEditor ? (
<Header backClick={() => setActiveEditor(false)} />
<Header
backClick={() => {
setActiveEditor(false);
}}
/>
) : (
<Header backClick={() => navigate(back ?? '/home/articles')} />
<Header
backClick={() => navigate(back ? back : '/home/articles')}
/>
)}
{activeEditor ? (
@@ -144,14 +113,11 @@ const ArticleEditor = () => {
? `Редактирование статьи: \"${article?.name}\"`
: 'Создание статьи'}
</div>
{/* Кнопки действий */}
<div>
{refactor ? (
<div className="flex gap-[20px]">
<PrimaryButton
onClick={() => {
setClickSubmit(true);
dispatch(
updateArticle({
articleId,
@@ -163,22 +129,21 @@ const ArticleEditor = () => {
}}
text="Обновить"
className="mt-[20px]"
disabled={statusUpdate === 'loading'}
disabled={statusUpdate == 'loading'}
/>
<ReverseButton
onClick={() =>
dispatch(deleteArticle(articleId))
}
onClick={() => {
dispatch(deleteArticle(articleId));
}}
color="error"
text="Удалить"
className="mt-[20px]"
disabled={statusDelete === 'loading'}
disabled={statusDelete == 'loading'}
/>
</div>
) : (
<PrimaryButton
onClick={() => {
setClickSubmit(true);
dispatch(
createArticle({
name,
@@ -189,12 +154,11 @@ const ArticleEditor = () => {
}}
text="Опубликовать"
className="mt-[20px]"
disabled={statusCreate === 'loading'}
disabled={statusCreate == 'loading'}
/>
)}
</div>
{/* Название */}
<Input
defaultState={name}
name="articleName"
@@ -202,12 +166,13 @@ const ArticleEditor = () => {
className="mt-[20px] max-w-[600px]"
type="text"
label="Название"
onChange={setName}
onChange={(v) => {
setName(v);
}}
placeholder="Новая статья"
error={getNameErrorMessage()}
/>
{/* Теги */}
{/* Блок для тегов */}
<div className="mt-[20px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
@@ -216,11 +181,14 @@ const ArticleEditor = () => {
className="mt-[20px] max-w-[600px]"
type="text"
label="Теги"
onChange={setTagInput}
onChange={(v) => {
setTagInput(v);
}}
defaultState={tagInput}
placeholder="arrays"
onKeyDown={(e) => {
if (e.key === 'Enter') addTag();
console.log(e.key);
if (e.key == 'Enter') addTag();
}}
/>
<PrimaryButton
@@ -247,23 +215,14 @@ const ArticleEditor = () => {
</div>
</div>
{/* Просмотр и переход в редактор */}
<PrimaryButton
onClick={() => setActiveEditor(true)}
text="Редактировать текст"
className="mt-[20px]"
/>
<div
className={cn(
'text-liquid-red text-[14px] h-auto mt-[5px] whitespace-pre-line ',
getContentErrorMessage() == '' && 'h-0 mt-0',
)}
>
{getContentErrorMessage()}
</div>
<MarkdownPreview
content={code}
className="bg-transparent border-liquid-lighter border-[3px] rounded-[20px] mt-[20px]"
className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]"
/>
</div>
)}

View File

@@ -1,610 +0,0 @@
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;

View File

@@ -1,25 +1,23 @@
// src/pages/Home.tsx
import { Navigate, Route, Routes } from 'react-router-dom';
// import React from "react";
import { Route, Routes } from 'react-router-dom';
import Login from '../views/home/auth/Login';
import Register from '../views/home/auth/Register';
import Menu from '../views/home/menu/Menu';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { useEffect } from 'react';
import { fetchWhoAmI } from '../redux/slices/auth';
import { fetchWhoAmI, logout } from '../redux/slices/auth';
import Missions from '../views/home/missions/Missions';
import Articles from '../views/home/articles/Articles';
import Groups from '../views/home/groups/Groups';
import Contests from '../views/home/contests/Contests';
import Group from '../views/home/group/Group';
import { PrimaryButton } from '../components/button/PrimaryButton';
import Group from '../views/home/groups/Group';
import Contest from '../views/home/contest/Contest';
import Account from '../views/home/account/Account';
import ProtectedRoute from '../components/router/ProtectedRoute';
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
import { GroupRightPanel } from '../views/home/rightpanel/group/Group';
import GroupInvite from '../views/home/groupinviter/GroupInvite';
const Home = () => {
const name = useAppSelector((state) => state.auth.username);
const jwt = useAppSelector((state) => state.auth.jwt);
const dispatch = useAppDispatch();
@@ -36,34 +34,45 @@ const Home = () => {
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="account/*" element={<Account />} />
<Route
path="group-invite/*"
element={<GroupInvite />}
/>
<Route path="group/:groupId/*" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
</Route>
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
<Route path="missions/*" element={<Missions />} />
<Route path="articles/*" element={<Articles />} />
<Route path="group/:groupId" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
<Route path="contests/*" element={<Contests />} />
<Route path="contest/:contestId/*" element={<Contest />} />
<Route
path="*"
element={<Navigate to="/home/account" replace />}
element={
<>
<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>
</div>
{
<Routes>
<Route path="articles/*" element={<ArticlesRightPanel />} />
<Route path="missions/*" element={<MissionsRightPanel />} />
<Route
path="group/:groupId/*"
element={<GroupRightPanel />}
/>
<Route path="articles/*" element={<div></div>} />
</Routes>
}
</div>

View File

@@ -1,35 +1,25 @@
import { useParams, Navigate, useNavigate } from 'react-router-dom';
import { useParams, Navigate } from 'react-router-dom';
import CodeEditor from '../views/mission/codeeditor/CodeEditor';
import Statement from '../views/mission/statement/Statement';
import { PrimaryButton } from '../components/button/PrimaryButton';
import { useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
import { fetchMissionById, setMissionsStatus } from '../redux/slices/missions';
import { fetchMissionById } from '../redux/slices/missions';
import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
import { useQuery } from '../hooks/useQuery';
import { fetchMyAttemptsInContest } from '../redux/slices/contests';
const Mission = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
// Получаем параметры из URL
const { missionId } = useParams<{ missionId: string }>();
const mission = useAppSelector((state) => state.missions.currentMission);
const missionStatus = useAppSelector(
(state) => state.missions.statuses.fetchById,
);
const attempt = useAppSelector(
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
);
const missionIdNumber = Number(missionId);
const query = useQuery();
const back = query.get('back') ?? undefined;
const contestId = Number(query.get('contestId') ?? undefined);
if (!missionId || isNaN(missionIdNumber)) {
if (back) return <Navigate to={back} replace />;
@@ -49,9 +39,6 @@ const Mission = () => {
if (pollingRef.current) return;
pollingRef.current = setInterval(async () => {
if (contestId) {
dispatch(fetchMyAttemptsInContest(contestId));
}
dispatch(fetchMySubmitsByMission(missionIdNumber));
const hasWaiting = submissionsRef.current.some(
@@ -71,12 +58,6 @@ const Mission = () => {
}, 5000); // 10 секунд
};
useEffect(() => {
if (contestId) {
dispatch(fetchMyAttemptsInContest(contestId));
}
}, [contestId]);
useEffect(() => {
dispatch(fetchMissionById(missionIdNumber));
dispatch(fetchMySubmitsByMission(missionIdNumber));
@@ -92,12 +73,6 @@ const Mission = () => {
}
};
}, []);
useEffect(() => {
if (missionStatus == 'failed') {
setMissionsStatus({ key: 'fetchById', status: 'idle' });
navigate(back ?? '/home/missions');
}
}, [missionStatus]);
useEffect(() => {
submissionsRef.current = submissions;
@@ -173,7 +148,9 @@ const Mission = () => {
html: htmlStatement.statementTexts['problem.html'],
mediaFiles: latexStatement.mediaFiles,
};
} catch (err) {}
} catch (err) {
console.error('Ошибка парсинга statementTexts:', err);
}
return (
<div className="h-screen grid grid-rows-[60px,1fr]">
@@ -208,8 +185,7 @@ const Mission = () => {
language: language,
languageVersion: 'latest',
sourceCode: code,
contestAttemptId:
attempt?.attemptId,
contestId: null,
}),
).unwrap();
dispatch(
@@ -222,10 +198,7 @@ const Mission = () => {
</div>
<div className="h-full w-full ">
<MissionSubmissions
missionId={missionIdNumber}
contestId={contestId}
/>
<MissionSubmissions missionId={missionIdNumber} />
</div>
</div>
</div>

View File

View File

@@ -1,10 +1,7 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
import { toastError } from '../../lib/toastNotification';
// =====================
// Типы
// =====================
// ─── Типы ────────────────────────────────────────────
type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -18,184 +15,39 @@ export interface Article {
updatedAt: string;
}
interface ArticlesResponse {
hasNextPage: boolean;
articles: Article[];
}
// =====================
// Состояние
// =====================
interface ArticlesState {
fetchArticles: {
articles: Article[];
currentArticle?: 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;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchAll: Status;
fetchById: Status;
};
error: string | null;
}
const initialState: ArticlesState = {
fetchArticles: {
articles: [],
currentArticle: undefined,
hasNextPage: false,
status: 'idle',
error: undefined,
},
fetchNewArticles: {
articles: [],
hasNextPage: false,
status: 'idle',
error: undefined,
},
fetchArticleById: {
article: undefined,
status: 'idle',
error: undefined,
},
createArticle: {
article: undefined,
status: 'idle',
error: undefined,
},
updateArticle: {
article: undefined,
status: 'idle',
error: undefined,
},
deleteArticle: {
status: 'idle',
error: undefined,
},
fetchMyArticles: {
articles: [],
status: 'idle',
error: undefined,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchAll: 'idle',
fetchById: 'idle',
},
error: null,
};
// =====================
// Async Thunks
// =====================
// ─── Async Thunks ─────────────────────────────────────
// Новые статьи
export const fetchNewArticles = createAsyncThunk(
'articles/fetchNewArticles',
async (
{
page = 0,
pageSize = 5,
tags,
}: { page?: number; pageSize?: number; tags?: string[] } = {},
{ rejectWithValue },
) => {
try {
const params: any = { page, pageSize };
if (tags && tags.length > 0) params.tags = tags;
const response = await axios.get<ArticlesResponse>('/articles', {
params,
paramsSerializer: {
indexes: null,
},
});
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
);
// Все статьи
export const fetchArticles = createAsyncThunk(
'articles/fetchArticles',
async (
{
page = 0,
pageSize = 100,
tags,
}: { page?: number; pageSize?: number; tags?: string[] } = {},
{ rejectWithValue },
) => {
try {
const params: any = { page, pageSize };
if (tags && tags.length > 0) params.tags = tags;
const response = await axios.get<ArticlesResponse>('/articles', {
params,
paramsSerializer: {
indexes: null,
},
});
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
);
// Мои статьи
export const fetchMyArticles = createAsyncThunk(
'articles/fetchMyArticles',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get<Article[]>('/articles/my');
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
);
// Статья по ID
export const fetchArticleById = createAsyncThunk(
'articles/fetchById',
async (articleId: number, { rejectWithValue }) => {
try {
const response = await axios.get<Article>(`/articles/${articleId}`);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
);
// Создание статьи
// POST /articles
export const createArticle = createAsyncThunk(
'articles/create',
'articles/createArticle',
async (
{
name,
@@ -205,21 +57,23 @@ export const createArticle = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const response = await axios.post<Article>('/articles', {
const response = await axios.post('/articles', {
name,
content,
tags,
});
return response.data;
return response.data as Article;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при создании статьи',
);
}
},
);
// Обновление статьи
// PUT /articles/{articleId}
export const updateArticle = createAsyncThunk(
'articles/update',
'articles/updateArticle',
async (
{
articleId,
@@ -230,221 +84,215 @@ export const updateArticle = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const response = await axios.put<Article>(
`/articles/${articleId}`,
{
const response = await axios.put(`/articles/${articleId}`, {
name,
content,
tags,
},
);
return response.data;
});
return response.data as Article;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при обновлении статьи',
);
}
},
);
// Удаление статьи
// DELETE /articles/{articleId}
export const deleteArticle = createAsyncThunk(
'articles/delete',
'articles/deleteArticle',
async (articleId: number, { rejectWithValue }) => {
try {
await axios.delete(`/articles/${articleId}`);
return articleId;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении статьи',
);
}
},
);
// =====================
// Slice
// =====================
// GET /articles
export const fetchArticles = createAsyncThunk(
'articles/fetchArticles',
async (
{
page = 0,
pageSize = 10,
tags,
}: { page?: number; pageSize?: number; tags?: string[] },
{ rejectWithValue },
) => {
try {
const params: any = { page, pageSize };
if (tags && tags.length > 0) params.tags = tags;
const response = await axios.get('/articles', { params });
return response.data as {
hasNextPage: boolean;
articles: Article[];
};
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статей',
);
}
},
);
// GET /articles/{articleId}
export const fetchArticleById = createAsyncThunk(
'articles/fetchArticleById',
async (articleId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/articles/${articleId}`);
return response.data as Article;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статьи',
);
}
},
);
// ─── Slice ────────────────────────────────────────────
const articlesSlice = createSlice({
name: 'articles',
initialState,
reducers: {
clearCurrentArticle: (state) => {
state.currentArticle = undefined;
},
setArticlesStatus: (
state,
action: PayloadAction<{ key: keyof ArticlesState; status: Status }>,
action: PayloadAction<{
key: keyof ArticlesState['statuses'];
status: Status;
}>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
state.statuses[key] = status;
},
},
extraReducers: (builder) => {
// fetchArticles
builder.addCase(fetchArticles.pending, (state) => {
state.fetchArticles.status = 'loading';
state.fetchArticles.error = undefined;
});
builder.addCase(
fetchArticles.fulfilled,
(state, action: PayloadAction<ArticlesResponse>) => {
state.fetchArticles.status = 'successful';
state.fetchArticles.articles = action.payload.articles;
state.fetchArticles.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(fetchArticles.rejected, (state, action: any) => {
state.fetchArticles.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// fetchNewArticles
builder.addCase(fetchNewArticles.pending, (state) => {
state.fetchNewArticles.status = 'loading';
state.fetchNewArticles.error = undefined;
});
builder.addCase(
fetchNewArticles.fulfilled,
(state, action: PayloadAction<ArticlesResponse>) => {
state.fetchNewArticles.status = 'successful';
state.fetchNewArticles.articles = action.payload.articles;
state.fetchNewArticles.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(fetchNewArticles.rejected, (state, action: any) => {
state.fetchNewArticles.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// fetchMyArticles
builder.addCase(fetchMyArticles.pending, (state) => {
state.fetchMyArticles.status = 'loading';
state.fetchMyArticles.error = undefined;
});
builder.addCase(
fetchMyArticles.fulfilled,
(state, action: PayloadAction<Article[]>) => {
state.fetchMyArticles.status = 'successful';
state.fetchMyArticles.articles = action.payload;
},
);
builder.addCase(fetchMyArticles.rejected, (state, action: any) => {
state.fetchMyArticles.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// fetchArticleById
builder.addCase(fetchArticleById.pending, (state) => {
state.fetchArticleById.status = 'loading';
state.fetchArticleById.error = undefined;
});
builder.addCase(
fetchArticleById.fulfilled,
(state, action: PayloadAction<Article>) => {
state.fetchArticleById.status = 'successful';
state.fetchArticleById.article = action.payload;
},
);
builder.addCase(fetchArticleById.rejected, (state, action: any) => {
state.fetchArticleById.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// createArticle
// ─── CREATE ARTICLE ───
builder.addCase(createArticle.pending, (state) => {
state.createArticle.status = 'loading';
state.createArticle.error = undefined;
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.createArticle.status = 'successful';
state.createArticle.article = action.payload;
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;
},
);
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
// ─── UPDATE ARTICLE ───
builder.addCase(updateArticle.pending, (state) => {
state.updateArticle.status = 'loading';
state.updateArticle.error = undefined;
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.updateArticle.status = 'successful';
state.updateArticle.article = action.payload;
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;
},
);
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
// ─── DELETE ARTICLE ───
builder.addCase(deleteArticle.pending, (state) => {
state.deleteArticle.status = 'loading';
state.deleteArticle.error = undefined;
state.statuses.delete = 'loading';
state.error = null;
});
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(
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: any) => {
state.deleteArticle.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
builder.addCase(
deleteArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH ARTICLES ───
builder.addCase(fetchArticles.pending, (state) => {
state.statuses.fetchAll = 'loading';
state.error = null;
});
builder.addCase(
fetchArticles.fulfilled,
(
state,
action: PayloadAction<{
hasNextPage: boolean;
articles: Article[];
}>,
) => {
state.statuses.fetchAll = 'successful';
state.articles = action.payload.articles;
state.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(
fetchArticles.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchAll = 'failed';
state.error = action.payload;
},
);
// ─── FETCH ARTICLE BY ID ───
builder.addCase(fetchArticleById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
});
builder.addCase(
fetchArticleById.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.fetchById = 'successful';
state.currentArticle = action.payload;
},
);
builder.addCase(
fetchArticleById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
},
);
},
});
export const { setArticlesStatus } = articlesSlice.actions;
export const { clearCurrentArticle, setArticlesStatus } = articlesSlice.actions;
export const articlesReducer = articlesSlice.reducer;

View File

@@ -1,8 +1,5 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from '../../axios';
import { toastError } from '../../lib/toastNotification';
type Status = 'idle' | 'loading' | 'successful' | 'failed';
// 🔹 Декодирование JWT
function decodeJwt(token: string) {
@@ -18,12 +15,8 @@ interface AuthState {
username: string | null;
email: string | null;
id: string | null;
status: Status;
status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null;
register: {
errors?: Record<string, string[]>;
status: Status;
};
}
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
@@ -38,9 +31,6 @@ const initialState: AuthState = {
id: null,
status: 'idle',
error: null,
register: {
status: 'idle',
},
};
// Если токен есть, подставляем в axios и декодируем
@@ -86,7 +76,9 @@ export const registerUser = createAsyncThunk(
});
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.errors ? err.response?.data?.errors : {"error" : [err.response?.data]});
return rejectWithValue(
err.response?.data?.message || 'Registration failed',
);
}
},
);
@@ -129,26 +121,11 @@ export const refreshToken = createAsyncThunk(
export const fetchWhoAmI = createAsyncThunk(
'auth/whoami',
async (_, { dispatch, getState, rejectWithValue }) => {
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/authentication/whoami');
return response.data;
} 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(
err.response?.data?.message || 'Failed to fetch user info',
);
@@ -173,15 +150,6 @@ const authSlice = createSlice({
localStorage.removeItem('refreshToken');
delete axios.defaults.headers.common['Authorization'];
},
setAuthStatus: (
state,
action: PayloadAction<{ key: keyof AuthState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// ----------------- Register -----------------
@@ -216,12 +184,7 @@ const authSlice = createSlice({
});
builder.addCase(registerUser.rejected, (state, action) => {
state.status = 'failed';
state.register.errors = action.payload as Record<string, string[]>;
Object.values(state.register.errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
state.error = action.payload as string;
});
// ----------------- Login -----------------
@@ -306,25 +269,9 @@ const authSlice = createSlice({
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
// Если пользователь не авторизован (401), делаем logout и пытаемся refresh
if (
action.payload === 'Unauthorized' ||
action.payload === 'Failed to fetch user info'
) {
// Вызов logout
state.jwt = null;
state.refreshToken = null;
state.username = null;
state.email = null;
state.id = null;
localStorage.removeItem('jwt');
localStorage.removeItem('refreshToken');
delete axios.defaults.headers.common['Authorization'];
}
});
},
});
export const { logout, setAuthStatus } = authSlice.actions;
export const { logout } = authSlice.actions;
export const authReducer = authSlice.reducer;

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
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;

View File

@@ -1,368 +0,0 @@
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;

View File

@@ -1,10 +1,7 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
import { toastError } from '../../lib/toastNotification';
// =====================
// Типы
// =====================
// ─── Типы ────────────────────────────────────────────
type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -22,106 +19,39 @@ export interface Group {
contests: any[];
}
// =====================
// Состояние
// =====================
interface GroupsState {
fetchMyGroups: {
groups: Group[];
status: Status;
error?: string;
};
fetchGroupById: {
group?: Group;
status: Status;
error?: string;
};
createGroup: {
group?: Group;
status: Status;
error?: string;
};
updateGroup: {
group?: Group;
status: Status;
error?: string;
};
deleteGroup: {
deletedId?: number;
status: Status;
error?: string;
};
addGroupMember: {
status: Status;
error?: string;
};
removeGroupMember: {
status: Status;
error?: string;
};
fetchGroupJoinLink: {
joinLink?: { token: string; expiresAt: string };
status: Status;
error?: string;
};
joinGroupByToken: {
group?: Group;
status: Status;
error?: string;
currentGroup: Group | null;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchMy: Status;
fetchById: Status;
addMember: Status;
removeMember: Status;
};
error: string | null;
}
const initialState: GroupsState = {
fetchMyGroups: {
groups: [],
status: 'idle',
error: undefined,
},
fetchGroupById: {
group: undefined,
status: 'idle',
error: undefined,
},
createGroup: {
group: undefined,
status: 'idle',
error: undefined,
},
updateGroup: {
group: undefined,
status: 'idle',
error: undefined,
},
deleteGroup: {
deletedId: undefined,
status: 'idle',
error: undefined,
},
addGroupMember: {
status: 'idle',
error: undefined,
},
removeGroupMember: {
status: 'idle',
error: undefined,
},
fetchGroupJoinLink: {
joinLink: undefined,
status: 'idle',
error: undefined,
},
joinGroupByToken: {
group: undefined,
status: 'idle',
error: undefined,
currentGroup: null,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchMy: 'idle',
fetchById: 'idle',
addMember: 'idle',
removeMember: 'idle',
},
error: null,
};
// =====================
// Async Thunks
// =====================
// ─── Async Thunks ─────────────────────────────────────
// POST /groups
export const createGroup = createAsyncThunk(
'groups/createGroup',
async (
@@ -132,11 +62,14 @@ export const createGroup = createAsyncThunk(
const response = await axios.post('/groups', { name, description });
return response.data as Group;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при создании группы',
);
}
},
);
// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk(
'groups/updateGroup',
async (
@@ -154,11 +87,14 @@ export const updateGroup = createAsyncThunk(
});
return response.data as Group;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при обновлении группы',
);
}
},
);
// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk(
'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => {
@@ -166,11 +102,14 @@ export const deleteGroup = createAsyncThunk(
await axios.delete(`/groups/${groupId}`);
return groupId;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении группы',
);
}
},
);
// GET /groups/my
export const fetchMyGroups = createAsyncThunk(
'groups/fetchMyGroups',
async (_, { rejectWithValue }) => {
@@ -178,11 +117,14 @@ export const fetchMyGroups = createAsyncThunk(
const response = await axios.get('/groups/my');
return response.data.groups as Group[];
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении групп',
);
}
},
);
// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk(
'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => {
@@ -190,33 +132,33 @@ export const fetchGroupById = createAsyncThunk(
const response = await axios.get(`/groups/${groupId}`);
return response.data as Group;
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении группы',
);
}
},
);
// POST /groups/members
export const addGroupMember = createAsyncThunk(
'groups/addGroupMember',
async (
{
groupId,
userId,
role,
}: { groupId: number; userId: number; role: string },
{ userId, role }: { userId: number; role: string },
{ rejectWithValue },
) => {
try {
const response = await axios.post(`/groups/${groupId}/members`, {
userId,
role,
});
return response.data;
await axios.post('/groups/members', { userId, role });
return { userId, role };
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при добавлении участника',
);
}
},
);
// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk(
'groups/removeGroupMember',
async (
@@ -227,204 +169,154 @@ export const removeGroupMember = createAsyncThunk(
await axios.delete(`/groups/${groupId}/members/${memberId}`);
return { groupId, memberId };
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении участника',
);
}
},
);
// =====================
// Новые 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
// =====================
// ─── Slice ────────────────────────────────────────────
const groupsSlice = createSlice({
name: 'groups',
initialState,
reducers: {
setGroupsStatus: (
state,
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
clearCurrentGroup: (state) => {
state.currentGroup = null;
},
},
extraReducers: (builder) => {
// fetchMyGroups
builder.addCase(fetchMyGroups.pending, (state) => {
state.fetchMyGroups.status = 'loading';
});
builder.addCase(
fetchMyGroups.fulfilled,
(state, action: PayloadAction<Group[]>) => {
state.fetchMyGroups.status = 'successful';
state.fetchMyGroups.groups = action.payload;
},
);
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
state.fetchMyGroups.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// fetchGroupById
builder.addCase(fetchGroupById.pending, (state) => {
state.fetchGroupById.status = 'loading';
});
builder.addCase(
fetchGroupById.fulfilled,
(state, action: PayloadAction<Group>) => {
state.fetchGroupById.status = 'successful';
state.fetchGroupById.group = action.payload;
},
);
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
state.fetchGroupById.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// createGroup
// ─── CREATE GROUP ───
builder.addCase(createGroup.pending, (state) => {
state.createGroup.status = 'loading';
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.createGroup.status = 'successful';
state.createGroup.group = action.payload;
state.fetchMyGroups.groups.push(action.payload);
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;
},
);
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
// ─── UPDATE GROUP ───
builder.addCase(updateGroup.pending, (state) => {
state.updateGroup.status = 'loading';
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.updateGroup.status = 'successful';
state.updateGroup.group = action.payload;
const index = state.fetchMyGroups.groups.findIndex(
state.statuses.update = 'successful';
const index = state.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;
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;
},
);
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
// ─── DELETE GROUP ───
builder.addCase(deleteGroup.pending, (state) => {
state.deleteGroup.status = 'loading';
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteGroup.fulfilled,
(state, action: PayloadAction<number>) => {
state.deleteGroup.status = 'successful';
state.deleteGroup.deletedId = action.payload;
state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter(
state.statuses.delete = 'successful';
state.groups = state.groups.filter(
(g) => g.id !== action.payload,
);
if (state.fetchGroupById.group?.id === action.payload)
state.fetchGroupById.group = undefined;
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;
},
);
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);
});
});
// ─── FETCH MY GROUPS ───
builder.addCase(fetchMyGroups.pending, (state) => {
state.statuses.fetchMy = 'loading';
state.error = null;
});
builder.addCase(
fetchMyGroups.fulfilled,
(state, action: PayloadAction<Group[]>) => {
state.statuses.fetchMy = 'successful';
state.groups = action.payload;
},
);
builder.addCase(
fetchMyGroups.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
state.error = action.payload;
},
);
// addGroupMember
// ─── FETCH GROUP BY ID ───
builder.addCase(fetchGroupById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
});
builder.addCase(
fetchGroupById.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.fetchById = 'successful';
state.currentGroup = action.payload;
},
);
builder.addCase(
fetchGroupById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
},
);
// ─── ADD MEMBER ───
builder.addCase(addGroupMember.pending, (state) => {
state.addGroupMember.status = 'loading';
state.statuses.addMember = 'loading';
state.error = null;
});
builder.addCase(addGroupMember.fulfilled, (state) => {
state.addGroupMember.status = 'successful';
state.statuses.addMember = 'successful';
});
builder.addCase(addGroupMember.rejected, (state, action: any) => {
state.addGroupMember.status = 'failed';
builder.addCase(
addGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.addMember = 'failed';
state.error = action.payload;
},
);
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// removeGroupMember
// ─── REMOVE MEMBER ───
builder.addCase(removeGroupMember.pending, (state) => {
state.removeGroupMember.status = 'loading';
state.statuses.removeMember = 'loading';
state.error = null;
});
builder.addCase(
removeGroupMember.fulfilled,
@@ -432,78 +324,27 @@ const groupsSlice = createSlice({
state,
action: PayloadAction<{ groupId: number; memberId: number }>,
) => {
state.removeGroupMember.status = 'successful';
state.statuses.removeMember = 'successful';
if (
state.fetchGroupById.group &&
state.fetchGroupById.group.id === action.payload.groupId
state.currentGroup &&
state.currentGroup.id === action.payload.groupId
) {
state.fetchGroupById.group.members =
state.fetchGroupById.group.members.filter(
state.currentGroup.members =
state.currentGroup.members.filter(
(m) => m.userId !== action.payload.memberId,
);
}
},
);
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
state.removeGroupMember.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// fetchGroupJoinLink
builder.addCase(fetchGroupJoinLink.pending, (state) => {
state.fetchGroupJoinLink.status = 'loading';
});
builder.addCase(
fetchGroupJoinLink.fulfilled,
(
state,
action: PayloadAction<{ token: string; expiresAt: string }>,
) => {
state.fetchGroupJoinLink.status = 'successful';
state.fetchGroupJoinLink.joinLink = action.payload;
removeGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.removeMember = 'failed';
state.error = action.payload;
},
);
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
state.fetchGroupJoinLink.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// joinGroupByToken
builder.addCase(joinGroupByToken.pending, (state) => {
state.joinGroupByToken.status = 'loading';
});
builder.addCase(
joinGroupByToken.fulfilled,
(state, action: PayloadAction<Group>) => {
state.joinGroupByToken.status = 'successful';
state.joinGroupByToken.group = action.payload;
state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список
},
);
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
state.joinGroupByToken.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
},
});
export const { setGroupsStatus } = groupsSlice.actions;
export const { clearCurrentGroup } = groupsSlice.actions;
export const groupsReducer = groupsSlice.reducer;

View File

@@ -1,6 +1,5 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
import { toastError } from '../../lib/toastNotification';
// ─── Типы ────────────────────────────────────────────
@@ -21,25 +20,17 @@ export interface Mission {
tags: string[];
createdAt: string;
updatedAt: string;
timeLimit: number;
memoryLimit: number;
statements?: Statement[];
}
interface MissionsState {
missions: Mission[];
newMissions: Mission[];
currentMission: Mission | null;
hasNextPage: boolean;
create: {
errors?: Record<string, string[]>;
};
statuses: {
fetchList: Status;
fetchById: Status;
upload: Status;
fetchMy: Status;
delete: Status;
};
error: string | null;
}
@@ -48,16 +39,12 @@ interface MissionsState {
const initialState: MissionsState = {
missions: [],
newMissions: [],
currentMission: null,
hasNextPage: false,
create: {},
statuses: {
fetchList: 'idle',
fetchById: 'idle',
upload: 'idle',
fetchMy: 'idle',
delete: 'idle',
},
error: null,
};
@@ -67,33 +54,6 @@ const initialState: MissionsState = {
// GET /missions
export const fetchMissions = createAsyncThunk(
'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 (
{
page = 0,
@@ -105,15 +65,12 @@ export const fetchNewMissions = createAsyncThunk(
try {
const params: any = { page, pageSize };
if (tags.length) params.tags = tags;
const response = await axios.get('/missions', {
params,
paramsSerializer: {
indexes: null,
},
});
const response = await axios.get('/missions', { params });
return response.data; // { missions, hasNextPage }
} catch (err: any) {
return rejectWithValue(err.response?.data);
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении миссий',
);
}
},
);
@@ -126,20 +83,9 @@ export const fetchMissionById = createAsyncThunk(
const response = await axios.get(`/missions/${id}`);
return response.data; // Mission
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
return rejectWithValue(
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);
}
},
);
@@ -168,20 +114,9 @@ export const uploadMission = createAsyncThunk(
});
return response.data; // Mission
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
return rejectWithValue(
err.response?.data?.message || 'Ошибка при загрузке миссии',
);
// 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);
}
},
);
@@ -192,6 +127,9 @@ const missionsSlice = createSlice({
name: 'missions',
initialState,
reducers: {
clearCurrentMission: (state) => {
state.currentMission = null;
},
setMissionsStatus: (
state,
action: PayloadAction<{
@@ -227,52 +165,7 @@ const missionsSlice = createSlice({
fetchMissions.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchList = 'failed';
const errors = action.payload.errors as Record<
string,
string[]
>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
},
);
// ─── FETCH 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);
});
});
state.error = action.payload;
},
);
@@ -292,45 +185,7 @@ const missionsSlice = createSlice({
fetchMissionById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
const errors = action.payload.errors as Record<
string,
string[]
>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
},
);
// ✅ FETCH MY MISSIONS ───
builder.addCase(fetchMyMissions.pending, (state) => {
state.statuses.fetchMy = 'loading';
state.error = null;
});
builder.addCase(
fetchMyMissions.fulfilled,
(state, action: PayloadAction<Mission[]>) => {
state.statuses.fetchMy = 'successful';
state.missions = action.payload;
},
);
builder.addCase(
fetchMyMissions.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
const errors = action.payload.errors as Record<
string,
string[]
>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
state.error = action.payload;
},
);
@@ -350,57 +205,11 @@ const missionsSlice = createSlice({
uploadMission.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.upload = 'failed';
const errors = action.payload.errors as Record<
string,
string[]
>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
state.create.errors = errors;
},
);
// ─── DELETE MISSION ───
builder.addCase(deleteMission.pending, (state) => {
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteMission.fulfilled,
(state, action: PayloadAction<number>) => {
state.statuses.delete = 'successful';
state.missions = state.missions.filter(
(m) => m.id !== action.payload,
);
if (state.currentMission?.id === action.payload) {
state.currentMission = null;
}
},
);
builder.addCase(
deleteMission.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
const errors = action.payload.errors as Record<
string,
string[]
>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
state.error = action.payload;
},
);
},
});
export const { setMissionsStatus } = missionsSlice.actions;
export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions;
export const missionsReducer = missionsSlice.reducer;

View File

@@ -1,395 +0,0 @@
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;

View File

@@ -5,22 +5,6 @@ interface StorState {
menu: {
activePage: string;
activeProfilePage: string;
activeGroupPage: string;
};
group: {
groupFilter: string;
};
articles: {
articleTagFilter: string[];
filterName: string;
};
contests: {
contestsTagFilter: string[];
filterName: string;
};
missions: {
missionsTagFilter: string[];
filterName: string;
};
}
@@ -29,22 +13,6 @@ const initialState: StorState = {
menu: {
activePage: '',
activeProfilePage: '',
activeGroupPage: '',
},
group: {
groupFilter: '',
},
articles: {
articleTagFilter: [],
filterName: '',
},
contests: {
contestsTagFilter: [],
filterName: '',
},
missions: {
missionsTagFilter: [],
filterName: '',
},
};
@@ -53,63 +21,18 @@ const storeSlice = createSlice({
name: 'store',
initialState,
reducers: {
setMenuActivePage: (state, action: PayloadAction<string>) => {
state.menu.activePage = action.payload;
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
state.menu.activePage = activePage.payload;
},
setMenuActiveProfilePage: (state, action: PayloadAction<string>) => {
state.menu.activeProfilePage = action.payload;
},
setMenuActiveGroupPage: (state, action: PayloadAction<string>) => {
state.menu.activeGroupPage = action.payload;
},
setGroupFilter: (state, action: PayloadAction<string>) => {
state.group.groupFilter = action.payload;
},
// ---------- ARTICLES ----------
setArticlesTagFilter: (state, action: PayloadAction<string[]>) => {
state.articles.articleTagFilter = action.payload;
},
setArticlesNameFilter: (state, action: PayloadAction<string>) => {
state.articles.filterName = action.payload;
},
// ---------- CONTESTS ----------
setContestsTagFilter: (state, action: PayloadAction<string[]>) => {
state.contests.contestsTagFilter = action.payload;
},
setContestsNameFilter: (state, action: PayloadAction<string>) => {
state.contests.filterName = action.payload;
},
// ---------- MISSIONS ----------
setMissionsTagFilter: (state, action: PayloadAction<string[]>) => {
state.missions.missionsTagFilter = action.payload;
},
setMissionsNameFilter: (state, action: PayloadAction<string>) => {
state.missions.filterName = action.payload;
setMenuActiveProfilePage: (
state,
activeProfilePage: PayloadAction<string>,
) => {
state.menu.activeProfilePage = activeProfilePage.payload;
},
},
});
export const {
// menu
setMenuActivePage,
setMenuActiveProfilePage,
setMenuActiveGroupPage,
setGroupFilter,
// articles
setArticlesTagFilter,
setArticlesNameFilter,
// contests
setContestsTagFilter,
setContestsNameFilter,
// missions
setMissionsTagFilter,
setMissionsNameFilter,
} = storeSlice.actions;
export const { setMenuActivePage, setMenuActiveProfilePage } =
storeSlice.actions;
export const storeReducer = storeSlice.reducer;

View File

@@ -8,7 +8,7 @@ export interface Submit {
language: string;
languageVersion: string;
sourceCode: string;
contestAttemptId?: number;
contestId: number | null;
}
export interface Solution {
@@ -30,8 +30,8 @@ export interface MissionSubmit {
id: number;
userId: number;
solution: Solution;
contestId?: number;
contestName?: string;
contestId: number | null;
contestName: string | null;
sourceType: string;
}
@@ -40,7 +40,7 @@ interface SubmitState {
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
currentSubmit?: Submit;
status: 'idle' | 'loading' | 'successful' | 'failed';
error?: string;
error: string | null;
}
// Начальное состояние
@@ -49,7 +49,7 @@ const initialState: SubmitState = {
submitsById: {}, // ✅ инициализация
currentSubmit: undefined,
status: 'idle',
error: undefined,
error: null,
};
// AsyncThunk: Отправка решения
@@ -123,7 +123,7 @@ const submitSlice = createSlice({
clearCurrentSubmit: (state) => {
state.currentSubmit = undefined;
state.status = 'idle';
state.error = undefined;
state.error = null;
},
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
delete state.submitsById[action.payload];
@@ -133,7 +133,7 @@ const submitSlice = createSlice({
// Отправка решения
builder.addCase(submitMission.pending, (state) => {
state.status = 'loading';
state.error = undefined;
state.error = null;
});
builder.addCase(
submitMission.fulfilled,
@@ -153,7 +153,7 @@ const submitSlice = createSlice({
// Получить все свои отправки
builder.addCase(fetchMySubmits.pending, (state) => {
state.status = 'loading';
state.error = undefined;
state.error = null;
});
builder.addCase(
fetchMySubmits.fulfilled,
@@ -173,7 +173,7 @@ const submitSlice = createSlice({
// Получить отправку по ID
builder.addCase(fetchSubmitById.pending, (state) => {
state.status = 'loading';
state.error = undefined;
state.error = null;
});
builder.addCase(
fetchSubmitById.fulfilled,
@@ -193,7 +193,7 @@ const submitSlice = createSlice({
// ✅ Получить отправки по миссии
builder.addCase(fetchMySubmitsByMission.pending, (state) => {
state.status = 'loading';
state.error = undefined;
state.error = null;
});
builder.addCase(
fetchMySubmitsByMission.fulfilled,

View File

@@ -6,9 +6,6 @@ import { submitReducer } from './slices/submit';
import { contestsReducer } from './slices/contests';
import { groupsReducer } from './slices/groups';
import { articlesReducer } from './slices/articles';
import { groupFeedReducer } from './slices/groupfeed';
import { groupChatReducer } from './slices/groupChat';
import { profileReducer } from './slices/profile';
// использование
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
@@ -28,9 +25,6 @@ export const store = configureStore({
contests: contestsReducer,
groups: groupsReducer,
articles: articlesReducer,
groupfeed: groupFeedReducer,
groupchat: groupChatReducer,
profile: profileReducer,
},
});

View File

@@ -3,7 +3,6 @@
@import 'tailwindcss/utilities';
@import './latex-container.css';
@import './toast.css';
* {
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/

View File

@@ -1,32 +0,0 @@
.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);
}

View File

@@ -4,7 +4,18 @@ import 'highlight.js/styles/github-dark.css';
import MarkdownPreview from './MarckDownPreview';
export const MarkDownPattern = `# 🌙 Добро пожаловать в Markdown-редактор
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue ||
`# 🌙 Добро пожаловать в Markdown-редактор
Добро пожаловать в **Markdown-редактор**!
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
@@ -68,14 +79,14 @@ function greet(user: User) {
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
}
consol.log(greet({ name: "Ты", role: "Разработчик" }));
console.log(greet({ name: "Ты", role: "Разработчик" }));
\`\`\`
Пример **JavaScript**:
\`\`\`js
const sum = (a, b) => a + b;
consol.log(sum(2, 3)); // 5
console.log(sum(2, 3)); // 5
\`\`\`
Пример **Python**:
@@ -198,29 +209,13 @@ print(greet("Мир"))
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
`;
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue || MarkDownPattern,
`,
);
useEffect(() => {
onChange(markdown);
}, [markdown]);
useEffect(() => {
setMarkdown(defaultValue || MarkDownPattern);
}, [defaultValue]);
// Обработчик вставки
const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement>,
@@ -256,7 +251,9 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
markdown.slice(cursorPos);
setMarkdown(newText);
} catch (err) {}
} catch (err) {
console.error('Ошибка загрузки изображения:', err);
}
}
}
};

View File

@@ -30,7 +30,12 @@ const MarkdownPreview: FC<MarkdownPreviewProps> = ({
className = '',
}) => {
return (
<div className={cn('flex-1 bg-[#161b22] rounded-lg p-6', className)}>
<div
className={cn(
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
className,
)}
>
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
<ReactMarkdown
remarkPlugins={[remarkGfm]}

View File

@@ -1,54 +1,22 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AccountMenu from './AccoutMenu';
import RightPanel from './RightPanel';
import Missions from './missions/Missions';
import Contests from './contests/Contests';
import ArticlesBlock from './articles/ArticlesBlock';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import MissionsBlock from './MissionsBlock';
import ContestsBlock from './ContestsBlock';
import ArticlesBlock from './ArticlesBlock';
import { useAppDispatch } from '../../../redux/hooks';
import { useEffect } from 'react';
import { setMenuActivePage } from '../../../redux/slices/store';
import { useQuery } from '../../../hooks/useQuery';
import {
fetchProfile,
fetchProfileArticles,
fetchProfileContests,
fetchProfileMissions,
} from '../../../redux/slices/profile';
const Account = () => {
const dispatch = useAppDispatch();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
useEffect(() => {
if (username == myname) {
dispatch(setMenuActivePage('account'));
} else {
dispatch(setMenuActivePage(''));
}
dispatch(
fetchProfileMissions({
username: username,
recentPageSize: 1,
authoredPageSize: 100,
}),
);
dispatch(fetchProfileArticles({ username: username, pageSize: 100 }));
dispatch(
fetchProfileContests({
username: username,
pastPageSize: 100,
minePageSize: 100,
upcomingPageSize: 100,
}),
);
dispatch(fetchProfile(username));
}, [username]);
}, []);
return (
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,430px] relative divide-x-[1px] divide-liquid-lighter">
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,520px] relative divide-x-[1px] divide-liquid-lighter">
<div className=" h-full min-h-0 flex flex-col">
<div className=" h-full grid grid-rows-[80px,1fr] ">
<div className="h-full w-full">
@@ -56,12 +24,18 @@ const Account = () => {
</div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px] ">
<Routes>
<Route path="missions" element={<Missions />} />
<Route
path="missions"
element={<MissionsBlock />}
/>
<Route
path="articles"
element={<ArticlesBlock />}
/>
<Route path="contests" element={<Contests />} />
<Route
path="contests"
element={<ContestsBlock />}
/>
<Route
path="*"
element={

View File

@@ -76,6 +76,8 @@ const AccountMenu = () => {
(state) => state.store.menu.activeProfilePage,
);
console.log('active', [activeProfilePage]);
return (
<div className="h-full w-full relative flex p-[20px] gap-[10px]">
{menuItems.map((v, i) => (

View File

@@ -0,0 +1,124 @@
import { FC, useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
import { cn } from '../../../lib/cn';
import { ChevroneDown, Edit } from '../../../assets/icons/groups';
import { fetchArticles } from '../../../redux/slices/articles';
import { useNavigate } from 'react-router-dom';
export interface ArticleItemProps {
id: number;
name: string;
tags: string[];
}
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
const navigate = useNavigate();
return (
<div
className={cn(
'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
'gap-[20px] px-[20px] py-[10px] box-border ',
'border-b-[1px] border-b-liquid-lighter cursor-pointer hover:bg-liquid-lighter transition-all duration-300',
)}
onClick={() => {
navigate(`/article/${id}?back=/home/account/articles`);
}}
>
<div className="h-[23px] flex ">
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
#{id}
</div>
<div className="text-[18px] font-bold flex items-center">
{name}
</div>
</div>
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
{tags.map((v, i) => (
<div
key={i}
className={cn(
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
v == 'Sertificated' && 'text-liquid-green',
)}
>
{v}
</div>
))}
</div>
<img
className=" absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
src={Edit}
onClick={(e) => {
e.stopPropagation();
navigate(
`/article/create?back=/home/account/articles&articleId=${id}`,
);
}}
/>
</div>
);
};
interface ArticlesBlockProps {
className?: string;
}
const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
const dispatch = useAppDispatch();
const articles = useAppSelector((state) => state.articles.articles);
const [active, setActive] = useState<boolean>(true);
useEffect(() => {
dispatch(setMenuActiveProfilePage('articles'));
dispatch(fetchArticles({}));
}, []);
return (
<div className="h-full w-full relative p-[20px]">
<div
className={cn(
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
className,
)}
>
<div
className={cn(
' h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300',
active && ' border-b-liquid-lighter',
)}
onClick={() => {
setActive(!active);
}}
>
<span>Мои статьи</span>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div>
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
active && 'grid-rows-[1fr] opacity-100',
)}
>
<div className="overflow-hidden">
<div className="grid gap-[20px] pt-[20px] pb-[20px] box-border">
{articles.map((v, i) => (
<ArticleItem key={i} {...v} />
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default ArticlesBlock;

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { logout } from '../../../redux/slices/auth';
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
import { FC } from 'react';
import { useQuery } from '../../../hooks/useQuery';
interface StatisticItemProps {
icon: string;
@@ -34,55 +34,32 @@ 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 dispatch = useAppDispatch();
const { data: profileData } = useAppSelector(
(state) => state.profile.profile,
);
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const name = useAppSelector((state) => state.auth.username);
const email = useAppSelector((state) => state.auth.email);
return (
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
<div className=" relative">
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
{profileData?.identity.username}
{name}
</div>
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
{profileData?.identity.email}
{email}
</div>
<div className=" absolute bottom-0 text-liquid-light text-[24px] leading-[30px] font-bold">
Топ 50%
</div>
</div>
</div>
<div className=" text-liquid-light text-[18px] leading-[30px] font-bold">
{`Зарегистрирован ${formatDate(
profileData?.identity.createdAt,
)}`}
</div>
{/* {username == myname && (
<PrimaryButton
onClick={() => {}}
text="Редактировать"
className="w-full"
/>
)} */}
<div className="h-[1px] w-full bg-liquid-lighter"></div>
@@ -93,14 +70,14 @@ const RightPanel = () => {
<StatisticItem
icon={Clipboard}
title={'Задачи'}
count={profileData?.solutions.totalSolved}
countLastWeek={profileData?.solutions.solvedLast7Days}
count={14}
countLastWeek={5}
/>
<StatisticItem
icon={Cup}
title={'Контесты'}
count={profileData?.contests.totalParticipations}
countLastWeek={profileData?.contests.participationsLast7Days}
count={8}
countLastWeek={2}
/>
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
@@ -110,23 +87,22 @@ const RightPanel = () => {
<StatisticItem
icon={Clipboard}
title={'Задачи'}
count={profileData?.creation.missions.total}
countLastWeek={profileData?.creation.missions.last7Days}
count={4}
countLastWeek={2}
/>
<StatisticItem
icon={OpenBook}
title={'Статьи'}
count={profileData?.creation.articles.total}
countLastWeek={profileData?.creation.articles.last7Days}
count={12}
countLastWeek={4}
/>
<StatisticItem
icon={Cup}
title={'Контесты'}
count={profileData?.creation.contests.total}
countLastWeek={profileData?.creation.contests.last7Days}
count={2}
countLastWeek={0}
/>
{username == myname && (
<ReverseButton
className="absolute bottom-[20px] right-[20px]"
onClick={() => {
@@ -135,7 +111,6 @@ const RightPanel = () => {
text="Выход"
color="error"
/>
)}
</div>
);
};

View File

@@ -1,148 +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 { 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;

View File

@@ -1,62 +0,0 @@
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;

View File

@@ -1,97 +0,0 @@
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;

View File

@@ -1,146 +0,0 @@
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;

View File

@@ -1,112 +0,0 @@
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;

View File

@@ -1,160 +0,0 @@
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;

View File

@@ -1,141 +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 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;

View File

@@ -1,78 +0,0 @@
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;

View File

@@ -1,108 +0,0 @@
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;

View File

@@ -1,6 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { cn } from '../../../lib/cn';
import { useAppSelector } from '../../../redux/hooks';
export interface ArticleItemProps {
id: number;
@@ -10,65 +9,6 @@ export interface ArticleItemProps {
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
const navigate = useNavigate();
const filterTags = useAppSelector(
(state) => state.store.articles.articleTagFilter,
);
const nameFilter = useAppSelector(
(state) => state.store.articles.filterName,
);
const highlightZ = (name: string, filter: string) => {
if (!filter) return name;
const s = filter.toLowerCase();
const t = name.toLowerCase();
const n = t.length;
const m = s.length;
const mark = Array(n).fill(false);
// Проходимся с конца и ставим отметки
for (let i = n - 1; i >= 0; i--) {
if (i + m <= n && t.slice(i, i + m) === s) {
for (let j = i; j < i + m; j++) {
if (mark[j]) break;
mark[j] = true;
}
}
}
// === Формируем единые жёлтые блоки ===
const result: any[] = [];
let i = 0;
while (i < n) {
if (!mark[i]) {
// обычный символ
result.push(name[i]);
i++;
} else {
// начинаем жёлтый блок
let j = i;
while (j < n && mark[j]) j++;
const chunk = name.slice(i, j);
result.push(
<span
key={i}
className="bg-yellow-400 text-black rounded px-1"
>
{chunk}
</span>,
);
i = j;
}
}
return result;
};
return (
<div
className={cn(
@@ -86,7 +26,7 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
#{id}
</div>
<div className="text-[18px] font-bold flex items-center bg-red-400r">
{highlightZ(name, nameFilter)}
{name}
</div>
</div>
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
@@ -96,8 +36,6 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
className={cn(
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
v == 'Sertificated' && 'text-liquid-green',
filterTags.includes(v) &&
'border-liquid-brightmain border-[1px] border-solid text-liquid-brightmain',
)}
>
{v}

View File

@@ -2,84 +2,55 @@ import { useEffect } from 'react';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import ArticleItem from './ArticleItem';
import {
setArticlesNameFilter,
setArticlesTagFilter,
setMenuActivePage,
} from '../../../redux/slices/store';
import { setMenuActivePage } from '../../../redux/slices/store';
import { useNavigate } from 'react-router-dom';
import { fetchArticles } from '../../../redux/slices/articles';
import Filters from './Filter';
export interface Article {
id: number;
name: string;
tags: string[];
}
const Articles = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
// ✅ Берём данные из нового состояния
const articles = useAppSelector(
(state) => state.articles.fetchArticles.articles,
);
const tagsFilter = useAppSelector(
(state) => state.store.articles.articleTagFilter,
);
const nameFilter = useAppSelector(
(state) => state.store.articles.filterName,
);
const articles = useAppSelector((state) => state.articles.articles);
const status = useAppSelector((state) => state.articles.statuses.fetchAll);
useEffect(() => {
dispatch(setMenuActivePage('articles'));
dispatch(fetchArticles({ tags: tagsFilter }));
dispatch(fetchArticles({}));
}, []);
const filterTagsHandler = (value: string[]) => {
dispatch(setArticlesTagFilter(value));
dispatch(fetchArticles({ tags: value }));
};
if (status == 'loading') return <div>Загрузка...</div>;
// ========================
// Основной контент
// ========================
return (
<div className="h-full w-full box-border p-[20px]">
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
<div className="h-full box-border">
{/* Заголовок */}
<div className="relative flex items-center mb-[20px]">
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
Статьи
</div>
<SecondaryButton
onClick={() => navigate('/article/create')}
onClick={() => {
navigate('/article/create');
}}
text="Создать статью"
className="absolute right-0"
/>
</div>
{/* Фильтры */}
<Filters
onChangeTags={(value: string[]) => {
filterTagsHandler(value);
}}
onChangeName={(value: string) => {
dispatch(setArticlesNameFilter(value));
}}
/>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
{/* Список статей */}
<div className="mt-[20px]">
{articles.length === 0 ? (
<div className="text-liquid-light text-[16px]">
Пока нет статей
</div>
) : (
articles
.filter((v) =>
v.name
.toLocaleLowerCase()
.includes(nameFilter.toLocaleLowerCase()),
)
.map((v) => <ArticleItem key={v.id} {...v} />)
)}
<div>
{articles.map((v, i) => (
<ArticleItem key={i} {...v} />
))}
</div>
<div>pages</div>
</div>
</div>
);

View File

@@ -1,28 +0,0 @@
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;

View File

@@ -1,18 +1,18 @@
// src/views/home/auth/Login.tsx
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from '../../../assets/icons/auth';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { googleLogo } from '../../../assets/icons/input';
const Login = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
@@ -25,13 +25,12 @@ const Login = () => {
// После успешного логина
useEffect(() => {
dispatch(setMenuActivePage('account'));
console.log(submitClicked);
}, []);
useEffect(() => {
if (jwt) {
const from = location.state?.from;
const path = from ? from.pathname + from.search : '/home/account';
navigate(path, { replace: true });
navigate('/home/account'); // или другая страница после входа
}
}, [jwt]);
@@ -44,21 +43,6 @@ const Login = () => {
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 (
<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 ">
@@ -67,7 +51,7 @@ const Login = () => {
</div>
<div className=" relative pointer-events-auto">
<div>
<div className="text-[35px] text-liquid-white font-bold h-[50px]">
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
С возвращением
</div>
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
@@ -85,7 +69,6 @@ const Login = () => {
setUsername(v);
}}
placeholder="login"
error={getErrorLoginMessage()}
/>
<Input
name="password"
@@ -97,11 +80,17 @@ const Login = () => {
setPassword(v);
}}
placeholder="abCD1234"
error={getErrorPasswordMessage()}
/>
<div className="flex justify-end mt-[10px] h-[20px]">
<div className="flex justify-end mt-[10px]">
<Link
to={''}
className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Забыли пароль?
</Link>
</div>
<div className="mt-[10px]">
@@ -111,6 +100,15 @@ const Login = () => {
text={status === 'loading' ? 'Вход...' : 'Вход'}
disabled={status === 'loading'}
/>
<SecondaryButton className="w-full" onClick={() => {}}>
<div className="flex items-center">
<img
src={googleLogo}
className="h-[24px] w-[24px] mr-[15px]"
/>
Вход с Google
</div>
</SecondaryButton>
</div>
<div className="flex justify-center mt-[10px]">

View File

@@ -1,140 +1,51 @@
// src/views/home/auth/Register.tsx
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from '../../../assets/icons/auth';
import { Link } from 'react-router-dom';
// import { Checkbox } from '../../../components/checkbox/Checkbox';
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');
}
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Checkbox } from '../../../components/checkbox/Checkbox';
import { googleLogo } from '../../../assets/icons/input';
const Register = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const [politicChecked, setPoliticChecked] = useState<boolean>(true);
const { status, jwt } = useAppSelector((state) => state.auth);
// const { errors } = useAppSelector((state) => state.auth.register);
// После успешной регистрации — переход в систему
useEffect(() => {
setPoliticChecked(true);
dispatch(setMenuActivePage('account'));
}, []);
useEffect(() => {
if (jwt) {
const from = location.state?.from;
const path = from ? from.pathname + from.search : '/home/account';
navigate(path, { replace: true });
navigate('/home/account');
}
console.log(submitClicked);
}, [jwt]);
const handleRegister = () => {
setSubmitClicked(true);
if (!politicChecked) return;
if (!username || !email || !password || !confirmPassword) return;
if (password !== confirmPassword) return;
if (
!isValidEmail(email) ||
!isValidLogin(username) ||
isValidatePassword(password) != ''
)
return;
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 (
<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 ">
@@ -143,7 +54,7 @@ const Register = () => {
</div>
<div className=" relative pointer-events-auto">
<div>
<div className="text-[35px] text-liquid-white font-bold h-[50px]">
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
Добро пожаловать
</div>
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
@@ -161,7 +72,6 @@ const Register = () => {
setEmail(v);
}}
placeholder="example@gmail.com"
error={getErrorEmailMessage()}
/>
<Input
name="login"
@@ -173,7 +83,6 @@ const Register = () => {
setUsername(v);
}}
placeholder="login"
error={getErrorLoginMessage()}
/>
<Input
name="password"
@@ -185,7 +94,6 @@ const Register = () => {
setPassword(v);
}}
placeholder="abCD1234"
error={getErrorPasswordMessage()}
/>
<Input
name="confirm-password"
@@ -197,21 +105,16 @@ const Register = () => {
setConfirmPassword(v);
}}
placeholder="abCD1234"
error={getErrorConfirmPasswordMessage()}
/>
<div className=" flex items-center mt-[10px] h-[24px]">
{/* <Checkbox
<Checkbox
onChange={(value: boolean) => {
setPoliticChecked(value);
value;
}}
className="p-0 w-fit m-[2.75px]"
size="md"
color={
politicChecked || !submitClicked
? 'secondary'
: 'danger'
}
color="secondary"
variant="default"
/>
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
@@ -219,10 +122,7 @@ const Register = () => {
<Link to={'/home'} className={' underline'}>
политику конфиденциальности
</Link>
<span className={' underline cursor-pointer'}>
политику конфиденциальности
</span>
</span> */}
</div>
<div className="mt-[10px]">
@@ -236,6 +136,15 @@ const Register = () => {
}
disabled={status === 'loading'}
/>
<SecondaryButton className="w-full" onClick={() => {}}>
<div className="flex items-center">
<img
src={googleLogo}
className="h-[24px] w-[24px] mr-[15px]"
/>
Регистрация с Google
</div>
</SecondaryButton>
</div>
<div className="flex justify-center mt-[10px]">

View File

@@ -2,12 +2,8 @@ import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActivePage } from '../../../redux/slices/store';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import {
fetchContestById,
fetchMyAttemptsInContest,
} from '../../../redux/slices/contests';
import { fetchContestById } from '../../../redux/slices/contests';
import ContestMissions from './Missions';
import Submissions from './Submissions';
export interface Article {
id: number;
@@ -19,13 +15,11 @@ const Contest = () => {
const { contestId } = useParams<{ contestId: string }>();
const contestIdNumber =
contestId && /^\d+$/.test(contestId) ? parseInt(contestId, 10) : null;
if (!contestIdNumber) {
if (contestIdNumber === null) {
return <Navigate to="/home/contests" replace />;
}
const dispatch = useAppDispatch();
const contest = useAppSelector(
(state) => state.contests.fetchContestById.contest,
);
const contest = useAppSelector((state) => state.contests.selectedContest);
useEffect(() => {
dispatch(setMenuActivePage('contest'));
@@ -33,16 +27,11 @@ const Contest = () => {
useEffect(() => {
dispatch(fetchContestById(contestIdNumber));
dispatch(fetchMyAttemptsInContest(contestIdNumber));
}, [contestIdNumber]);
return (
<div className="w-full h-full">
<div>
<Routes>
<Route
path="submissions"
element={<Submissions contest={contest} />}
/>
<Route
path="*"
element={<ContestMissions contest={contest} />}

View File

@@ -2,17 +2,14 @@ import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from '../../../assets/icons/missions';
import { useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { toastWarning } from '../../../lib/toastNotification';
export interface MissionItemProps {
contestId: number;
id: number;
name: string;
timeLimit?: number;
memoryLimit?: number;
type?: 'first' | 'second';
status?: 'success' | 'error';
attemptsStarted?: boolean;
status?: 'empty' | 'success' | 'error';
}
export function formatMilliseconds(ms: number): string {
@@ -27,14 +24,12 @@ export function formatBytesToMB(bytes: number): string {
}
const MissionItem: React.FC<MissionItemProps> = ({
contestId,
id,
name,
timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024,
type,
status,
attemptsStarted,
}) => {
const navigate = useNavigate();
const location = useLocation();
@@ -53,12 +48,7 @@ const MissionItem: React.FC<MissionItemProps> = ({
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
)}
onClick={() => {
if (attemptsStarted){
navigate(`/mission/${id}?back=${path}&contestId=${contestId}`);
}
else{
toastWarning("Нужно начать попытку")
}
navigate(`/mission/${id}?back=${path}`);
}}
>
<div className="text-[18px] font-bold">#{id}</div>

View File

@@ -1,17 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { FC } from 'react';
import MissionItem from './MissionItem';
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';
import { Contest } from '../../../redux/slices/contests';
export interface Article {
id: number;
@@ -20,192 +9,31 @@ export interface Article {
}
interface ContestMissionsProps {
contest?: Contest;
contest: Contest | null;
}
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) {
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 (
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] 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(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-screen grid grid-rows-[74px,1fr] p-[20px] gap-[20px]">
<div className=""></div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className="h-[40px] w-ufll ">
{contest?.name} {contest.id}
</div>
<div className="w-full">
{(contest.missions ?? []).map((v, i) => {
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 (
{contest.missions.map((v, i) => (
<MissionItem
attemptsStarted={attemptsStarted}
contestId={contest.id}
key={i}
id={v.id}
name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
status={status}
type={i % 2 ? 'second' : 'first'}
/>
);
})}
))}
</div>
</div>
</div>

View File

@@ -1,94 +0,0 @@
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;

View File

@@ -1,81 +0,0 @@
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;

View File

@@ -1,75 +0,0 @@
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;

View File

@@ -0,0 +1,124 @@
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;

View File

@@ -3,42 +3,37 @@ import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { cn } from '../../../lib/cn';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import ContestsBlock from './ContestsBlock';
import {
setContestsNameFilter,
setMenuActivePage,
} from '../../../redux/slices/store';
import {
fetchContests,
fetchMyContests,
fetchParticipatingContests,
} from '../../../redux/slices/contests';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchContests } from '../../../redux/slices/contests';
import ModalCreateContest from './ModalCreate';
import Filters from './Filter';
import { toastWarning } from '../../../lib/toastNotification';
const Contests = () => {
const dispatch = useAppDispatch();
const now = new Date();
const [modalActive, setModalActive] = useState<boolean>(false);
const jwt = useAppSelector((state) => state.auth.jwt);
const { contests, status } = useAppSelector(
(state) => state.contests.fetchContests,
);
const nameFilter = useAppSelector(
(state) => state.store.contests.filterName,
);
// Берём данные из Redux
const contests = useAppSelector((state) => state.contests.contests);
const status = useAppSelector((state) => state.contests.statuses.create);
const error = useAppSelector((state) => state.contests.error);
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => {
dispatch(setMenuActivePage('contests'));
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 (
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
<div className="h-full box-border">
@@ -52,10 +47,6 @@ const Contests = () => {
</div>
<SecondaryButton
onClick={() => {
if (!jwt){
toastWarning("Для создания контеста необходимо авторизоваться")
return;
}
setModalActive(true);
}}
text="Создать контест"
@@ -63,49 +54,25 @@ const Contests = () => {
/>
</div>
<Filters
onChangeName={(v: string) => {
dispatch(setContestsNameFilter(v));
}}
/>
{status == 'loading' && (
<div className="text-liquid-white p-4">
Загрузка контестов...
</div>
)}
{status == 'successful' && (
<>
<div className="bg-liquid-lighter h-[50px] mb-[20px]" />
<ContestsBlock
className="mb-[20px]"
title="Текущие"
contests={contests
.filter((v) =>
v.name
.toLocaleLowerCase()
.includes(
nameFilter.toLocaleLowerCase(),
),
)
.filter((c) => c.scheduleType != 'AlwaysOpen')}
type="upcoming"
contests={contests.filter((contest) => {
const endTime = new Date(contest.endsAt).getTime();
return endTime >= now.getTime();
})}
/>
<ContestsBlock
className="mb-[20px]"
title=остоянные"
contests={contests
.filter((v) =>
v.name
.toLocaleLowerCase()
.includes(
nameFilter.toLocaleLowerCase(),
),
)
.filter((c) => c.scheduleType == 'AlwaysOpen')}
type="past"
title=рошедшие"
contests={contests.filter((contest) => {
const endTime = new Date(contest.endsAt).getTime();
return endTime < now.getTime();
})}
/>
</>
)}
</div>
<ModalCreateContest

View File

@@ -1,22 +1,19 @@
import { useState, FC } from 'react';
import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups';
import ContestItem from './ContestItem';
import { Contest } from '../../../redux/slices/contests';
import PastContestItem from './PastContestItem';
import UpcoingContestItem from './UpcomingContestItem';
interface ContestsBlockProps {
contests: Contest[];
title: string;
className?: string;
type: 'upcoming' | 'past';
}
const ContestsBlock: FC<ContestsBlockProps> = ({
contests,
title,
className,
type,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -36,11 +33,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
setActive(!active);
}}
>
<span className=" select-none">{title}</span>
<span>{title}</span>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300 select-none',
'transition-all duration-300',
active && 'rotate-180',
)}
/>
@@ -53,51 +50,21 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
>
<div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]">
{contests.map((v, i) => {
if (type == 'past') {
return (
<PastContestItem
{contests.map((v, i) => (
<ContestItem
key={i}
contestId={v.id}
scheduleType={v.scheduleType}
id={v.id}
name={v.name}
startsAt={
v.startsAt ?? new Date().toString()
}
endsAt={
v.endsAt ?? new Date().toString()
}
attemptDurationMinutes={
v.attemptDurationMinutes ?? 0
startAt={v.startsAt}
statusRegister={'reg'}
duration={
new Date(v.endsAt).getTime() -
new Date(v.startsAt).getTime()
}
members={v.members.length}
type={i % 2 ? 'second' : 'first'}
/>
);
}
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>

View File

@@ -1,21 +0,0 @@
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;

View File

@@ -4,30 +4,9 @@ import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import {
createContest,
setContestStatus,
} from '../../../redux/slices/contests';
import { createContest } from '../../../redux/slices/contests';
import { CreateContestBody } from '../../../redux/slices/contests';
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();
}
import DateRangeInput from '../../../components/input/DateRangeInput';
interface ModalCreateContestProps {
active: boolean;
@@ -39,99 +18,39 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
setActive,
}) => {
const dispatch = useAppDispatch();
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 status = useAppSelector((state) => state.contests.statuses.create);
const [form, setForm] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: toLocal(now),
endsAt: toLocal(plus60),
attemptDurationMinutes: 60,
maxAttempts: 1,
startsAt: null,
endsAt: null,
attemptDurationMinutes: null,
maxAttempts: null,
allowEarlyFinish: false,
missionIds: [],
articleIds: [],
groupId: null,
missionIds: null,
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(() => {
if (status === 'successful') {
dispatch(
setContestStatus({ key: 'createContest', status: 'idle' }),
);
navigate(
`/contest/create?back=/home/account/contests&contestId=${contest.id}`,
);
setActive(false);
}
}, [status]);
useEffect(() => {
if (active) {
dispatch(fetchMyGroups());
}
}, [active]);
const handleChange = (key: keyof CreateContestBody, value: any) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = () => {
dispatch(
createContest({
...form,
endsAt: toUtc(form.endsAt),
startsAt: toUtc(form.startsAt),
}),
);
dispatch(createContest(form));
};
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 (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
@@ -165,123 +84,80 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<div>
<label className="block text-sm mb-1">
Тип контеста
Тип расписания
</label>
<DropDownList
items={scheduleTypeItems}
onChange={(v) => {
handleChange('scheduleType', v);
}}
weight="w-full"
/>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={form.scheduleType}
onChange={(e) =>
handleChange(
'scheduleType',
e.target
.value as CreateContestBody['scheduleType'],
)
}
>
<option value="AlwaysOpen">Всегда открыт</option>
<option value="FixedWindow">
Фиксированные даты
</option>
<option value="RollingWindow">
Скользящее окно
</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Видимость</label>
<DropDownList
items={visibilityItems}
onChange={(v) => {
handleChange('visibility', v);
}}
weight="w-full"
/>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={form.visibility}
onChange={(e) =>
handleChange(
'visibility',
e.target
.value as CreateContestBody['visibility'],
)
}
>
<option value="Public">Публичный</option>
<option value="GroupPrivate">Групповой</option>
</select>
</div>
</div>
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-200 mb-[10px]',
form.visibility == 'GroupPrivate' &&
'grid-rows-[1fr] opacity-100',
)}
>
{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 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)}
<DateRangeInput
startValue={form.startsAt || ''}
endValue={form.endsAt || ''}
onChange={handleChange}
className="mt-[10px]"
/>
<DateInput
label="Дата окончания"
value={form.endsAt}
onChange={(v) => handleChange('endsAt', v)}
/>
</div>
</div>
</div>
{/* Продолжительность и лимиты */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<NumberInput
defaultState={form.attemptDurationMinutes}
<Input
name="attemptDurationMinutes"
type="number"
label="Длительность попытки (мин)"
placeholder="Например: 60"
minValue={1}
maxValue={365 * 24 * 60}
onChange={(v) =>
handleChange('attemptDurationMinutes', Number(v))
}
/>
<NumberInput
defaultState={form.maxAttempts}
<Input
name="maxAttempts"
type="number"
label="Макс. попыток"
placeholder="Например: 3"
minValue={1}
maxValue={100}
onChange={(v) => handleChange('maxAttempts', Number(v))}
/>
</div>
{/* Разрешить раннее завершение */}
{/* <div className="flex items-center gap-[10px] mt-[15px]">
<div className="flex items-center gap-[10px] mt-[15px]">
<input
id="allowEarlyFinish"
type="checkbox"
@@ -293,14 +169,12 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
<label htmlFor="allowEarlyFinish">
Разрешить раннее завершение
</label>
</div> */}
</div>
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
handleSubmit();
}}
onClick={handleSubmit}
text="Создать"
disabled={status === 'loading'}
/>

View File

@@ -1,246 +0,0 @@
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;

View File

@@ -1,290 +0,0 @@
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;

View File

@@ -1,54 +0,0 @@
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;

Some files were not shown because too many files have changed in this diff Show More