Compare commits
5 Commits
dev
...
d9f449f0b8
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f449f0b8 | |||
| 04da2b565a | |||
| 070edbfc42 | |||
|
|
a4480db444 | ||
| f2baf189e4 |
35
.gitea/workflows/build-and-push.yaml
Normal file
35
.gitea/workflows/build-and-push.yaml
Normal 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
2
.gitignore
vendored
@@ -11,7 +11,6 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -24,4 +23,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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"]
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
|||||||
@@ -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
1
public/vite.svg
Normal 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 |
@@ -1,6 +1,5 @@
|
|||||||
import Cup from './cup.svg';
|
import Cup from './cup.svg';
|
||||||
import Home from './home.svg';
|
import Home from './home.svg';
|
||||||
import MessageChat from './message-chat.svg';
|
import MessageChat from './message-chat.svg';
|
||||||
import Logout from './logout.svg';
|
|
||||||
|
|
||||||
export { Cup, MessageChat, Home, Logout };
|
export { Cup, MessageChat, Home };
|
||||||
|
|||||||
@@ -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 |
@@ -5,5 +5,4 @@ import Edit from './edit.svg';
|
|||||||
import UserAdd from './user-profile-add.svg';
|
import UserAdd from './user-profile-add.svg';
|
||||||
import ChevroneDown from './chevron-down.svg';
|
import ChevroneDown from './chevron-down.svg';
|
||||||
|
|
||||||
|
|
||||||
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };
|
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import upload from './upload.svg';
|
|||||||
import chevroneDropDownList from './chevron-drop-down.svg';
|
import chevroneDropDownList from './chevron-drop-down.svg';
|
||||||
import checkMark from './check-mark.svg';
|
import checkMark from './check-mark.svg';
|
||||||
import Edit from './edit.svg';
|
import Edit from './edit.svg';
|
||||||
import Send from './send.svg';
|
|
||||||
import Trash from './trash.svg';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -16,6 +14,4 @@ export {
|
|||||||
upload,
|
upload,
|
||||||
chevroneDropDownList,
|
chevroneDropDownList,
|
||||||
checkMark,
|
checkMark,
|
||||||
Send,
|
|
||||||
Trash,
|
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
src/assets/icons/input/receipt.png
Normal file
BIN
src/assets/icons/input/receipt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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 |
@@ -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 |
@@ -105,7 +105,6 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300',
|
'group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300',
|
||||||
color == 'danger' && ' border-liquid-red',
|
|
||||||
sizeVariants[size],
|
sizeVariants[size],
|
||||||
radiusVraiants[radius],
|
radiusVraiants[radius],
|
||||||
active && borderColorsVariants[color],
|
active && borderColorsVariants[color],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
|
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
|
||||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
@@ -14,7 +14,6 @@ interface DropDownListProps {
|
|||||||
onChange: (state: string) => void;
|
onChange: (state: string) => void;
|
||||||
defaultState?: DropDownListItem;
|
defaultState?: DropDownListItem;
|
||||||
items: DropDownListItem[];
|
items: DropDownListItem[];
|
||||||
weight?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropDownList: React.FC<DropDownListProps> = ({
|
export const DropDownList: React.FC<DropDownListProps> = ({
|
||||||
@@ -23,7 +22,6 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
defaultState,
|
defaultState,
|
||||||
items = [{ text: '', value: '' }],
|
items = [{ text: '', value: '' }],
|
||||||
weight = 'w-[180px]',
|
|
||||||
}) => {
|
}) => {
|
||||||
if (items.length == 0) items.push({ text: '', value: '' });
|
if (items.length == 0) items.push({ text: '', value: '' });
|
||||||
|
|
||||||
@@ -32,24 +30,21 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
);
|
);
|
||||||
const [active, setActive] = React.useState<boolean>(false);
|
const [active, setActive] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => onChange(value.value), [value]);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useClickOutside(ref, () => {
|
useClickOutside(ref, () => {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(defaultState != undefined ? defaultState : items[0]);
|
|
||||||
}, [defaultState]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)} ref={ref}>
|
<div className={cn('relative', className)} ref={ref}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px]',
|
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]',
|
||||||
'text-[18px] font-bold cursor-pointer select-none',
|
'text-[18px] font-bold cursor-pointer select-none',
|
||||||
'transitin-all active:scale-95 duration-300',
|
'transitin-all active:scale-95 duration-300',
|
||||||
weight,
|
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActive(!active);
|
setActive(!active);
|
||||||
@@ -61,22 +56,21 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
<img
|
<img
|
||||||
src={chevroneDropDownList}
|
src={chevroneDropDownList}
|
||||||
className={cn(
|
className={cn(
|
||||||
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none select-none',
|
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||||
active && ' rotate-180',
|
active && ' rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'grid overflow-hidden',
|
||||||
weight,
|
|
||||||
active
|
active
|
||||||
? 'grid-rows-[1fr] opacity-100'
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
: 'grid-rows-[0fr] opacity-0',
|
: 'grid-rows-[0fr] opacity-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className=" overflow-hidden p-[8px] border-liquid-background border-solid border-[1px] rounded-[10px]">
|
<div className=" overflow-hidden p-[8px]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
||||||
@@ -95,7 +89,6 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
onChange(v.value);
|
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -11,7 +11,6 @@ interface inputProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
inputClassName?: string;
|
|
||||||
onChange: (state: string) => void;
|
onChange: (state: string) => void;
|
||||||
defaultState?: string;
|
defaultState?: string;
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
@@ -26,7 +25,6 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
label = '',
|
label = '',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
className = '',
|
className = '',
|
||||||
inputClassName = '',
|
|
||||||
onChange,
|
onChange,
|
||||||
defaultState = '',
|
defaultState = '',
|
||||||
name = '',
|
name = '',
|
||||||
@@ -54,11 +52,10 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
||||||
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
||||||
inputClassName,
|
|
||||||
)}
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
name={name}
|
name={name}
|
||||||
autoComplete={autocomplete || undefined}
|
autoComplete={autocomplete}
|
||||||
type={
|
type={
|
||||||
type == 'password'
|
type == 'password'
|
||||||
? visible
|
? visible
|
||||||
@@ -87,7 +84,7 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
error == '' && 'h-0 mt-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -47,7 +47,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
exit={modalbgVariants.closed}
|
exit={modalbgVariants.closed}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-[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 == 'blur' && open && 'backdrop-blur-sm',
|
||||||
backdrop == 'opaque' &&
|
backdrop == 'opaque' &&
|
||||||
open &&
|
open &&
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from '../redux/slices/articles';
|
} from '../redux/slices/articles';
|
||||||
import { useQuery } from '../hooks/useQuery';
|
import { useQuery } from '../hooks/useQuery';
|
||||||
import { ReverseButton } from '../components/button/ReverseButton';
|
import { ReverseButton } from '../components/button/ReverseButton';
|
||||||
import { cn } from '../lib/cn';
|
|
||||||
|
|
||||||
const ArticleEditor = () => {
|
const ArticleEditor = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -25,7 +24,6 @@ const ArticleEditor = () => {
|
|||||||
const back = query.get('back') ?? undefined;
|
const back = query.get('back') ?? undefined;
|
||||||
const articleId = Number(query.get('articleId') ?? undefined);
|
const articleId = Number(query.get('articleId') ?? undefined);
|
||||||
const refactor = articleId && !isNaN(articleId);
|
const refactor = articleId && !isNaN(articleId);
|
||||||
const [clickSubmit, setClickSubmit] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Достаём данные из redux
|
// Достаём данные из redux
|
||||||
const article = useAppSelector(
|
const article = useAppSelector(
|
||||||
@@ -63,6 +61,7 @@ const ArticleEditor = () => {
|
|||||||
const removeTag = (tagToRemove: string) => {
|
const removeTag = (tagToRemove: string) => {
|
||||||
setTags(tags.filter((tag) => tag !== tagToRemove));
|
setTags(tags.filter((tag) => tag !== tagToRemove));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
// Эффекты по статусам
|
// Эффекты по статусам
|
||||||
// ==========================
|
// ==========================
|
||||||
@@ -97,7 +96,6 @@ const ArticleEditor = () => {
|
|||||||
// Получение статьи
|
// Получение статьи
|
||||||
// ==========================
|
// ==========================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setClickSubmit(false);
|
|
||||||
if (articleId) {
|
if (articleId) {
|
||||||
dispatch(fetchArticleById(articleId));
|
dispatch(fetchArticleById(articleId));
|
||||||
}
|
}
|
||||||
@@ -112,18 +110,6 @@ const ArticleEditor = () => {
|
|||||||
}
|
}
|
||||||
}, [article]);
|
}, [article]);
|
||||||
|
|
||||||
const getNameErrorMessage = (): string => {
|
|
||||||
if (!clickSubmit) return '';
|
|
||||||
if (name == '') return 'Поле не может быть пустым';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getContentErrorMessage = (): string => {
|
|
||||||
if (!clickSubmit) return '';
|
|
||||||
if (code == '') return 'Поле не может быть пустым';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
// Рендер
|
// Рендер
|
||||||
// ==========================
|
// ==========================
|
||||||
@@ -151,7 +137,6 @@ const ArticleEditor = () => {
|
|||||||
<div className="flex gap-[20px]">
|
<div className="flex gap-[20px]">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setClickSubmit(true);
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updateArticle({
|
updateArticle({
|
||||||
articleId,
|
articleId,
|
||||||
@@ -178,7 +163,6 @@ const ArticleEditor = () => {
|
|||||||
) : (
|
) : (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setClickSubmit(true);
|
|
||||||
dispatch(
|
dispatch(
|
||||||
createArticle({
|
createArticle({
|
||||||
name,
|
name,
|
||||||
@@ -204,7 +188,6 @@ const ArticleEditor = () => {
|
|||||||
label="Название"
|
label="Название"
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
placeholder="Новая статья"
|
placeholder="Новая статья"
|
||||||
error={getNameErrorMessage()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Теги */}
|
{/* Теги */}
|
||||||
@@ -253,14 +236,6 @@ const ArticleEditor = () => {
|
|||||||
text="Редактировать текст"
|
text="Редактировать текст"
|
||||||
className="mt-[20px]"
|
className="mt-[20px]"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-liquid-red text-[14px] h-auto mt-[5px] whitespace-pre-line ',
|
|
||||||
getContentErrorMessage() == '' && 'h-0 mt-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getContentErrorMessage()}
|
|
||||||
</div>
|
|
||||||
<MarkdownPreview
|
<MarkdownPreview
|
||||||
content={code}
|
content={code}
|
||||||
className="bg-transparent border-liquid-lighter border-[3px] rounded-[20px] mt-[20px]"
|
className="bg-transparent border-liquid-lighter border-[3px] rounded-[20px] mt-[20px]"
|
||||||
|
|||||||
@@ -10,82 +10,17 @@ import {
|
|||||||
setContestStatus,
|
setContestStatus,
|
||||||
updateContest,
|
updateContest,
|
||||||
} from '../redux/slices/contests';
|
} from '../redux/slices/contests';
|
||||||
|
import DateRangeInput from '../components/input/DateRangeInput';
|
||||||
import { useQuery } from '../hooks/useQuery';
|
import { useQuery } from '../hooks/useQuery';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { fetchMissionById, fetchMissions } from '../redux/slices/missions';
|
import { fetchMissionById } from '../redux/slices/missions';
|
||||||
import { ReverseButton } from '../components/button/ReverseButton';
|
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 {
|
interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Страница создания / редактирования контеста
|
* Страница создания / редактирования контеста
|
||||||
*/
|
*/
|
||||||
@@ -106,42 +41,22 @@ const ContestEditor = () => {
|
|||||||
(state) => state.contests.createContest.status,
|
(state) => state.contests.createContest.status,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [missionFindInput, setMissionFindInput] = useState<string>('');
|
const [missionIdInput, setMissionIdInput] = 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>({
|
const [contest, setContest] = useState<CreateContestBody>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
scheduleType: 'AlwaysOpen',
|
scheduleType: 'AlwaysOpen',
|
||||||
visibility: 'Public',
|
visibility: 'Public',
|
||||||
startsAt: toLocal(now),
|
startsAt: '',
|
||||||
endsAt: toLocal(plus60),
|
endsAt: '',
|
||||||
attemptDurationMinutes: 60,
|
attemptDurationMinutes: 60,
|
||||||
maxAttempts: 1,
|
maxAttempts: 1,
|
||||||
allowEarlyFinish: false,
|
allowEarlyFinish: true,
|
||||||
missionIds: [],
|
missionIds: [],
|
||||||
articleIds: [],
|
articleIds: [],
|
||||||
});
|
});
|
||||||
const myname = useAppSelector((state) => state.auth.username);
|
|
||||||
const [missions, setMissions] = useState<Mission[]>([]);
|
const [missions, setMissions] = useState<Mission[]>([]);
|
||||||
|
|
||||||
const statusDelete = useAppSelector(
|
const statusDelete = useAppSelector(
|
||||||
@@ -154,30 +69,6 @@ const ContestEditor = () => {
|
|||||||
const { contest: contestById, status: contestByIdstatus } = useAppSelector(
|
const { contest: contestById, status: contestByIdstatus } = useAppSelector(
|
||||||
(state) => state.contests.fetchContestById,
|
(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(() => {
|
useEffect(() => {
|
||||||
if (status === 'successful') {
|
if (status === 'successful') {
|
||||||
}
|
}
|
||||||
@@ -188,14 +79,7 @@ const ContestEditor = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateContest = () => {
|
const handleUpdateContest = () => {
|
||||||
dispatch(
|
dispatch(updateContest({ ...contest, contestId }));
|
||||||
updateContest({
|
|
||||||
...contest,
|
|
||||||
endsAt: toUtc(contest.endsAt),
|
|
||||||
startsAt: toUtc(contest.startsAt),
|
|
||||||
contestId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteContest = () => {
|
const handleDeleteContest = () => {
|
||||||
@@ -203,15 +87,7 @@ const ContestEditor = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addMission = () => {
|
const addMission = () => {
|
||||||
const mission = globalMissions
|
const id = Number(missionIdInput.trim());
|
||||||
.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;
|
if (!id || contest.missionIds?.includes(id)) return;
|
||||||
dispatch(fetchMissionById(id))
|
dispatch(fetchMissionById(id))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -221,11 +97,9 @@ const ContestEditor = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
missionIds: [...(prev.missionIds ?? []), id],
|
missionIds: [...(prev.missionIds ?? []), id],
|
||||||
}));
|
}));
|
||||||
setMissionFindInput('');
|
setMissionIdInput('');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {});
|
||||||
err;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMission = (removeId: number) => {
|
const removeMission = (removeId: number) => {
|
||||||
@@ -257,8 +131,6 @@ const ContestEditor = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refactor) {
|
if (refactor) {
|
||||||
dispatch(fetchContestById(contestId));
|
dispatch(fetchContestById(contestId));
|
||||||
dispatch(fetchMyGroups());
|
|
||||||
dispatch(fetchMissions({}));
|
|
||||||
}
|
}
|
||||||
}, [refactor]);
|
}, [refactor]);
|
||||||
|
|
||||||
@@ -271,32 +143,13 @@ const ContestEditor = () => {
|
|||||||
articleIds: contestById.articles?.map(
|
articleIds: contestById.articles?.map(
|
||||||
(article) => article.articleId,
|
(article) => article.articleId,
|
||||||
),
|
),
|
||||||
|
visibility: 'Public',
|
||||||
|
scheduleType: 'AlwaysOpen',
|
||||||
});
|
});
|
||||||
setMissions(contestById.missions ?? []);
|
setMissions(contestById.missions ?? []);
|
||||||
}
|
}
|
||||||
}, [contestById]);
|
}, [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 (
|
return (
|
||||||
<div className="h-screen grid grid-rows-[60px,1fr] text-liquid-white">
|
<div className="h-screen grid grid-rows-[60px,1fr] text-liquid-white">
|
||||||
<Header backClick={() => navigate(back || '/home/contests')} />
|
<Header backClick={() => navigate(back || '/home/contests')} />
|
||||||
@@ -337,134 +190,73 @@ const ContestEditor = () => {
|
|||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1">
|
<label className="block text-sm mb-1">
|
||||||
Тип контеста
|
Тип расписания
|
||||||
</label>
|
</label>
|
||||||
|
<select
|
||||||
<DropDownList
|
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
||||||
items={scheduleTypeItems}
|
value={contest.scheduleType}
|
||||||
defaultState={scheduleTypeDefaultState}
|
onChange={(e) =>
|
||||||
onChange={(v) => {
|
handleChange(
|
||||||
handleChange('scheduleType', v);
|
'scheduleType',
|
||||||
}}
|
e.target
|
||||||
weight="w-full"
|
.value as CreateContestBody['scheduleType'],
|
||||||
/>
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="AlwaysOpen">
|
||||||
|
Всегда открыт
|
||||||
|
</option>
|
||||||
|
<option value="FixedWindow">
|
||||||
|
Фиксированные даты
|
||||||
|
</option>
|
||||||
|
<option value="RollingWindow">
|
||||||
|
Скользящее окно
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1">
|
<label className="block text-sm mb-1">
|
||||||
Видимость
|
Видимость
|
||||||
</label>
|
</label>
|
||||||
<DropDownList
|
<select
|
||||||
items={visibilityItems}
|
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
||||||
onChange={(v) => {
|
value={contest.visibility}
|
||||||
handleChange('visibility', v);
|
onChange={(e) =>
|
||||||
}}
|
|
||||||
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(
|
handleChange(
|
||||||
'groupId',
|
'visibility',
|
||||||
Number(v),
|
e.target
|
||||||
);
|
.value as CreateContestBody['visibility'],
|
||||||
}}
|
)
|
||||||
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">
|
<option value="Public">
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
Публичный
|
||||||
<DateInput
|
</option>
|
||||||
label="Дата начала"
|
<option value="GroupPrivate">
|
||||||
value={
|
Групповой
|
||||||
contest.startsAt
|
</option>
|
||||||
? toLocalInputValue(
|
</select>
|
||||||
contest.startsAt,
|
</div>
|
||||||
)
|
</div>
|
||||||
: ''
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
handleChange('startsAt', v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
{/* Даты начала и конца */}
|
||||||
label="Дата окончания"
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
value={
|
<DateRangeInput
|
||||||
contest.endsAt
|
startValue={contest.startsAt || ''}
|
||||||
? toLocalInputValue(
|
endValue={contest.endsAt || ''}
|
||||||
contest.endsAt,
|
onChange={handleChange}
|
||||||
)
|
className="mt-[10px]"
|
||||||
: ''
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
handleChange('endsAt', v)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Продолжительность и лимиты */}
|
{/* Продолжительность и лимиты */}
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<NumberInput
|
<Input
|
||||||
defaultState={
|
|
||||||
contest.attemptDurationMinutes
|
|
||||||
}
|
|
||||||
name="attemptDurationMinutes"
|
name="attemptDurationMinutes"
|
||||||
|
type="number"
|
||||||
label="Длительность попытки (мин)"
|
label="Длительность попытки (мин)"
|
||||||
placeholder="Например: 60"
|
placeholder="Например: 60"
|
||||||
minValue={1}
|
|
||||||
maxValue={365 * 24 * 60}
|
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
handleChange(
|
handleChange(
|
||||||
'attemptDurationMinutes',
|
'attemptDurationMinutes',
|
||||||
@@ -472,19 +264,35 @@ const ContestEditor = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<Input
|
||||||
defaultState={contest.maxAttempts}
|
|
||||||
name="maxAttempts"
|
name="maxAttempts"
|
||||||
|
type="number"
|
||||||
label="Макс. попыток"
|
label="Макс. попыток"
|
||||||
placeholder="Например: 3"
|
placeholder="Например: 3"
|
||||||
minValue={1}
|
|
||||||
maxValue={100}
|
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
handleChange('maxAttempts', Number(v))
|
handleChange('maxAttempts', Number(v))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Разрешить раннее завершение */}
|
||||||
|
<div className="flex items-center gap-[10px] mt-[15px]">
|
||||||
|
<input
|
||||||
|
id="allowEarlyFinish"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!contest.allowEarlyFinish}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
'allowEarlyFinish',
|
||||||
|
e.target.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor="allowEarlyFinish">
|
||||||
|
Разрешить раннее завершение
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -504,22 +312,24 @@ const ContestEditor = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Правая панель */}
|
{/* Правая панель */}
|
||||||
<div className="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">
|
<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="mt-[20px] max-w-[600px] relative">
|
<div className="mt-[20px] max-w-[600px]">
|
||||||
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
||||||
<Input
|
<Input
|
||||||
name="missionId"
|
name="missionId"
|
||||||
autocomplete="missionId"
|
autocomplete="missionId"
|
||||||
className="mt-[20px] max-w-[600px]"
|
className="mt-[20px] max-w-[600px]"
|
||||||
label="Введите название или ID миссии"
|
type="number"
|
||||||
type="text"
|
label="ID миссии"
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setMissionFindInput(v);
|
setMissionIdInput(v);
|
||||||
}}
|
}}
|
||||||
defaultState={missionFindInput}
|
defaultState={missionIdInput}
|
||||||
placeholder={`Наприме: \"458\" или \"Поиск наименьшего\"`}
|
placeholder="458"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key == 'Enter') addMission();
|
if (e.key == 'Enter') addMission();
|
||||||
}}
|
}}
|
||||||
@@ -529,70 +339,18 @@ const ContestEditor = () => {
|
|||||||
text="Добавить"
|
text="Добавить"
|
||||||
className="h-[40px] w-[140px]"
|
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 className="flex flex-wrap gap-[10px] mt-2">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="gap-[10px] mt-[20px]">
|
|
||||||
{missions.map((v, i) => (
|
{missions.map((v, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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"
|
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
|
||||||
>
|
>
|
||||||
<div>{'#' + v.id}</div>
|
<span>{v.id}</span>
|
||||||
<div>{v.name}</div>
|
<span>{v.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeMission(v.id)}
|
onClick={() => removeMission(v.id)}
|
||||||
className="text-liquid-red font-bold ml-[5px] absolute right-[16px]"
|
className="text-liquid-red font-bold ml-[5px]"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
// src/pages/Home.tsx
|
// src/pages/Home.tsx
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import Login from '../views/home/auth/Login';
|
import Login from '../views/home/auth/Login';
|
||||||
import Register from '../views/home/auth/Register';
|
import Register from '../views/home/auth/Register';
|
||||||
import Menu from '../views/home/menu/Menu';
|
import Menu from '../views/home/menu/Menu';
|
||||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { fetchWhoAmI } from '../redux/slices/auth';
|
import { fetchWhoAmI, logout } from '../redux/slices/auth';
|
||||||
import Missions from '../views/home/missions/Missions';
|
import Missions from '../views/home/missions/Missions';
|
||||||
import Articles from '../views/home/articles/Articles';
|
import Articles from '../views/home/articles/Articles';
|
||||||
import Groups from '../views/home/groups/Groups';
|
import Groups from '../views/home/groups/Groups';
|
||||||
import Contests from '../views/home/contests/Contests';
|
import Contests from '../views/home/contests/Contests';
|
||||||
|
import { PrimaryButton } from '../components/button/PrimaryButton';
|
||||||
import Group from '../views/home/group/Group';
|
import Group from '../views/home/group/Group';
|
||||||
import Contest from '../views/home/contest/Contest';
|
import Contest from '../views/home/contest/Contest';
|
||||||
import Account from '../views/home/account/Account';
|
import Account from '../views/home/account/Account';
|
||||||
import ProtectedRoute from '../components/router/ProtectedRoute';
|
import ProtectedRoute from '../components/router/ProtectedRoute';
|
||||||
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
|
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
|
||||||
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
||||||
import { GroupRightPanel } from '../views/home/rightpanel/group/Group';
|
import { GroupRightPanel } from '../views/home/rightpanel/Group';
|
||||||
import GroupInvite from '../views/home/groupinviter/GroupInvite';
|
import GroupInvite from '../views/home/groupinviter/GroupInvite';
|
||||||
|
import {
|
||||||
|
toastError,
|
||||||
|
toastSuccess,
|
||||||
|
toastWarning,
|
||||||
|
} from '../lib/toastNotification';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
const name = useAppSelector((state) => state.auth.username);
|
||||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
const jwt = useAppSelector((state) => state.auth.jwt);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -52,7 +59,53 @@ const Home = () => {
|
|||||||
<Route path="contest/:contestId/*" element={<Contest />} />
|
<Route path="contest/:contestId/*" element={<Contest />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to="/home/account" replace />}
|
element={
|
||||||
|
<>
|
||||||
|
<p>{jwt}</p>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
if (jwt) {
|
||||||
|
navigator.clipboard.writeText(jwt);
|
||||||
|
alert(jwt);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
text="скопировать токен"
|
||||||
|
className="pt-[20px]"
|
||||||
|
/>
|
||||||
|
<p className="py-[20px]">{name}</p>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(logout());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
выйти
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
|
<div className="flex mt-[20px] gap-[20px]">
|
||||||
|
<PrimaryButton
|
||||||
|
color="success"
|
||||||
|
text="Toast"
|
||||||
|
onClick={() => {
|
||||||
|
toastSuccess('Success');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
color="warning"
|
||||||
|
text="Toast"
|
||||||
|
onClick={() => {
|
||||||
|
toastWarning('Warning');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
color="error"
|
||||||
|
text="Toast"
|
||||||
|
onClick={() => {
|
||||||
|
toastError('Error');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,21 @@
|
|||||||
import { useParams, Navigate, useNavigate } from 'react-router-dom';
|
import { useParams, Navigate } from 'react-router-dom';
|
||||||
import CodeEditor from '../views/mission/codeeditor/CodeEditor';
|
import CodeEditor from '../views/mission/codeeditor/CodeEditor';
|
||||||
import Statement from '../views/mission/statement/Statement';
|
import Statement from '../views/mission/statement/Statement';
|
||||||
import { PrimaryButton } from '../components/button/PrimaryButton';
|
import { PrimaryButton } from '../components/button/PrimaryButton';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
|
import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
|
||||||
import { fetchMissionById, setMissionsStatus } from '../redux/slices/missions';
|
import { fetchMissionById } from '../redux/slices/missions';
|
||||||
import Header from '../views/mission/statement/Header';
|
import Header from '../views/mission/statement/Header';
|
||||||
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
||||||
import { useQuery } from '../hooks/useQuery';
|
import { useQuery } from '../hooks/useQuery';
|
||||||
import { fetchMyAttemptsInContest } from '../redux/slices/contests';
|
|
||||||
|
|
||||||
const Mission = () => {
|
const Mission = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Получаем параметры из URL
|
// Получаем параметры из URL
|
||||||
const { missionId } = useParams<{ missionId: string }>();
|
const { missionId } = useParams<{ missionId: string }>();
|
||||||
const mission = useAppSelector((state) => state.missions.currentMission);
|
const mission = useAppSelector((state) => state.missions.currentMission);
|
||||||
const missionStatus = useAppSelector(
|
|
||||||
(state) => state.missions.statuses.fetchById,
|
|
||||||
);
|
|
||||||
const attempt = useAppSelector(
|
|
||||||
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
const missionIdNumber = Number(missionId);
|
const missionIdNumber = Number(missionId);
|
||||||
|
|
||||||
const query = useQuery();
|
const query = useQuery();
|
||||||
@@ -49,9 +40,6 @@ const Mission = () => {
|
|||||||
if (pollingRef.current) return;
|
if (pollingRef.current) return;
|
||||||
|
|
||||||
pollingRef.current = setInterval(async () => {
|
pollingRef.current = setInterval(async () => {
|
||||||
if (contestId) {
|
|
||||||
dispatch(fetchMyAttemptsInContest(contestId));
|
|
||||||
}
|
|
||||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||||
|
|
||||||
const hasWaiting = submissionsRef.current.some(
|
const hasWaiting = submissionsRef.current.some(
|
||||||
@@ -71,12 +59,6 @@ const Mission = () => {
|
|||||||
}, 5000); // 10 секунд
|
}, 5000); // 10 секунд
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (contestId) {
|
|
||||||
dispatch(fetchMyAttemptsInContest(contestId));
|
|
||||||
}
|
|
||||||
}, [contestId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchMissionById(missionIdNumber));
|
dispatch(fetchMissionById(missionIdNumber));
|
||||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||||
@@ -92,12 +74,6 @@ const Mission = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
|
||||||
if (missionStatus == 'failed') {
|
|
||||||
setMissionsStatus({ key: 'fetchById', status: 'idle' });
|
|
||||||
navigate(back ?? '/home/missions');
|
|
||||||
}
|
|
||||||
}, [missionStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
submissionsRef.current = submissions;
|
submissionsRef.current = submissions;
|
||||||
@@ -208,8 +184,7 @@ const Mission = () => {
|
|||||||
language: language,
|
language: language,
|
||||||
languageVersion: 'latest',
|
languageVersion: 'latest',
|
||||||
sourceCode: code,
|
sourceCode: code,
|
||||||
contestAttemptId:
|
contestId: contestId,
|
||||||
attempt?.attemptId,
|
|
||||||
}),
|
}),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
dispatch(
|
dispatch(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
import { toastError } from '../../lib/toastNotification';
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Типы
|
// Типы
|
||||||
@@ -34,12 +33,6 @@ interface ArticlesState {
|
|||||||
status: Status;
|
status: Status;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
fetchNewArticles: {
|
|
||||||
articles: Article[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
fetchArticleById: {
|
fetchArticleById: {
|
||||||
article?: Article;
|
article?: Article;
|
||||||
status: Status;
|
status: Status;
|
||||||
@@ -73,12 +66,6 @@ const initialState: ArticlesState = {
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: undefined,
|
error: undefined,
|
||||||
},
|
},
|
||||||
fetchNewArticles: {
|
|
||||||
articles: [],
|
|
||||||
hasNextPage: false,
|
|
||||||
status: 'idle',
|
|
||||||
error: undefined,
|
|
||||||
},
|
|
||||||
fetchArticleById: {
|
fetchArticleById: {
|
||||||
article: undefined,
|
article: undefined,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@@ -109,42 +96,13 @@ const initialState: ArticlesState = {
|
|||||||
// 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(
|
export const fetchArticles = createAsyncThunk(
|
||||||
'articles/fetchArticles',
|
'articles/fetchArticles',
|
||||||
async (
|
async (
|
||||||
{
|
{
|
||||||
page = 0,
|
page = 0,
|
||||||
pageSize = 100,
|
pageSize = 10,
|
||||||
tags,
|
tags,
|
||||||
}: { page?: number; pageSize?: number; tags?: string[] } = {},
|
}: { page?: number; pageSize?: number; tags?: string[] } = {},
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
@@ -152,17 +110,14 @@ export const fetchArticles = createAsyncThunk(
|
|||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (tags && tags.length > 0) params.tags = tags;
|
if (tags && tags.length > 0) params.tags = tags;
|
||||||
|
|
||||||
const response = await axios.get<ArticlesResponse>('/articles', {
|
const response = await axios.get<ArticlesResponse>('/articles', {
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
|
||||||
indexes: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при получении статей',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -175,7 +130,10 @@ export const fetchMyArticles = createAsyncThunk(
|
|||||||
const response = await axios.get<Article[]>('/articles/my');
|
const response = await axios.get<Article[]>('/articles/my');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при получении моих статей',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -188,7 +146,9 @@ export const fetchArticleById = createAsyncThunk(
|
|||||||
const response = await axios.get<Article>(`/articles/${articleId}`);
|
const response = await axios.get<Article>(`/articles/${articleId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при получении статьи',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -212,7 +172,9 @@ export const createArticle = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при создании статьи',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -240,7 +202,9 @@ export const updateArticle = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при обновлении статьи',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -253,7 +217,9 @@ export const deleteArticle = createAsyncThunk(
|
|||||||
await axios.delete(`/articles/${articleId}`);
|
await axios.delete(`/articles/${articleId}`);
|
||||||
return articleId;
|
return articleId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при удалении статьи',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -292,35 +258,7 @@ const articlesSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchArticles.rejected, (state, action: any) => {
|
builder.addCase(fetchArticles.rejected, (state, action: any) => {
|
||||||
state.fetchArticles.status = 'failed';
|
state.fetchArticles.status = 'failed';
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
state.fetchArticles.error = action.payload;
|
||||||
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
|
// fetchMyArticles
|
||||||
@@ -337,12 +275,7 @@ const articlesSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchMyArticles.rejected, (state, action: any) => {
|
builder.addCase(fetchMyArticles.rejected, (state, action: any) => {
|
||||||
state.fetchMyArticles.status = 'failed';
|
state.fetchMyArticles.status = 'failed';
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
state.fetchMyArticles.error = action.payload;
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetchArticleById
|
// fetchArticleById
|
||||||
@@ -359,12 +292,7 @@ const articlesSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchArticleById.rejected, (state, action: any) => {
|
builder.addCase(fetchArticleById.rejected, (state, action: any) => {
|
||||||
state.fetchArticleById.status = 'failed';
|
state.fetchArticleById.status = 'failed';
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
state.fetchArticleById.error = action.payload;
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// createArticle
|
// createArticle
|
||||||
@@ -381,14 +309,7 @@ const articlesSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(createArticle.rejected, (state, action: any) => {
|
builder.addCase(createArticle.rejected, (state, action: any) => {
|
||||||
state.createArticle.status = 'failed';
|
state.createArticle.status = 'failed';
|
||||||
state.createArticle.error = action.payload.title;
|
state.createArticle.error = action.payload;
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// updateArticle
|
// updateArticle
|
||||||
@@ -405,14 +326,7 @@ const articlesSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(updateArticle.rejected, (state, action: any) => {
|
builder.addCase(updateArticle.rejected, (state, action: any) => {
|
||||||
state.updateArticle.status = 'failed';
|
state.updateArticle.status = 'failed';
|
||||||
state.createArticle.error = action.payload.title;
|
state.updateArticle.error = action.payload;
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// deleteArticle
|
// deleteArticle
|
||||||
@@ -436,12 +350,7 @@ const articlesSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(deleteArticle.rejected, (state, action: any) => {
|
builder.addCase(deleteArticle.rejected, (state, action: any) => {
|
||||||
state.deleteArticle.status = 'failed';
|
state.deleteArticle.status = 'failed';
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
state.deleteArticle.error = action.payload;
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
import { toastError } from '../../lib/toastNotification';
|
|
||||||
|
|
||||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
|
||||||
|
|
||||||
// 🔹 Декодирование JWT
|
// 🔹 Декодирование JWT
|
||||||
function decodeJwt(token: string) {
|
function decodeJwt(token: string) {
|
||||||
@@ -18,12 +15,8 @@ interface AuthState {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
status: Status;
|
status: 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
error: string | null;
|
error: string | null;
|
||||||
register: {
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
status: Status;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
|
// 🔹 Инициализация состояния с синхронной загрузкой из localStorage
|
||||||
@@ -38,9 +31,6 @@ const initialState: AuthState = {
|
|||||||
id: null,
|
id: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
register: {
|
|
||||||
status: 'idle',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Если токен есть, подставляем в axios и декодируем
|
// Если токен есть, подставляем в axios и декодируем
|
||||||
@@ -86,7 +76,9 @@ export const registerUser = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} 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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -173,15 +165,6 @@ const authSlice = createSlice({
|
|||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
delete axios.defaults.headers.common['Authorization'];
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
},
|
},
|
||||||
setAuthStatus: (
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{ key: keyof AuthState; status: Status }>,
|
|
||||||
) => {
|
|
||||||
const { key, status } = action.payload;
|
|
||||||
if (state[key]) {
|
|
||||||
(state[key] as any).status = status;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// ----------------- Register -----------------
|
// ----------------- Register -----------------
|
||||||
@@ -216,12 +199,7 @@ const authSlice = createSlice({
|
|||||||
});
|
});
|
||||||
builder.addCase(registerUser.rejected, (state, action) => {
|
builder.addCase(registerUser.rejected, (state, action) => {
|
||||||
state.status = 'failed';
|
state.status = 'failed';
|
||||||
state.register.errors = action.payload as Record<string, string[]>;
|
state.error = action.payload as string;
|
||||||
Object.values(state.register.errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------- Login -----------------
|
// ----------------- Login -----------------
|
||||||
@@ -326,5 +304,5 @@ const authSlice = createSlice({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { logout, setAuthStatus } = authSlice.actions;
|
export const { logout } = authSlice.actions;
|
||||||
export const authReducer = authSlice.reducer;
|
export const authReducer = authSlice.reducer;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
import { toastError } from '../../lib/toastNotification';
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Типы
|
// Типы
|
||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Типы для посылок
|
||||||
|
// =====================
|
||||||
|
|
||||||
export interface Solution {
|
export interface Solution {
|
||||||
id: number;
|
id: number;
|
||||||
missionId: number;
|
missionId: number;
|
||||||
@@ -70,26 +73,11 @@ export interface Contest {
|
|||||||
members?: Member[];
|
members?: Member[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attempt {
|
|
||||||
attemptId: number;
|
|
||||||
contestId: number;
|
|
||||||
startedAt: string;
|
|
||||||
expiresAt: string;
|
|
||||||
finished: boolean;
|
|
||||||
submissions?: Submission[];
|
|
||||||
results?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContestsResponse {
|
interface ContestsResponse {
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MembersPage {
|
|
||||||
members: Member[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateContestBody {
|
export interface CreateContestBody {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -154,65 +142,17 @@ interface ContestsState {
|
|||||||
status: Status;
|
status: Status;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// NEW:
|
|
||||||
fetchContestMembers: {
|
|
||||||
members: Member[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
addOrUpdateMember: {
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
deleteContestMember: {
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
startAttempt: {
|
|
||||||
attempt?: Attempt;
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
fetchMyAttemptsInContest: {
|
|
||||||
attempts: Attempt[];
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
fetchMyAllAttempts: {
|
|
||||||
attempts: Attempt[];
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
fetchMyActiveAttempt: {
|
|
||||||
attempt?: Attempt | null;
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
checkRegistration: {
|
|
||||||
registered: boolean;
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUpcomingEligible: {
|
|
||||||
contests: Contest[];
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchParticipating: {
|
|
||||||
contests: Contest[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
status: Status;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyContest: Contest = {
|
const initialState: ContestsState = {
|
||||||
|
fetchContests: {
|
||||||
|
contests: [],
|
||||||
|
hasNextPage: false,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
fetchContestById: {
|
||||||
|
contest: {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -223,37 +163,73 @@ const emptyContest: Contest = {
|
|||||||
attemptDurationMinutes: 0,
|
attemptDurationMinutes: 0,
|
||||||
maxAttempts: 0,
|
maxAttempts: 0,
|
||||||
allowEarlyFinish: false,
|
allowEarlyFinish: false,
|
||||||
|
groupId: undefined,
|
||||||
|
groupName: undefined,
|
||||||
missions: [],
|
missions: [],
|
||||||
articles: [],
|
articles: [],
|
||||||
members: [],
|
members: [],
|
||||||
};
|
},
|
||||||
|
|
||||||
const initialState: ContestsState = {
|
|
||||||
fetchContests: { contests: [], hasNextPage: false, status: 'idle' },
|
|
||||||
fetchContestById: { contest: emptyContest, status: 'idle' },
|
|
||||||
createContest: { contest: emptyContest, status: 'idle' },
|
|
||||||
fetchMySubmissions: { submissions: [], status: 'idle' },
|
|
||||||
updateContest: { contest: emptyContest, status: 'idle' },
|
|
||||||
deleteContest: { status: 'idle' },
|
|
||||||
fetchMyContests: { contests: [], status: 'idle' },
|
|
||||||
fetchRegisteredContests: {
|
|
||||||
contests: [],
|
|
||||||
hasNextPage: false,
|
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
fetchMySubmissions: {
|
||||||
|
submissions: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchContestMembers: { members: [], hasNextPage: false, status: 'idle' },
|
createContest: {
|
||||||
addOrUpdateMember: { status: 'idle' },
|
contest: {
|
||||||
deleteContestMember: { status: 'idle' },
|
id: 0,
|
||||||
|
name: '',
|
||||||
startAttempt: { status: 'idle' },
|
description: '',
|
||||||
fetchMyAttemptsInContest: { attempts: [], status: 'idle' },
|
scheduleType: 'AlwaysOpen',
|
||||||
fetchMyAllAttempts: { attempts: [], status: 'idle' },
|
visibility: 'Public',
|
||||||
fetchMyActiveAttempt: { attempt: null, status: 'idle' },
|
startsAt: '',
|
||||||
|
endsAt: '',
|
||||||
checkRegistration: { registered: false, status: 'idle' },
|
attemptDurationMinutes: 0,
|
||||||
fetchUpcomingEligible: { contests: [], status: 'idle' },
|
maxAttempts: 0,
|
||||||
fetchParticipating: {
|
allowEarlyFinish: false,
|
||||||
|
groupId: undefined,
|
||||||
|
groupName: undefined,
|
||||||
|
missions: [],
|
||||||
|
articles: [],
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
updateContest: {
|
||||||
|
contest: {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
scheduleType: 'AlwaysOpen',
|
||||||
|
visibility: 'Public',
|
||||||
|
startsAt: '',
|
||||||
|
endsAt: '',
|
||||||
|
attemptDurationMinutes: 0,
|
||||||
|
maxAttempts: 0,
|
||||||
|
allowEarlyFinish: false,
|
||||||
|
groupId: undefined,
|
||||||
|
groupName: undefined,
|
||||||
|
missions: [],
|
||||||
|
articles: [],
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
deleteContest: {
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
fetchMyContests: {
|
||||||
|
contests: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
fetchRegisteredContests: {
|
||||||
contests: [],
|
contests: [],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@@ -265,27 +241,7 @@ const initialState: ContestsState = {
|
|||||||
// Async Thunks
|
// Async Thunks
|
||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
// Existing ----------------------------
|
// Мои посылки в контесте
|
||||||
|
|
||||||
export const fetchParticipatingContests = createAsyncThunk(
|
|
||||||
'contests/fetchParticipating',
|
|
||||||
async (
|
|
||||||
params: { page?: number; pageSize?: number } = {},
|
|
||||||
{ rejectWithValue },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const { page = 0, pageSize = 100 } = params;
|
|
||||||
const response = await axios.get<ContestsResponse>(
|
|
||||||
'/contests/participating',
|
|
||||||
{ params: { page, pageSize } },
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const fetchMySubmissions = createAsyncThunk(
|
export const fetchMySubmissions = createAsyncThunk(
|
||||||
'contests/fetchMySubmissions',
|
'contests/fetchMySubmissions',
|
||||||
async (contestId: number, { rejectWithValue }) => {
|
async (contestId: number, { rejectWithValue }) => {
|
||||||
@@ -295,11 +251,14 @@ export const fetchMySubmissions = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to fetch my submissions',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Все контесты
|
||||||
export const fetchContests = createAsyncThunk(
|
export const fetchContests = createAsyncThunk(
|
||||||
'contests/fetchAll',
|
'contests/fetchAll',
|
||||||
async (
|
async (
|
||||||
@@ -311,17 +270,20 @@ export const fetchContests = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { page = 0, pageSize = 100, groupId } = params;
|
const { page = 0, pageSize = 10, groupId } = params;
|
||||||
const response = await axios.get<ContestsResponse>('/contests', {
|
const response = await axios.get<ContestsResponse>('/contests', {
|
||||||
params: { page, pageSize, groupId },
|
params: { page, pageSize, groupId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to fetch contests',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Контест по ID
|
||||||
export const fetchContestById = createAsyncThunk(
|
export const fetchContestById = createAsyncThunk(
|
||||||
'contests/fetchById',
|
'contests/fetchById',
|
||||||
async (id: number, { rejectWithValue }) => {
|
async (id: number, { rejectWithValue }) => {
|
||||||
@@ -329,11 +291,14 @@ export const fetchContestById = createAsyncThunk(
|
|||||||
const response = await axios.get<Contest>(`/contests/${id}`);
|
const response = await axios.get<Contest>(`/contests/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to fetch contest',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Создание контеста
|
||||||
export const createContest = createAsyncThunk(
|
export const createContest = createAsyncThunk(
|
||||||
'contests/create',
|
'contests/create',
|
||||||
async (contestData: CreateContestBody, { rejectWithValue }) => {
|
async (contestData: CreateContestBody, { rejectWithValue }) => {
|
||||||
@@ -344,11 +309,14 @@ export const createContest = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to create contest',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 Обновление контеста
|
||||||
export const updateContest = createAsyncThunk(
|
export const updateContest = createAsyncThunk(
|
||||||
'contests/update',
|
'contests/update',
|
||||||
async (
|
async (
|
||||||
@@ -365,11 +333,14 @@ export const updateContest = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to update contest',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 Удаление контеста
|
||||||
export const deleteContest = createAsyncThunk(
|
export const deleteContest = createAsyncThunk(
|
||||||
'contests/delete',
|
'contests/delete',
|
||||||
async (contestId: number, { rejectWithValue }) => {
|
async (contestId: number, { rejectWithValue }) => {
|
||||||
@@ -377,11 +348,14 @@ export const deleteContest = createAsyncThunk(
|
|||||||
await axios.delete(`/contests/${contestId}`);
|
await axios.delete(`/contests/${contestId}`);
|
||||||
return contestId;
|
return contestId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to delete contest',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Контесты, созданные мной
|
||||||
export const fetchMyContests = createAsyncThunk(
|
export const fetchMyContests = createAsyncThunk(
|
||||||
'contests/fetchMyContests',
|
'contests/fetchMyContests',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { rejectWithValue }) => {
|
||||||
@@ -389,11 +363,14 @@ export const fetchMyContests = createAsyncThunk(
|
|||||||
const response = await axios.get<Contest[]>('/contests/my');
|
const response = await axios.get<Contest[]>('/contests/my');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to fetch my contests',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Контесты, где я зарегистрирован
|
||||||
export const fetchRegisteredContests = createAsyncThunk(
|
export const fetchRegisteredContests = createAsyncThunk(
|
||||||
'contests/fetchRegisteredContests',
|
'contests/fetchRegisteredContests',
|
||||||
async (
|
async (
|
||||||
@@ -401,167 +378,17 @@ export const fetchRegisteredContests = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { page = 0, pageSize = 100 } = params;
|
const { page = 0, pageSize = 10 } = params;
|
||||||
const response = await axios.get<ContestsResponse>(
|
const response = await axios.get<ContestsResponse>(
|
||||||
'/contests/registered',
|
'/contests/registered',
|
||||||
{ params: { page, pageSize } },
|
{ params: { page, pageSize } },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message ||
|
||||||
},
|
'Failed to fetch registered contests',
|
||||||
);
|
);
|
||||||
|
|
||||||
// NEW -----------------------------------
|
|
||||||
|
|
||||||
// Add or update member
|
|
||||||
export const addOrUpdateContestMember = createAsyncThunk(
|
|
||||||
'contests/addOrUpdateMember',
|
|
||||||
async (
|
|
||||||
{
|
|
||||||
contestId,
|
|
||||||
member,
|
|
||||||
}: { contestId: number; member: { userId: number; role: string } },
|
|
||||||
{ rejectWithValue },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.post<Member[]>(
|
|
||||||
`/contests/${contestId}/members`,
|
|
||||||
member,
|
|
||||||
);
|
|
||||||
return { contestId, members: response.data };
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete member
|
|
||||||
export const deleteContestMember = createAsyncThunk(
|
|
||||||
'contests/deleteContestMember',
|
|
||||||
async (
|
|
||||||
{ contestId, memberId }: { contestId: number; memberId: number },
|
|
||||||
{ rejectWithValue },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await axios.delete(`/contests/${contestId}/members/${memberId}`);
|
|
||||||
return { contestId, memberId };
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start attempt
|
|
||||||
export const startContestAttempt = createAsyncThunk(
|
|
||||||
'contests/startContestAttempt',
|
|
||||||
async (contestId: number, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.post<Attempt>(
|
|
||||||
`/contests/${contestId}/attempts`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// My attempts in contest
|
|
||||||
export const fetchMyAttemptsInContest = createAsyncThunk(
|
|
||||||
'contests/fetchMyAttemptsInContest',
|
|
||||||
async (contestId: number, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<Attempt[]>(
|
|
||||||
`/contests/${contestId}/attempts/my`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Members with pagination
|
|
||||||
export const fetchContestMembers = createAsyncThunk(
|
|
||||||
'contests/fetchContestMembers',
|
|
||||||
async (
|
|
||||||
{
|
|
||||||
contestId,
|
|
||||||
page = 0,
|
|
||||||
pageSize = 100,
|
|
||||||
}: { contestId: number; page?: number; pageSize?: number },
|
|
||||||
{ rejectWithValue },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<MembersPage>(
|
|
||||||
`/contests/${contestId}/members`,
|
|
||||||
{ params: { page, pageSize } },
|
|
||||||
);
|
|
||||||
return { contestId, ...response.data };
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check registration
|
|
||||||
export const checkContestRegistration = createAsyncThunk(
|
|
||||||
'contests/checkRegistration',
|
|
||||||
async (contestId: number, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<{ registered: boolean }>(
|
|
||||||
`/contests/${contestId}/registered`,
|
|
||||||
);
|
|
||||||
return { contestId, registered: response.data.registered };
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Upcoming eligible contests
|
|
||||||
export const fetchUpcomingEligibleContests = createAsyncThunk(
|
|
||||||
'contests/fetchUpcomingEligible',
|
|
||||||
async (_, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<Contest[]>(
|
|
||||||
'/contests/upcoming/eligible',
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// All my attempts
|
|
||||||
export const fetchMyAllAttempts = createAsyncThunk(
|
|
||||||
'contests/fetchMyAllAttempts',
|
|
||||||
async (_, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<Attempt[]>(
|
|
||||||
'/contests/attempts/my',
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Active attempt
|
|
||||||
export const fetchMyActiveAttempt = createAsyncThunk(
|
|
||||||
'contests/fetchMyActiveAttempt',
|
|
||||||
async (contestId: number, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<Attempt | null>(
|
|
||||||
`/contests/${contestId}/attempts/my/active`,
|
|
||||||
);
|
|
||||||
return { contestId, attempt: response.data };
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -574,6 +401,7 @@ const contestsSlice = createSlice({
|
|||||||
name: 'contests',
|
name: 'contests',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
// 🆕 Сброс статусов
|
||||||
setContestStatus: (
|
setContestStatus: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ key: keyof ContestsState; status: Status }>,
|
action: PayloadAction<{ key: keyof ContestsState; status: Status }>,
|
||||||
@@ -585,8 +413,7 @@ const contestsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// ——— YOUR EXISTING HANDLERS (unchanged) ———
|
// 🆕 fetchMySubmissions
|
||||||
|
|
||||||
builder.addCase(fetchMySubmissions.pending, (state) => {
|
builder.addCase(fetchMySubmissions.pending, (state) => {
|
||||||
state.fetchMySubmissions.status = 'loading';
|
state.fetchMySubmissions.status = 'loading';
|
||||||
state.fetchMySubmissions.error = undefined;
|
state.fetchMySubmissions.error = undefined;
|
||||||
@@ -600,17 +427,13 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchMySubmissions.rejected, (state, action: any) => {
|
builder.addCase(fetchMySubmissions.rejected, (state, action: any) => {
|
||||||
state.fetchMySubmissions.status = 'failed';
|
state.fetchMySubmissions.status = 'failed';
|
||||||
|
state.fetchMySubmissions.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fetchContests
|
||||||
builder.addCase(fetchContests.pending, (state) => {
|
builder.addCase(fetchContests.pending, (state) => {
|
||||||
state.fetchContests.status = 'loading';
|
state.fetchContests.status = 'loading';
|
||||||
|
state.fetchContests.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchContests.fulfilled,
|
fetchContests.fulfilled,
|
||||||
@@ -622,17 +445,13 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchContests.rejected, (state, action: any) => {
|
builder.addCase(fetchContests.rejected, (state, action: any) => {
|
||||||
state.fetchContests.status = 'failed';
|
state.fetchContests.status = 'failed';
|
||||||
|
state.fetchContests.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fetchContestById
|
||||||
builder.addCase(fetchContestById.pending, (state) => {
|
builder.addCase(fetchContestById.pending, (state) => {
|
||||||
state.fetchContestById.status = 'loading';
|
state.fetchContestById.status = 'loading';
|
||||||
|
state.fetchContestById.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchContestById.fulfilled,
|
fetchContestById.fulfilled,
|
||||||
@@ -643,17 +462,13 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchContestById.rejected, (state, action: any) => {
|
builder.addCase(fetchContestById.rejected, (state, action: any) => {
|
||||||
state.fetchContestById.status = 'failed';
|
state.fetchContestById.status = 'failed';
|
||||||
|
state.fetchContestById.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// createContest
|
||||||
builder.addCase(createContest.pending, (state) => {
|
builder.addCase(createContest.pending, (state) => {
|
||||||
state.createContest.status = 'loading';
|
state.createContest.status = 'loading';
|
||||||
|
state.createContest.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
createContest.fulfilled,
|
createContest.fulfilled,
|
||||||
@@ -664,17 +479,13 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(createContest.rejected, (state, action: any) => {
|
builder.addCase(createContest.rejected, (state, action: any) => {
|
||||||
state.createContest.status = 'failed';
|
state.createContest.status = 'failed';
|
||||||
|
state.createContest.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 updateContest
|
||||||
builder.addCase(updateContest.pending, (state) => {
|
builder.addCase(updateContest.pending, (state) => {
|
||||||
state.updateContest.status = 'loading';
|
state.updateContest.status = 'loading';
|
||||||
|
state.updateContest.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
updateContest.fulfilled,
|
updateContest.fulfilled,
|
||||||
@@ -685,22 +496,19 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(updateContest.rejected, (state, action: any) => {
|
builder.addCase(updateContest.rejected, (state, action: any) => {
|
||||||
state.updateContest.status = 'failed';
|
state.updateContest.status = 'failed';
|
||||||
|
state.updateContest.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 deleteContest
|
||||||
builder.addCase(deleteContest.pending, (state) => {
|
builder.addCase(deleteContest.pending, (state) => {
|
||||||
state.deleteContest.status = 'loading';
|
state.deleteContest.status = 'loading';
|
||||||
|
state.deleteContest.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
deleteContest.fulfilled,
|
deleteContest.fulfilled,
|
||||||
(state, action: PayloadAction<number>) => {
|
(state, action: PayloadAction<number>) => {
|
||||||
state.deleteContest.status = 'successful';
|
state.deleteContest.status = 'successful';
|
||||||
|
// Удалим контест из списков
|
||||||
state.fetchContests.contests =
|
state.fetchContests.contests =
|
||||||
state.fetchContests.contests.filter(
|
state.fetchContests.contests.filter(
|
||||||
(c) => c.id !== action.payload,
|
(c) => c.id !== action.payload,
|
||||||
@@ -713,17 +521,13 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(deleteContest.rejected, (state, action: any) => {
|
builder.addCase(deleteContest.rejected, (state, action: any) => {
|
||||||
state.deleteContest.status = 'failed';
|
state.deleteContest.status = 'failed';
|
||||||
|
state.deleteContest.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fetchMyContests
|
||||||
builder.addCase(fetchMyContests.pending, (state) => {
|
builder.addCase(fetchMyContests.pending, (state) => {
|
||||||
state.fetchMyContests.status = 'loading';
|
state.fetchMyContests.status = 'loading';
|
||||||
|
state.fetchMyContests.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchMyContests.fulfilled,
|
fetchMyContests.fulfilled,
|
||||||
@@ -734,17 +538,13 @@ const contestsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchMyContests.rejected, (state, action: any) => {
|
builder.addCase(fetchMyContests.rejected, (state, action: any) => {
|
||||||
state.fetchMyContests.status = 'failed';
|
state.fetchMyContests.status = 'failed';
|
||||||
|
state.fetchMyContests.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fetchRegisteredContests
|
||||||
builder.addCase(fetchRegisteredContests.pending, (state) => {
|
builder.addCase(fetchRegisteredContests.pending, (state) => {
|
||||||
state.fetchRegisteredContests.status = 'loading';
|
state.fetchRegisteredContests.status = 'loading';
|
||||||
|
state.fetchRegisteredContests.error = undefined;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchRegisteredContests.fulfilled,
|
fetchRegisteredContests.fulfilled,
|
||||||
@@ -760,269 +560,7 @@ const contestsSlice = createSlice({
|
|||||||
fetchRegisteredContests.rejected,
|
fetchRegisteredContests.rejected,
|
||||||
(state, action: any) => {
|
(state, action: any) => {
|
||||||
state.fetchRegisteredContests.status = 'failed';
|
state.fetchRegisteredContests.status = 'failed';
|
||||||
const errors = action.payload.errors as Record<
|
state.fetchRegisteredContests.error = action.payload;
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// NEW HANDLERS
|
|
||||||
|
|
||||||
builder.addCase(fetchContestMembers.pending, (state) => {
|
|
||||||
state.fetchContestMembers.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
fetchContestMembers.fulfilled,
|
|
||||||
(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
contestId: number;
|
|
||||||
members: Member[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
state.fetchContestMembers.status = 'successful';
|
|
||||||
state.fetchContestMembers.members = action.payload.members;
|
|
||||||
state.fetchContestMembers.hasNextPage =
|
|
||||||
action.payload.hasNextPage;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(fetchContestMembers.rejected, (state, action: any) => {
|
|
||||||
state.fetchContestMembers.status = 'failed';
|
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.addCase(addOrUpdateContestMember.pending, (state) => {
|
|
||||||
state.addOrUpdateMember.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(addOrUpdateContestMember.fulfilled, (state) => {
|
|
||||||
state.addOrUpdateMember.status = 'successful';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
addOrUpdateContestMember.rejected,
|
|
||||||
(state, action: any) => {
|
|
||||||
state.addOrUpdateMember.status = 'failed';
|
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.addCase(deleteContestMember.pending, (state) => {
|
|
||||||
state.deleteContestMember.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(deleteContestMember.fulfilled, (state) => {
|
|
||||||
state.deleteContestMember.status = 'successful';
|
|
||||||
});
|
|
||||||
builder.addCase(deleteContestMember.rejected, (state, action: any) => {
|
|
||||||
state.deleteContestMember.status = 'failed';
|
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.addCase(startContestAttempt.pending, (state) => {
|
|
||||||
state.startAttempt.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
startContestAttempt.fulfilled,
|
|
||||||
(state, action: PayloadAction<Attempt>) => {
|
|
||||||
state.startAttempt.status = 'successful';
|
|
||||||
state.startAttempt.attempt = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(startContestAttempt.rejected, (state, action: any) => {
|
|
||||||
state.startAttempt.status = 'failed';
|
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.addCase(fetchMyAttemptsInContest.pending, (state) => {
|
|
||||||
state.fetchMyAttemptsInContest.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
fetchMyAttemptsInContest.fulfilled,
|
|
||||||
(state, action: PayloadAction<Attempt[]>) => {
|
|
||||||
state.fetchMyAttemptsInContest.status = 'successful';
|
|
||||||
state.fetchMyAttemptsInContest.attempts = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchMyAttemptsInContest.rejected,
|
|
||||||
(state, action: any) => {
|
|
||||||
state.fetchMyAttemptsInContest.status = 'failed';
|
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.addCase(fetchMyAllAttempts.pending, (state) => {
|
|
||||||
state.fetchMyAllAttempts.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
fetchMyAllAttempts.fulfilled,
|
|
||||||
(state, action: PayloadAction<Attempt[]>) => {
|
|
||||||
state.fetchMyAllAttempts.status = 'successful';
|
|
||||||
state.fetchMyAllAttempts.attempts = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(fetchMyAllAttempts.rejected, (state, action: any) => {
|
|
||||||
state.fetchMyAllAttempts.status = 'failed';
|
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.addCase(fetchMyActiveAttempt.pending, (state) => {
|
|
||||||
state.fetchMyActiveAttempt.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
fetchMyActiveAttempt.fulfilled,
|
|
||||||
(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
contestId: number;
|
|
||||||
attempt: Attempt | null;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
state.fetchMyActiveAttempt.status = 'successful';
|
|
||||||
state.fetchMyActiveAttempt.attempt = action.payload.attempt;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(fetchMyActiveAttempt.rejected, (state, action: any) => {
|
|
||||||
state.fetchMyActiveAttempt.status = 'failed';
|
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.addCase(checkContestRegistration.pending, (state) => {
|
|
||||||
state.checkRegistration.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
checkContestRegistration.fulfilled,
|
|
||||||
(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
contestId: number;
|
|
||||||
registered: boolean;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
state.checkRegistration.status = 'successful';
|
|
||||||
state.checkRegistration.registered = action.payload.registered;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
checkContestRegistration.rejected,
|
|
||||||
(state, action: any) => {
|
|
||||||
state.checkRegistration.status = 'failed';
|
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.addCase(fetchUpcomingEligibleContests.pending, (state) => {
|
|
||||||
state.fetchUpcomingEligible.status = 'loading';
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
fetchUpcomingEligibleContests.fulfilled,
|
|
||||||
(state, action: PayloadAction<Contest[]>) => {
|
|
||||||
state.fetchUpcomingEligible.status = 'successful';
|
|
||||||
state.fetchUpcomingEligible.contests = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchUpcomingEligibleContests.rejected,
|
|
||||||
(state, action: any) => {
|
|
||||||
state.fetchUpcomingEligible.status = 'failed';
|
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.addCase(fetchParticipatingContests.pending, (state) => {
|
|
||||||
state.fetchParticipating.status = 'loading';
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.addCase(
|
|
||||||
fetchParticipatingContests.fulfilled,
|
|
||||||
(state, action: PayloadAction<ContestsResponse>) => {
|
|
||||||
state.fetchParticipating.status = 'successful';
|
|
||||||
state.fetchParticipating.contests = action.payload.contests;
|
|
||||||
state.fetchParticipating.hasNextPage =
|
|
||||||
action.payload.hasNextPage;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.addCase(
|
|
||||||
fetchParticipatingContests.rejected,
|
|
||||||
(state, action: any) => {
|
|
||||||
state.fetchParticipating.status = 'failed';
|
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
import { toastError } from '../../lib/toastNotification';
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Типы
|
// Типы
|
||||||
@@ -95,7 +94,7 @@ export const fetchGroupPosts = createAsyncThunk(
|
|||||||
{
|
{
|
||||||
groupId,
|
groupId,
|
||||||
page = 0,
|
page = 0,
|
||||||
pageSize = 100,
|
pageSize = 20,
|
||||||
}: { groupId: number; page?: number; pageSize?: number },
|
}: { groupId: number; page?: number; pageSize?: number },
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
@@ -105,7 +104,9 @@ export const fetchGroupPosts = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return { page, data: response.data as PostsPage };
|
return { page, data: response.data as PostsPage };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка загрузки постов',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -123,7 +124,9 @@ export const fetchPostById = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return response.data as Post;
|
return response.data as Post;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка загрузки поста',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -146,7 +149,9 @@ export const createPost = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data as Post;
|
return response.data as Post;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка создания поста',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -178,7 +183,9 @@ export const updatePost = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return response.data as Post;
|
return response.data as Post;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка обновления поста',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -194,7 +201,9 @@ export const deletePost = createAsyncThunk(
|
|||||||
await axios.delete(`/groups/${groupId}/feed/${postId}`);
|
await axios.delete(`/groups/${groupId}/feed/${postId}`);
|
||||||
return postId;
|
return postId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка удаления поста',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -235,13 +244,7 @@ const postsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchGroupPosts.rejected, (state, action: any) => {
|
builder.addCase(fetchGroupPosts.rejected, (state, action: any) => {
|
||||||
state.fetchPosts.status = 'failed';
|
state.fetchPosts.status = 'failed';
|
||||||
|
state.fetchPosts.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetchPostById
|
// fetchPostById
|
||||||
@@ -257,13 +260,7 @@ const postsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchPostById.rejected, (state, action: any) => {
|
builder.addCase(fetchPostById.rejected, (state, action: any) => {
|
||||||
state.fetchPostById.status = 'failed';
|
state.fetchPostById.status = 'failed';
|
||||||
|
state.fetchPostById.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// createPost
|
// createPost
|
||||||
@@ -284,13 +281,7 @@ const postsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(createPost.rejected, (state, action: any) => {
|
builder.addCase(createPost.rejected, (state, action: any) => {
|
||||||
state.createPost.status = 'failed';
|
state.createPost.status = 'failed';
|
||||||
|
state.createPost.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// updatePost
|
// updatePost
|
||||||
@@ -319,13 +310,7 @@ const postsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(updatePost.rejected, (state, action: any) => {
|
builder.addCase(updatePost.rejected, (state, action: any) => {
|
||||||
state.updatePost.status = 'failed';
|
state.updatePost.status = 'failed';
|
||||||
|
state.updatePost.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// deletePost
|
// deletePost
|
||||||
@@ -353,13 +338,7 @@ const postsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(deletePost.rejected, (state, action: any) => {
|
builder.addCase(deletePost.rejected, (state, action: any) => {
|
||||||
state.deletePost.status = 'failed';
|
state.deletePost.status = 'failed';
|
||||||
|
state.deletePost.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
import { toastError } from '../../lib/toastNotification';
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Типы
|
// Типы
|
||||||
@@ -132,7 +131,9 @@ export const createGroup = createAsyncThunk(
|
|||||||
const response = await axios.post('/groups', { name, description });
|
const response = await axios.post('/groups', { name, description });
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при создании группы',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -154,7 +155,9 @@ export const updateGroup = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при обновлении группы',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -166,7 +169,9 @@ export const deleteGroup = createAsyncThunk(
|
|||||||
await axios.delete(`/groups/${groupId}`);
|
await axios.delete(`/groups/${groupId}`);
|
||||||
return groupId;
|
return groupId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при удалении группы',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -178,7 +183,9 @@ export const fetchMyGroups = createAsyncThunk(
|
|||||||
const response = await axios.get('/groups/my');
|
const response = await axios.get('/groups/my');
|
||||||
return response.data.groups as Group[];
|
return response.data.groups as Group[];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при получении групп',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -190,7 +197,9 @@ export const fetchGroupById = createAsyncThunk(
|
|||||||
const response = await axios.get(`/groups/${groupId}`);
|
const response = await axios.get(`/groups/${groupId}`);
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при получении группы',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -212,7 +221,10 @@ export const addGroupMember = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при добавлении участника',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -227,7 +239,9 @@ export const removeGroupMember = createAsyncThunk(
|
|||||||
await axios.delete(`/groups/${groupId}/members/${memberId}`);
|
await axios.delete(`/groups/${groupId}/members/${memberId}`);
|
||||||
return { groupId, memberId };
|
return { groupId, memberId };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при удалении участника',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -244,7 +258,10 @@ export const fetchGroupJoinLink = createAsyncThunk(
|
|||||||
const response = await axios.get(`/groups/${groupId}/join-link`);
|
const response = await axios.get(`/groups/${groupId}/join-link`);
|
||||||
return response.data as { token: string; expiresAt: string };
|
return response.data as { token: string; expiresAt: string };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при получении ссылки для присоединения',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -257,7 +274,10 @@ export const joinGroupByToken = createAsyncThunk(
|
|||||||
const response = await axios.post(`/groups/join/${token}`);
|
const response = await axios.post(`/groups/join/${token}`);
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при присоединении к группе по ссылке',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -294,13 +314,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
|
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
|
||||||
state.fetchMyGroups.status = 'failed';
|
state.fetchMyGroups.status = 'failed';
|
||||||
|
state.fetchMyGroups.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetchGroupById
|
// fetchGroupById
|
||||||
@@ -316,13 +330,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
|
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
|
||||||
state.fetchGroupById.status = 'failed';
|
state.fetchGroupById.status = 'failed';
|
||||||
|
state.fetchGroupById.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// createGroup
|
// createGroup
|
||||||
@@ -339,13 +347,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(createGroup.rejected, (state, action: any) => {
|
builder.addCase(createGroup.rejected, (state, action: any) => {
|
||||||
state.createGroup.status = 'failed';
|
state.createGroup.status = 'failed';
|
||||||
|
state.createGroup.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// updateGroup
|
// updateGroup
|
||||||
@@ -368,13 +370,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(updateGroup.rejected, (state, action: any) => {
|
builder.addCase(updateGroup.rejected, (state, action: any) => {
|
||||||
state.updateGroup.status = 'failed';
|
state.updateGroup.status = 'failed';
|
||||||
|
state.updateGroup.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// deleteGroup
|
// deleteGroup
|
||||||
@@ -395,13 +391,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(deleteGroup.rejected, (state, action: any) => {
|
builder.addCase(deleteGroup.rejected, (state, action: any) => {
|
||||||
state.deleteGroup.status = 'failed';
|
state.deleteGroup.status = 'failed';
|
||||||
|
state.deleteGroup.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// addGroupMember
|
// addGroupMember
|
||||||
@@ -413,13 +403,7 @@ const groupsSlice = createSlice({
|
|||||||
});
|
});
|
||||||
builder.addCase(addGroupMember.rejected, (state, action: any) => {
|
builder.addCase(addGroupMember.rejected, (state, action: any) => {
|
||||||
state.addGroupMember.status = 'failed';
|
state.addGroupMember.status = 'failed';
|
||||||
|
state.addGroupMember.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// removeGroupMember
|
// removeGroupMember
|
||||||
@@ -446,13 +430,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
|
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
|
||||||
state.removeGroupMember.status = 'failed';
|
state.removeGroupMember.status = 'failed';
|
||||||
|
state.removeGroupMember.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetchGroupJoinLink
|
// fetchGroupJoinLink
|
||||||
@@ -471,13 +449,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
|
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
|
||||||
state.fetchGroupJoinLink.status = 'failed';
|
state.fetchGroupJoinLink.status = 'failed';
|
||||||
|
state.fetchGroupJoinLink.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// joinGroupByToken
|
// joinGroupByToken
|
||||||
@@ -494,13 +466,7 @@ const groupsSlice = createSlice({
|
|||||||
);
|
);
|
||||||
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
|
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
|
||||||
state.joinGroupByToken.status = 'failed';
|
state.joinGroupByToken.status = 'failed';
|
||||||
|
state.joinGroupByToken.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<string, string[]>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
import { toastError } from '../../lib/toastNotification';
|
|
||||||
|
|
||||||
// ─── Типы ────────────────────────────────────────────
|
// ─── Типы ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -28,18 +27,13 @@ export interface Mission {
|
|||||||
|
|
||||||
interface MissionsState {
|
interface MissionsState {
|
||||||
missions: Mission[];
|
missions: Mission[];
|
||||||
newMissions: Mission[];
|
|
||||||
currentMission: Mission | null;
|
currentMission: Mission | null;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
create: {
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
};
|
|
||||||
statuses: {
|
statuses: {
|
||||||
fetchList: Status;
|
fetchList: Status;
|
||||||
fetchById: Status;
|
fetchById: Status;
|
||||||
upload: Status;
|
upload: Status;
|
||||||
fetchMy: Status;
|
fetchMy: Status;
|
||||||
delete: Status;
|
|
||||||
};
|
};
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -48,16 +42,13 @@ interface MissionsState {
|
|||||||
|
|
||||||
const initialState: MissionsState = {
|
const initialState: MissionsState = {
|
||||||
missions: [],
|
missions: [],
|
||||||
newMissions: [],
|
|
||||||
currentMission: null,
|
currentMission: null,
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
create: {},
|
|
||||||
statuses: {
|
statuses: {
|
||||||
fetchList: 'idle',
|
fetchList: 'idle',
|
||||||
fetchById: 'idle',
|
fetchById: 'idle',
|
||||||
upload: 'idle',
|
upload: 'idle',
|
||||||
fetchMy: 'idle',
|
fetchMy: 'idle',
|
||||||
delete: 'idle',
|
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -67,33 +58,6 @@ const initialState: MissionsState = {
|
|||||||
// GET /missions
|
// GET /missions
|
||||||
export const fetchMissions = createAsyncThunk(
|
export const fetchMissions = createAsyncThunk(
|
||||||
'missions/fetchMissions',
|
'missions/fetchMissions',
|
||||||
async (
|
|
||||||
{
|
|
||||||
page = 0,
|
|
||||||
pageSize = 100,
|
|
||||||
tags = [],
|
|
||||||
}: { page?: number; pageSize?: number; tags?: string[] },
|
|
||||||
{ rejectWithValue },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const params: any = { page, pageSize };
|
|
||||||
if (tags.length) params.tags = tags;
|
|
||||||
const response = await axios.get('/missions', {
|
|
||||||
params,
|
|
||||||
paramsSerializer: {
|
|
||||||
indexes: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data; // { missions, hasNextPage }
|
|
||||||
} catch (err: any) {
|
|
||||||
return rejectWithValue(err.response?.data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /missions
|
|
||||||
export const fetchNewMissions = createAsyncThunk(
|
|
||||||
'missions/fetchNewMissions',
|
|
||||||
async (
|
async (
|
||||||
{
|
{
|
||||||
page = 0,
|
page = 0,
|
||||||
@@ -105,15 +69,12 @@ export const fetchNewMissions = createAsyncThunk(
|
|||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (tags.length) params.tags = tags;
|
if (tags.length) params.tags = tags;
|
||||||
const response = await axios.get('/missions', {
|
const response = await axios.get('/missions', { params });
|
||||||
params,
|
|
||||||
paramsSerializer: {
|
|
||||||
indexes: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data; // { missions, hasNextPage }
|
return response.data; // { missions, hasNextPage }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при получении миссий',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -126,7 +87,9 @@ export const fetchMissionById = createAsyncThunk(
|
|||||||
const response = await axios.get(`/missions/${id}`);
|
const response = await axios.get(`/missions/${id}`);
|
||||||
return response.data; // Mission
|
return response.data; // Mission
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка при получении миссии',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -139,7 +102,10 @@ export const fetchMyMissions = createAsyncThunk(
|
|||||||
const response = await axios.get('/missions/my');
|
const response = await axios.get('/missions/my');
|
||||||
return response.data as Mission[]; // массив миссий пользователя
|
return response.data as Mission[]; // массив миссий пользователя
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data);
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при получении моих миссий',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -168,20 +134,9 @@ export const uploadMission = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
return response.data; // Mission
|
return response.data; // Mission
|
||||||
} catch (err: any) {
|
} 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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -227,52 +182,7 @@ const missionsSlice = createSlice({
|
|||||||
fetchMissions.rejected,
|
fetchMissions.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.fetchList = 'failed';
|
state.statuses.fetchList = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── FETCH NEW MISSIONS ───
|
|
||||||
builder.addCase(fetchNewMissions.pending, (state) => {
|
|
||||||
state.statuses.fetchList = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
fetchNewMissions.fulfilled,
|
|
||||||
(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
missions: Mission[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
state.statuses.fetchList = 'successful';
|
|
||||||
state.newMissions = action.payload.missions;
|
|
||||||
state.hasNextPage = action.payload.hasNextPage;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchNewMissions.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.fetchList = 'failed';
|
|
||||||
|
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -292,16 +202,7 @@ const missionsSlice = createSlice({
|
|||||||
fetchMissionById.rejected,
|
fetchMissionById.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.fetchById = 'failed';
|
state.statuses.fetchById = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -321,16 +222,7 @@ const missionsSlice = createSlice({
|
|||||||
fetchMyMissions.rejected,
|
fetchMyMissions.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.fetchMy = 'failed';
|
state.statuses.fetchMy = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
Object.values(errors).forEach((messages) => {
|
|
||||||
messages.forEach((msg) => {
|
|
||||||
toastError(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -350,53 +242,7 @@ const missionsSlice = createSlice({
|
|||||||
uploadMission.rejected,
|
uploadMission.rejected,
|
||||||
(state, action: PayloadAction<any>) => {
|
(state, action: PayloadAction<any>) => {
|
||||||
state.statuses.upload = 'failed';
|
state.statuses.upload = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
const errors = action.payload.errors as Record<
|
|
||||||
string,
|
|
||||||
string[]
|
|
||||||
>;
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -7,21 +7,6 @@ interface StorState {
|
|||||||
activeProfilePage: string;
|
activeProfilePage: string;
|
||||||
activeGroupPage: string;
|
activeGroupPage: string;
|
||||||
};
|
};
|
||||||
group: {
|
|
||||||
groupFilter: string;
|
|
||||||
};
|
|
||||||
articles: {
|
|
||||||
articleTagFilter: string[];
|
|
||||||
filterName: string;
|
|
||||||
};
|
|
||||||
contests: {
|
|
||||||
contestsTagFilter: string[];
|
|
||||||
filterName: string;
|
|
||||||
};
|
|
||||||
missions: {
|
|
||||||
missionsTagFilter: string[];
|
|
||||||
filterName: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация состояния
|
// Инициализация состояния
|
||||||
@@ -31,21 +16,6 @@ const initialState: StorState = {
|
|||||||
activeProfilePage: '',
|
activeProfilePage: '',
|
||||||
activeGroupPage: '',
|
activeGroupPage: '',
|
||||||
},
|
},
|
||||||
group: {
|
|
||||||
groupFilter: '',
|
|
||||||
},
|
|
||||||
articles: {
|
|
||||||
articleTagFilter: [],
|
|
||||||
filterName: '',
|
|
||||||
},
|
|
||||||
contests: {
|
|
||||||
contestsTagFilter: [],
|
|
||||||
filterName: '',
|
|
||||||
},
|
|
||||||
missions: {
|
|
||||||
missionsTagFilter: [],
|
|
||||||
filterName: '',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slice
|
// Slice
|
||||||
@@ -53,63 +23,28 @@ const storeSlice = createSlice({
|
|||||||
name: 'store',
|
name: 'store',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setMenuActivePage: (state, action: PayloadAction<string>) => {
|
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
|
||||||
state.menu.activePage = action.payload;
|
state.menu.activePage = activePage.payload;
|
||||||
},
|
},
|
||||||
setMenuActiveProfilePage: (state, action: PayloadAction<string>) => {
|
setMenuActiveProfilePage: (
|
||||||
state.menu.activeProfilePage = action.payload;
|
state,
|
||||||
|
activeProfilePage: PayloadAction<string>,
|
||||||
|
) => {
|
||||||
|
state.menu.activeProfilePage = activeProfilePage.payload;
|
||||||
},
|
},
|
||||||
setMenuActiveGroupPage: (state, action: PayloadAction<string>) => {
|
setMenuActiveGroupPage: (
|
||||||
state.menu.activeGroupPage = action.payload;
|
state,
|
||||||
},
|
activeGroupPage: PayloadAction<string>,
|
||||||
setGroupFilter: (state, action: PayloadAction<string>) => {
|
) => {
|
||||||
state.group.groupFilter = action.payload;
|
state.menu.activeGroupPage = activeGroupPage.payload;
|
||||||
},
|
|
||||||
|
|
||||||
// ---------- ARTICLES ----------
|
|
||||||
setArticlesTagFilter: (state, action: PayloadAction<string[]>) => {
|
|
||||||
state.articles.articleTagFilter = action.payload;
|
|
||||||
},
|
|
||||||
setArticlesNameFilter: (state, action: PayloadAction<string>) => {
|
|
||||||
state.articles.filterName = action.payload;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---------- CONTESTS ----------
|
|
||||||
setContestsTagFilter: (state, action: PayloadAction<string[]>) => {
|
|
||||||
state.contests.contestsTagFilter = action.payload;
|
|
||||||
},
|
|
||||||
setContestsNameFilter: (state, action: PayloadAction<string>) => {
|
|
||||||
state.contests.filterName = action.payload;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---------- MISSIONS ----------
|
|
||||||
setMissionsTagFilter: (state, action: PayloadAction<string[]>) => {
|
|
||||||
state.missions.missionsTagFilter = action.payload;
|
|
||||||
},
|
|
||||||
setMissionsNameFilter: (state, action: PayloadAction<string>) => {
|
|
||||||
state.missions.filterName = action.payload;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
// menu
|
|
||||||
setMenuActivePage,
|
setMenuActivePage,
|
||||||
setMenuActiveProfilePage,
|
setMenuActiveProfilePage,
|
||||||
setMenuActiveGroupPage,
|
setMenuActiveGroupPage,
|
||||||
setGroupFilter,
|
|
||||||
|
|
||||||
// articles
|
|
||||||
setArticlesTagFilter,
|
|
||||||
setArticlesNameFilter,
|
|
||||||
|
|
||||||
// contests
|
|
||||||
setContestsTagFilter,
|
|
||||||
setContestsNameFilter,
|
|
||||||
|
|
||||||
// missions
|
|
||||||
setMissionsTagFilter,
|
|
||||||
setMissionsNameFilter,
|
|
||||||
} = storeSlice.actions;
|
} = storeSlice.actions;
|
||||||
|
|
||||||
export const storeReducer = storeSlice.reducer;
|
export const storeReducer = storeSlice.reducer;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Submit {
|
|||||||
language: string;
|
language: string;
|
||||||
languageVersion: string;
|
languageVersion: string;
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
contestAttemptId?: number;
|
contestId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Solution {
|
export interface Solution {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { contestsReducer } from './slices/contests';
|
|||||||
import { groupsReducer } from './slices/groups';
|
import { groupsReducer } from './slices/groups';
|
||||||
import { articlesReducer } from './slices/articles';
|
import { articlesReducer } from './slices/articles';
|
||||||
import { groupFeedReducer } from './slices/groupfeed';
|
import { groupFeedReducer } from './slices/groupfeed';
|
||||||
import { groupChatReducer } from './slices/groupChat';
|
|
||||||
import { profileReducer } from './slices/profile';
|
|
||||||
|
|
||||||
// использование
|
// использование
|
||||||
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
@@ -29,8 +27,6 @@ export const store = configureStore({
|
|||||||
groups: groupsReducer,
|
groups: groupsReducer,
|
||||||
articles: articlesReducer,
|
articles: articlesReducer,
|
||||||
groupfeed: groupFeedReducer,
|
groupfeed: groupFeedReducer,
|
||||||
groupchat: groupChatReducer,
|
|
||||||
profile: profileReducer,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ function greet(user: User) {
|
|||||||
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
|
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
|
||||||
}
|
}
|
||||||
|
|
||||||
consol.log(greet({ name: "Ты", role: "Разработчик" }));
|
console.log(greet({ name: "Ты", role: "Разработчик" }));
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Пример **JavaScript**:
|
Пример **JavaScript**:
|
||||||
|
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
const sum = (a, b) => a + b;
|
const sum = (a, b) => a + b;
|
||||||
consol.log(sum(2, 3)); // 5
|
console.log(sum(2, 3)); // 5
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Пример **Python**:
|
Пример **Python**:
|
||||||
@@ -256,7 +256,9 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
|||||||
markdown.slice(cursorPos);
|
markdown.slice(cursorPos);
|
||||||
|
|
||||||
setMarkdown(newText);
|
setMarkdown(newText);
|
||||||
} catch (err) {}
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки изображения:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,51 +4,19 @@ import RightPanel from './RightPanel';
|
|||||||
import Missions from './missions/Missions';
|
import Missions from './missions/Missions';
|
||||||
import Contests from './contests/Contests';
|
import Contests from './contests/Contests';
|
||||||
import ArticlesBlock from './articles/ArticlesBlock';
|
import ArticlesBlock from './articles/ArticlesBlock';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch } from '../../../redux/hooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { useQuery } from '../../../hooks/useQuery';
|
|
||||||
import {
|
|
||||||
fetchProfile,
|
|
||||||
fetchProfileArticles,
|
|
||||||
fetchProfileContests,
|
|
||||||
fetchProfileMissions,
|
|
||||||
} from '../../../redux/slices/profile';
|
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const myname = useAppSelector((state) => state.auth.username);
|
|
||||||
|
|
||||||
const query = useQuery();
|
|
||||||
const username = query.get('username') ?? myname ?? '';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (username == myname) {
|
|
||||||
dispatch(setMenuActivePage('account'));
|
dispatch(setMenuActivePage('account'));
|
||||||
} else {
|
}, []);
|
||||||
dispatch(setMenuActivePage(''));
|
|
||||||
}
|
|
||||||
dispatch(
|
|
||||||
fetchProfileMissions({
|
|
||||||
username: username,
|
|
||||||
recentPageSize: 1,
|
|
||||||
authoredPageSize: 100,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
dispatch(fetchProfileArticles({ username: username, pageSize: 100 }));
|
|
||||||
dispatch(
|
|
||||||
fetchProfileContests({
|
|
||||||
username: username,
|
|
||||||
pastPageSize: 100,
|
|
||||||
minePageSize: 100,
|
|
||||||
upcomingPageSize: 100,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
dispatch(fetchProfile(username));
|
|
||||||
}, [username]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,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 min-h-0 flex flex-col">
|
||||||
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { ReverseButton } from '../../../components/button/ReverseButton';
|
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { logout } from '../../../redux/slices/auth';
|
import { logout } from '../../../redux/slices/auth';
|
||||||
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
|
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { useQuery } from '../../../hooks/useQuery';
|
|
||||||
|
|
||||||
interface StatisticItemProps {
|
interface StatisticItemProps {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -34,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 RightPanel = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const name = useAppSelector((state) => state.auth.username);
|
||||||
const { data: profileData } = useAppSelector(
|
const email = useAppSelector((state) => state.auth.email);
|
||||||
(state) => state.profile.profile,
|
|
||||||
);
|
|
||||||
|
|
||||||
const myname = useAppSelector((state) => state.auth.username);
|
|
||||||
|
|
||||||
const query = useQuery();
|
|
||||||
const username = query.get('username') ?? myname ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
|
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
|
||||||
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
|
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
|
||||||
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
|
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
|
||||||
<div className=" relative">
|
<div className=" relative">
|
||||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||||
{profileData?.identity.username}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className=" text-liquid-light text-[18px] leading-[30px] font-bold">
|
|
||||||
{`Зарегистрирован ${formatDate(
|
|
||||||
profileData?.identity.createdAt,
|
|
||||||
)}`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* {username == myname && (
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
text="Редактировать"
|
text="Редактировать"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
)} */}
|
|
||||||
|
|
||||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||||
|
|
||||||
@@ -93,14 +70,14 @@ const RightPanel = () => {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Clipboard}
|
icon={Clipboard}
|
||||||
title={'Задачи'}
|
title={'Задачи'}
|
||||||
count={profileData?.solutions.totalSolved}
|
count={14}
|
||||||
countLastWeek={profileData?.solutions.solvedLast7Days}
|
countLastWeek={5}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Cup}
|
icon={Cup}
|
||||||
title={'Контесты'}
|
title={'Контесты'}
|
||||||
count={profileData?.contests.totalParticipations}
|
count={8}
|
||||||
countLastWeek={profileData?.contests.participationsLast7Days}
|
countLastWeek={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||||
@@ -110,23 +87,22 @@ const RightPanel = () => {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Clipboard}
|
icon={Clipboard}
|
||||||
title={'Задачи'}
|
title={'Задачи'}
|
||||||
count={profileData?.creation.missions.total}
|
count={4}
|
||||||
countLastWeek={profileData?.creation.missions.last7Days}
|
countLastWeek={2}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={OpenBook}
|
icon={OpenBook}
|
||||||
title={'Статьи'}
|
title={'Статьи'}
|
||||||
count={profileData?.creation.articles.total}
|
count={12}
|
||||||
countLastWeek={profileData?.creation.articles.last7Days}
|
countLastWeek={4}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Cup}
|
icon={Cup}
|
||||||
title={'Контесты'}
|
title={'Контесты'}
|
||||||
count={profileData?.creation.contests.total}
|
count={2}
|
||||||
countLastWeek={profileData?.creation.contests.last7Days}
|
countLastWeek={0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{username == myname && (
|
|
||||||
<ReverseButton
|
<ReverseButton
|
||||||
className="absolute bottom-[20px] right-[20px]"
|
className="absolute bottom-[20px] right-[20px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -135,7 +111,6 @@ const RightPanel = () => {
|
|||||||
text="Выход"
|
text="Выход"
|
||||||
color="error"
|
color="error"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,25 +3,16 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
|||||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { ChevroneDown, Edit } from '../../../../assets/icons/groups';
|
import { ChevroneDown, Edit } from '../../../../assets/icons/groups';
|
||||||
|
import { fetchMyArticles } from '../../../../redux/slices/articles';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
export interface ArticleItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatDate = (isoDate?: string): string => {
|
const ArticleItem: FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||||
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();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,8 +35,18 @@ const ArticleItem: FC<ArticleItemProps> = ({ id, name, createdAt }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[18px] flex text-liquid-light gap-[10px] mt-[20px]">
|
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
||||||
{`Опубликована ${formatDate(createdAt)}`}
|
{tags.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
||||||
|
v === 'Sertificated' && 'text-liquid-green',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
@@ -71,12 +72,20 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [active, setActive] = useState<boolean>(true);
|
const [active, setActive] = useState<boolean>(true);
|
||||||
|
|
||||||
const { data: articleData } = useAppSelector(
|
// ✅ Берём только "мои статьи"
|
||||||
(state) => state.profile.articles,
|
const articles = useAppSelector(
|
||||||
|
(state) => state.articles.fetchMyArticles.articles,
|
||||||
|
);
|
||||||
|
const status = useAppSelector(
|
||||||
|
(state) => state.articles.fetchMyArticles.status,
|
||||||
|
);
|
||||||
|
const error = useAppSelector(
|
||||||
|
(state) => state.articles.fetchMyArticles.error,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveProfilePage('articles'));
|
dispatch(setMenuActiveProfilePage('articles'));
|
||||||
|
dispatch(fetchMyArticles());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,21 +130,19 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'failed' && (
|
{status === 'failed' && (
|
||||||
<div className="text-liquid-red">Ошибка: </div>
|
<div className="text-liquid-red">
|
||||||
|
Ошибка:{' '}
|
||||||
|
{error || 'Не удалось загрузить статьи'}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'successful' &&
|
{status === 'successful' &&
|
||||||
articleData?.articles.items.length === 0 && (
|
articles.length === 0 && (
|
||||||
<div className="text-liquid-light">
|
<div className="text-liquid-light">
|
||||||
У вас пока нет статей
|
У вас пока нет статей
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{articleData?.articles.items.map((v, i) => (
|
{articles.map((v) => (
|
||||||
<ArticleItem
|
<ArticleItem key={v.id} {...v} />
|
||||||
key={i}
|
|
||||||
id={v.articleId}
|
|
||||||
name={v.title}
|
|
||||||
createdAt={v.createdAt}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||||
|
import {
|
||||||
|
fetchMyContests,
|
||||||
|
fetchRegisteredContests,
|
||||||
|
} from '../../../../redux/slices/contests';
|
||||||
import ContestsBlock from './ContestsBlock';
|
import ContestsBlock from './ContestsBlock';
|
||||||
|
|
||||||
const Contests = () => {
|
const Contests = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { data: constestData } = useAppSelector(
|
// Redux-состояния
|
||||||
(state) => state.profile.contests,
|
const myContestsState = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyContests,
|
||||||
);
|
);
|
||||||
|
|
||||||
// При загрузке страницы — выставляем вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveProfilePage('contests'));
|
dispatch(setMenuActiveProfilePage('contests'));
|
||||||
|
dispatch(fetchMyContests());
|
||||||
|
dispatch(fetchRegisteredContests({}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,38 +29,30 @@ const Contests = () => {
|
|||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Предстоящие контесты"
|
title="Предстоящие контесты"
|
||||||
type="upcoming"
|
type="reg"
|
||||||
contests={constestData?.upcoming.items
|
// contests={regContestsState.contests}
|
||||||
.filter((v) => v.role != 'Organizer')
|
contests={[]}
|
||||||
.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>
|
||||||
|
|
||||||
{/* Контесты, которые я создал */}
|
{/* Контесты, которые я создал */}
|
||||||
<div>
|
<div>
|
||||||
|
{myContestsState.status === 'loading' ? (
|
||||||
|
<div className="text-liquid-white p-4 text-[24px]">
|
||||||
|
Загрузка ваших контестов...
|
||||||
|
</div>
|
||||||
|
) : myContestsState.error ? (
|
||||||
|
<div className="text-red-500 p-4 text-[24px]">
|
||||||
|
Ошибка: {myContestsState.error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Созданные контесты"
|
title="Мои контесты"
|
||||||
type="edit"
|
type="my"
|
||||||
contests={constestData?.mine.items}
|
contests={myContestsState.contests}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { useState, FC } from 'react';
|
import { useState, FC } from 'react';
|
||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||||
import { ContestItem } from '../../../../redux/slices/profile';
|
import MyContestItem from './MyContestItem';
|
||||||
import PastContestItem from './PastContestItem';
|
import RegisterContestItem from './RegisterContestItem';
|
||||||
import UpcoingContestItem from './UpcomingContestItem';
|
import { Contest } from '../../../../redux/slices/contests';
|
||||||
import EditContestItem from './EditContestItem';
|
|
||||||
|
|
||||||
interface ContestsBlockProps {
|
interface ContestsBlockProps {
|
||||||
contests?: ContestItem[];
|
contests: Contest[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
type?: 'edit' | 'upcoming' | 'past';
|
type?: 'my' | 'reg';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
contests,
|
contests,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
type = 'edit',
|
type = 'my',
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
@@ -37,11 +36,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
setActive(!active);
|
setActive(!active);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className=" select-none">{title}</span>
|
<span>{title}</span>
|
||||||
<img
|
<img
|
||||||
src={ChevroneDown}
|
src={ChevroneDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300 select-none',
|
'transition-all duration-300',
|
||||||
active && 'rotate-180',
|
active && 'rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -54,38 +53,35 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-[10px] pt-[20px]">
|
<div className="pb-[10px] pt-[20px]">
|
||||||
{contests?.map((v, i) => {
|
{contests.map((v, i) => {
|
||||||
if (type == 'past') {
|
return type == 'my' ? (
|
||||||
return (
|
<MyContestItem
|
||||||
<PastContestItem
|
|
||||||
key={i}
|
key={i}
|
||||||
{...v}
|
id={v.id}
|
||||||
|
name={v.name}
|
||||||
|
startAt={v.startsAt ?? ''}
|
||||||
|
duration={
|
||||||
|
new Date(v.endsAt ?? '').getTime() -
|
||||||
|
new Date(v.startsAt ?? '').getTime()
|
||||||
|
}
|
||||||
|
members={(v.members??[]).length}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RegisterContestItem
|
||||||
|
key={i}
|
||||||
|
id={v.id}
|
||||||
|
name={v.name}
|
||||||
|
startAt={v.startsAt ?? ''}
|
||||||
|
statusRegister={'reg'}
|
||||||
|
duration={
|
||||||
|
new Date(v.endsAt ?? '').getTime() -
|
||||||
|
new Date(v.startsAt ?? '').getTime()
|
||||||
|
}
|
||||||
|
members={(v.members??[]).length}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
98
src/views/home/account/contests/MyContestItem.tsx
Normal file
98
src/views/home/account/contests/MyContestItem.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { cn } from '../../../../lib/cn';
|
||||||
|
import { Account } from '../../../../assets/icons/auth';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Edit } from '../../../../assets/icons/input';
|
||||||
|
|
||||||
|
export interface ContestItemProps {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
startAt: string;
|
||||||
|
duration: number;
|
||||||
|
members: 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 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,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,1fr,110px,110px,110px,24px] items-center font-bold',
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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=${id}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContestItem;
|
||||||
@@ -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;
|
|
||||||
114
src/views/home/account/contests/RegisterContestItem.tsx
Normal file
114
src/views/home/account/contests/RegisterContestItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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={() => {}} text="Регистрация" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<ReverseButton onClick={() => {}} text="Вы записаны" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContestItem;
|
||||||
@@ -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;
|
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import MissionsBlock from './MissionsBlock';
|
import MissionsBlock from './MissionsBlock';
|
||||||
import {
|
import {
|
||||||
deleteMission,
|
fetchMyMissions,
|
||||||
setMissionsStatus,
|
setMissionsStatus,
|
||||||
} from '../../../../redux/slices/missions';
|
} from '../../../../redux/slices/missions';
|
||||||
import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
|
||||||
import { fetchProfileMissions } from '../../../../redux/slices/profile';
|
|
||||||
import { useQuery } from '../../../../hooks/useQuery';
|
|
||||||
|
|
||||||
interface ItemProps {
|
interface ItemProps {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -44,20 +41,12 @@ const Item: FC<ItemProps> = ({
|
|||||||
|
|
||||||
const Missions = () => {
|
const Missions = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const missions = useAppSelector((state) => state.missions.missions);
|
||||||
const [modalDeleteTask, setModalDeleteTask] = useState<boolean>(false);
|
const status = useAppSelector((state) => state.missions.statuses.fetchMy);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveProfilePage('missions'));
|
dispatch(setMenuActiveProfilePage('missions'));
|
||||||
|
dispatch(fetchMyMissions());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,67 +62,46 @@ const Missions = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-between items-start">
|
<div className="flex flex-row justify-between items-start">
|
||||||
<div className="flex gap-[10px]">
|
<div className="flex gap-[10px]">
|
||||||
<Item
|
<Item count={14} totalCount={123} title="Задачи" />
|
||||||
count={missionData?.summary?.total?.solved ?? 0}
|
|
||||||
totalCount={
|
|
||||||
missionData?.summary?.total?.total ?? 0
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
missionData?.summary?.total?.label ??
|
|
||||||
'Задачи'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-[20px]">
|
<div className="flex gap-[20px]">
|
||||||
{missionData?.summary?.buckets?.map((bucket) => (
|
|
||||||
<Item
|
<Item
|
||||||
key={bucket.key}
|
count={14}
|
||||||
count={bucket.solved}
|
totalCount={123}
|
||||||
totalCount={bucket.total}
|
title="Easy"
|
||||||
title={bucket.label}
|
color="green"
|
||||||
color={
|
/>
|
||||||
bucket.key === 'easy'
|
<Item
|
||||||
? 'green'
|
count={14}
|
||||||
: bucket.key === 'medium'
|
totalCount={123}
|
||||||
? 'orange'
|
title="Medium"
|
||||||
: 'red'
|
color="orange"
|
||||||
}
|
/>
|
||||||
|
<Item
|
||||||
|
count={14}
|
||||||
|
totalCount={123}
|
||||||
|
title="Hard"
|
||||||
|
color="red"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[24px] font-bold text-liquid-white">
|
||||||
|
Компетенции
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-[10px]">
|
||||||
|
<Item count={14} totalCount={123} title="Массивы" />
|
||||||
|
<Item count={14} totalCount={123} title="Списки" />
|
||||||
|
<Item count={14} totalCount={123} title="Стэк" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-[20px]">
|
<div className="p-[20px]">
|
||||||
<MissionsBlock
|
<MissionsBlock
|
||||||
missions={missionData?.authored.items ?? []}
|
missions={missions ?? []}
|
||||||
title="Мои миссии"
|
title="Мои миссии"
|
||||||
setTastDeleteId={setTaskDeleteId}
|
|
||||||
setDeleteModalActive={setModalDeleteTask}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,23 +2,18 @@ import { useState, FC } from 'react';
|
|||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||||
import MyMissionItem from './MyMissionItem';
|
import MyMissionItem from './MyMissionItem';
|
||||||
import { MissionItem } from '../../../../redux/slices/profile';
|
import { Mission } from '../../../../redux/slices/missions';
|
||||||
|
|
||||||
interface MissionsBlockProps {
|
interface MissionsBlockProps {
|
||||||
missions: MissionItem[];
|
missions: Mission[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
setTastDeleteId: (v: number) => void;
|
|
||||||
setDeleteModalActive: (v: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MissionsBlock: FC<MissionsBlockProps> = ({
|
const MissionsBlock: FC<MissionsBlockProps> = ({
|
||||||
missions,
|
missions,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
|
|
||||||
setTastDeleteId,
|
|
||||||
setDeleteModalActive,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(true);
|
const [active, setActive] = useState<boolean>(true);
|
||||||
|
|
||||||
@@ -58,14 +53,12 @@ const MissionsBlock: FC<MissionsBlockProps> = ({
|
|||||||
{missions.map((v, i) => (
|
{missions.map((v, i) => (
|
||||||
<MyMissionItem
|
<MyMissionItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.missionId}
|
id={v.id}
|
||||||
name={v.missionName}
|
name={v.name}
|
||||||
timeLimit={v.timeLimitMilliseconds}
|
timeLimit={v.timeLimit}
|
||||||
memoryLimit={v.memoryLimitBytes}
|
memoryLimit={v.memoryLimit}
|
||||||
difficulty={v.difficultyValue}
|
difficulty={v.difficulty}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
setTastDeleteId={setTastDeleteId}
|
|
||||||
setDeleteModalActive={setDeleteModalActive}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Trash } from '../../../../assets/icons/input';
|
import { Edit } from '../../../../assets/icons/input';
|
||||||
import { useAppSelector } from '../../../../redux/hooks';
|
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,8 +14,6 @@ export interface MissionItemProps {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
type?: 'first' | 'second';
|
type?: 'first' | 'second';
|
||||||
status?: 'empty' | 'success' | 'error';
|
status?: 'empty' | 'success' | 'error';
|
||||||
setTastDeleteId: (v: number) => void;
|
|
||||||
setDeleteModalActive: (v: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMilliseconds(ms: number): string {
|
export function formatMilliseconds(ms: number): string {
|
||||||
@@ -38,19 +35,11 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
memoryLimit = 256 * 1024 * 1024,
|
memoryLimit = 256 * 1024 * 1024,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
setTastDeleteId,
|
|
||||||
setDeleteModalActive,
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const calcDifficulty = (d: number) => {
|
const difficultyItems = ['Easy', 'Medium', 'Hard'];
|
||||||
if (d <= 1200) return 'Easy';
|
const difficultyString =
|
||||||
if (d <= 2000) return 'Medium';
|
difficultyItems[Math.min(Math.max(0, difficulty - 1), 2)];
|
||||||
return 'Hard';
|
|
||||||
};
|
|
||||||
const difficultyString = calcDifficulty(difficulty);
|
|
||||||
const deleteStatus = useAppSelector(
|
|
||||||
(state) => state.missions.statuses.delete,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -86,18 +75,10 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-[24px] w-[24px]">
|
<div className="h-[24px] w-[24px]">
|
||||||
<img
|
<img
|
||||||
src={Trash}
|
src={Edit}
|
||||||
className={cn(
|
className="hover:bg-liquid-light rounded-[8px] transition-all duration-300"
|
||||||
'hover:bg-liquid-light rounded-[8px] transition-all duration-300',
|
|
||||||
deleteStatus == 'loading' &&
|
|
||||||
'cursor-default pointer-events-none hover:bg-transparent opacity-35',
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (deleteStatus != 'loading') {
|
|
||||||
setTastDeleteId(id);
|
|
||||||
setDeleteModalActive(true);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppSelector } from '../../../redux/hooks';
|
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
export interface ArticleItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,65 +9,6 @@ export interface ArticleItemProps {
|
|||||||
|
|
||||||
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const filterTags = useAppSelector(
|
|
||||||
(state) => state.store.articles.articleTagFilter,
|
|
||||||
);
|
|
||||||
const nameFilter = useAppSelector(
|
|
||||||
(state) => state.store.articles.filterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlightZ = (name: string, filter: string) => {
|
|
||||||
if (!filter) return name;
|
|
||||||
|
|
||||||
const s = filter.toLowerCase();
|
|
||||||
const t = name.toLowerCase();
|
|
||||||
const n = t.length;
|
|
||||||
const m = s.length;
|
|
||||||
|
|
||||||
const mark = Array(n).fill(false);
|
|
||||||
|
|
||||||
// Проходимся с конца и ставим отметки
|
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
|
||||||
if (i + m <= n && t.slice(i, i + m) === s) {
|
|
||||||
for (let j = i; j < i + m; j++) {
|
|
||||||
if (mark[j]) break;
|
|
||||||
mark[j] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Формируем единые жёлтые блоки ===
|
|
||||||
const result: any[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < n) {
|
|
||||||
if (!mark[i]) {
|
|
||||||
// обычный символ
|
|
||||||
result.push(name[i]);
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
// начинаем жёлтый блок
|
|
||||||
let j = i;
|
|
||||||
while (j < n && mark[j]) j++;
|
|
||||||
|
|
||||||
const chunk = name.slice(i, j);
|
|
||||||
result.push(
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="bg-yellow-400 text-black rounded px-1"
|
|
||||||
>
|
|
||||||
{chunk}
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -86,7 +26,7 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
#{id}
|
#{id}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
||||||
{highlightZ(name, nameFilter)}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
||||||
@@ -96,8 +36,6 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
||||||
v == 'Sertificated' && 'text-liquid-green',
|
v == 'Sertificated' && 'text-liquid-green',
|
||||||
filterTags.includes(v) &&
|
|
||||||
'border-liquid-brightmain border-[1px] border-solid text-liquid-brightmain',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{v}
|
{v}
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ArticleItem from './ArticleItem';
|
import ArticleItem from './ArticleItem';
|
||||||
import {
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
setArticlesNameFilter,
|
|
||||||
setArticlesTagFilter,
|
|
||||||
setMenuActivePage,
|
|
||||||
} from '../../../redux/slices/store';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { fetchArticles } from '../../../redux/slices/articles';
|
import { fetchArticles } from '../../../redux/slices/articles';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
@@ -19,22 +15,39 @@ const Articles = () => {
|
|||||||
const articles = useAppSelector(
|
const articles = useAppSelector(
|
||||||
(state) => state.articles.fetchArticles.articles,
|
(state) => state.articles.fetchArticles.articles,
|
||||||
);
|
);
|
||||||
const tagsFilter = useAppSelector(
|
const status = useAppSelector(
|
||||||
(state) => state.store.articles.articleTagFilter,
|
(state) => state.articles.fetchArticles.status,
|
||||||
);
|
|
||||||
const nameFilter = useAppSelector(
|
|
||||||
(state) => state.store.articles.filterName,
|
|
||||||
);
|
);
|
||||||
|
const error = useAppSelector((state) => state.articles.fetchArticles.error);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('articles'));
|
dispatch(setMenuActivePage('articles'));
|
||||||
dispatch(fetchArticles({ tags: tagsFilter }));
|
dispatch(fetchArticles({}));
|
||||||
}, []);
|
}, [dispatch]);
|
||||||
|
|
||||||
const filterTagsHandler = (value: string[]) => {
|
// ========================
|
||||||
dispatch(setArticlesTagFilter(value));
|
// Состояния загрузки / ошибки
|
||||||
dispatch(fetchArticles({ tags: value }));
|
// ========================
|
||||||
};
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex items-center justify-center text-liquid-light text-[18px]">
|
||||||
|
Загрузка статей...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col items-center justify-center text-liquid-red text-[18px]">
|
||||||
|
Ошибка при загрузке статей
|
||||||
|
{error && (
|
||||||
|
<div className="text-liquid-light text-[14px] mt-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Основной контент
|
// Основной контент
|
||||||
@@ -55,14 +68,7 @@ const Articles = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Фильтры */}
|
{/* Фильтры */}
|
||||||
<Filters
|
<Filters />
|
||||||
onChangeTags={(value: string[]) => {
|
|
||||||
filterTagsHandler(value);
|
|
||||||
}}
|
|
||||||
onChangeName={(value: string) => {
|
|
||||||
dispatch(setArticlesNameFilter(value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Список статей */}
|
{/* Список статей */}
|
||||||
<div className="mt-[20px]">
|
<div className="mt-[20px]">
|
||||||
@@ -71,15 +77,14 @@ const Articles = () => {
|
|||||||
Пока нет статей
|
Пока нет статей
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
articles
|
articles.map((v) => <ArticleItem key={v.id} {...v} />)
|
||||||
.filter((v) =>
|
|
||||||
v.name
|
|
||||||
.toLocaleLowerCase()
|
|
||||||
.includes(nameFilter.toLocaleLowerCase()),
|
|
||||||
)
|
|
||||||
.map((v) => <ArticleItem key={v.id} {...v} />)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Пагинация (пока заглушка) */}
|
||||||
|
<div className="mt-[20px] text-liquid-light text-[14px]">
|
||||||
|
pages
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
import { FC } from 'react';
|
import {
|
||||||
import { TagFilter } from '../../../components/filters/TagFilter';
|
FilterDropDown,
|
||||||
|
FilterItem,
|
||||||
|
} from '../../../components/drop-down-list/Filter';
|
||||||
|
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
interface ArticleFiltersProps {
|
const Filters = () => {
|
||||||
onChangeTags: (value: string[]) => void;
|
const items: FilterItem[] = [
|
||||||
onChangeName: (value: string) => void;
|
{ text: 'React', value: 'react' },
|
||||||
}
|
{ text: 'Vue', value: 'vue' },
|
||||||
|
{ text: 'Angular', value: 'angular' },
|
||||||
|
{ text: 'Svelte', value: 'svelte' },
|
||||||
|
{ text: 'Next.js', value: 'next' },
|
||||||
|
{ text: 'Nuxt', value: 'nuxt' },
|
||||||
|
{ text: 'Solid', value: 'solid' },
|
||||||
|
{ text: 'Qwik', value: 'qwik' },
|
||||||
|
];
|
||||||
|
|
||||||
const Filters: FC<ArticleFiltersProps> = ({ onChangeTags, onChangeName }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput
|
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
||||||
onChange={(value: string) => {
|
|
||||||
onChangeName(value);
|
<SorterDropDown
|
||||||
}}
|
items={[
|
||||||
placeholder="Поиск статьи"
|
{
|
||||||
|
value: '1',
|
||||||
|
text: 'Сложность',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '2',
|
||||||
|
text: 'Дата создания',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '3',
|
||||||
|
text: 'ID',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(v) => {}}
|
||||||
/>
|
/>
|
||||||
<TagFilter
|
|
||||||
onChange={(value: string[]) => {
|
<FilterDropDown
|
||||||
onChangeTags(value);
|
items={items}
|
||||||
}}
|
defaultState={[]}
|
||||||
|
onChange={(values) => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { loginUser } from '../../../redux/slices/auth';
|
|||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Balloon } from '../../../assets/icons/auth';
|
import { Balloon } from '../../../assets/icons/auth';
|
||||||
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
|
import { googleLogo } from '../../../assets/icons/input';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -25,6 +27,7 @@ const Login = () => {
|
|||||||
// После успешного логина
|
// После успешного логина
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('account'));
|
dispatch(setMenuActivePage('account'));
|
||||||
|
submitClicked;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,21 +47,6 @@ const Login = () => {
|
|||||||
dispatch(loginUser({ username, password }));
|
dispatch(loginUser({ username, password }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getErrorLoginMessage = (): string => {
|
|
||||||
if (!submitClicked) return '';
|
|
||||||
if (username == '') return 'Поле не может быть пустым';
|
|
||||||
if (password == '') return '';
|
|
||||||
if (status === 'failed')
|
|
||||||
return 'Неверное имя пользователя и/или пароль';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorPasswordMessage = (): string => {
|
|
||||||
if (!submitClicked) return '';
|
|
||||||
if (password == '') return 'Поле не может быть пустым';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
||||||
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
||||||
@@ -67,7 +55,7 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className=" relative pointer-events-auto">
|
<div className=" relative pointer-events-auto">
|
||||||
<div>
|
<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>
|
||||||
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
||||||
@@ -85,7 +73,6 @@ const Login = () => {
|
|||||||
setUsername(v);
|
setUsername(v);
|
||||||
}}
|
}}
|
||||||
placeholder="login"
|
placeholder="login"
|
||||||
error={getErrorLoginMessage()}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="password"
|
name="password"
|
||||||
@@ -97,11 +84,17 @@ const Login = () => {
|
|||||||
setPassword(v);
|
setPassword(v);
|
||||||
}}
|
}}
|
||||||
placeholder="abCD1234"
|
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>
|
||||||
|
|
||||||
<div className="mt-[10px]">
|
<div className="mt-[10px]">
|
||||||
@@ -111,6 +104,15 @@ const Login = () => {
|
|||||||
text={status === 'loading' ? 'Вход...' : 'Вход'}
|
text={status === 'loading' ? 'Вход...' : 'Вход'}
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
/>
|
/>
|
||||||
|
<SecondaryButton className="w-full" onClick={() => {}}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={googleLogo}
|
||||||
|
className="h-[24px] w-[24px] mr-[15px]"
|
||||||
|
/>
|
||||||
|
Вход с Google
|
||||||
|
</div>
|
||||||
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mt-[10px]">
|
<div className="flex justify-center mt-[10px]">
|
||||||
|
|||||||
@@ -9,38 +9,9 @@ import { registerUser } from '../../../redux/slices/auth';
|
|||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Balloon } from '../../../assets/icons/auth';
|
import { Balloon } from '../../../assets/icons/auth';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
// import { Checkbox } from '../../../components/checkbox/Checkbox';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
|
import { Checkbox } from '../../../components/checkbox/Checkbox';
|
||||||
function isValidEmail(email: string): boolean {
|
import { googleLogo } from '../../../assets/icons/input';
|
||||||
const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
||||||
return pattern.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidLogin(login: string): boolean {
|
|
||||||
return login.length >= 4 && login.length <= 128;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidatePassword(password: string): string {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (password.length < 8 || password.length > 255) {
|
|
||||||
errors.push('Пароль должен содержать от 8 до 255 символов');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/[A-Z]/.test(password)) {
|
|
||||||
errors.push('Пароль должен содержать хотя бы одну заглавную букву');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/[a-z]/.test(password)) {
|
|
||||||
errors.push('Пароль должен содержать хотя бы одну строчную букву');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/[0-9]/.test(password)) {
|
|
||||||
errors.push('Пароль должен содержать хотя бы одну цифру');
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -52,13 +23,12 @@ const Register = () => {
|
|||||||
const [password, setPassword] = useState<string>('');
|
const [password, setPassword] = useState<string>('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||||
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
||||||
const [politicChecked, setPoliticChecked] = useState<boolean>(true);
|
|
||||||
|
|
||||||
const { status, jwt } = useAppSelector((state) => state.auth);
|
const { status, jwt } = useAppSelector((state) => state.auth);
|
||||||
// const { errors } = useAppSelector((state) => state.auth.register);
|
|
||||||
|
// После успешной регистрации — переход в систему
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPoliticChecked(true);
|
|
||||||
dispatch(setMenuActivePage('account'));
|
dispatch(setMenuActivePage('account'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -68,73 +38,18 @@ const Register = () => {
|
|||||||
const path = from ? from.pathname + from.search : '/home/account';
|
const path = from ? from.pathname + from.search : '/home/account';
|
||||||
navigate(path, { replace: true });
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
|
submitClicked;
|
||||||
}, [jwt]);
|
}, [jwt]);
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
setSubmitClicked(true);
|
setSubmitClicked(true);
|
||||||
|
|
||||||
if (!politicChecked) return;
|
|
||||||
if (!username || !email || !password || !confirmPassword) return;
|
if (!username || !email || !password || !confirmPassword) return;
|
||||||
if (password !== confirmPassword) return;
|
if (password !== confirmPassword) return;
|
||||||
if (
|
|
||||||
!isValidEmail(email) ||
|
|
||||||
!isValidLogin(username) ||
|
|
||||||
isValidatePassword(password) != ''
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
dispatch(registerUser({ username, email, password }));
|
dispatch(registerUser({ username, email, password }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getErrorEmailMessage = (): string => {
|
|
||||||
if (!submitClicked) return '';
|
|
||||||
if (email == '') return 'Поле не может быть пустым';
|
|
||||||
if (!isValidEmail(email)) return 'Почта не валидна';
|
|
||||||
if (!username || !email || !password || !confirmPassword) return '';
|
|
||||||
if (password !== confirmPassword) return '';
|
|
||||||
// if (errors?.Email) {
|
|
||||||
// return errors.Email.join('\n');
|
|
||||||
// }
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorLoginMessage = (): string => {
|
|
||||||
if (!submitClicked) return '';
|
|
||||||
if (username == '') return 'Поле не может быть пустым';
|
|
||||||
if (!isValidLogin(username))
|
|
||||||
return 'Логин дложен быть длиной от 4 до 128 символов';
|
|
||||||
if (!username || !email || !password || !confirmPassword) return '';
|
|
||||||
if (password !== confirmPassword) return '';
|
|
||||||
// if (errors?.Username) {
|
|
||||||
// return errors.Username.join('\n');
|
|
||||||
// }
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorPasswordMessage = (): string => {
|
|
||||||
if (!submitClicked) return '';
|
|
||||||
if (password == '') return 'Поле не может быть пустым';
|
|
||||||
const val = isValidatePassword(password);
|
|
||||||
if (val != '') return val;
|
|
||||||
if (confirmPassword != password) return 'Пароли не совпадают';
|
|
||||||
if (!username || !email || !password || !confirmPassword) return '';
|
|
||||||
// if (errors?.Password) {
|
|
||||||
// return errors.Password.join('\n');
|
|
||||||
// }
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorConfirmPasswordMessage = (): string => {
|
|
||||||
if (!submitClicked) return '';
|
|
||||||
if (confirmPassword == '') return 'Поле не может быть пустым';
|
|
||||||
const val = isValidatePassword(confirmPassword);
|
|
||||||
if (val != '') return val;
|
|
||||||
if (confirmPassword != password) return 'Пароли не совпадают';
|
|
||||||
if (!username || !email || !password || !confirmPassword) return '';
|
|
||||||
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
||||||
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
||||||
@@ -143,7 +58,7 @@ const Register = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className=" relative pointer-events-auto">
|
<div className=" relative pointer-events-auto">
|
||||||
<div>
|
<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>
|
||||||
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
||||||
@@ -161,7 +76,6 @@ const Register = () => {
|
|||||||
setEmail(v);
|
setEmail(v);
|
||||||
}}
|
}}
|
||||||
placeholder="example@gmail.com"
|
placeholder="example@gmail.com"
|
||||||
error={getErrorEmailMessage()}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="login"
|
name="login"
|
||||||
@@ -173,7 +87,6 @@ const Register = () => {
|
|||||||
setUsername(v);
|
setUsername(v);
|
||||||
}}
|
}}
|
||||||
placeholder="login"
|
placeholder="login"
|
||||||
error={getErrorLoginMessage()}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="password"
|
name="password"
|
||||||
@@ -185,7 +98,6 @@ const Register = () => {
|
|||||||
setPassword(v);
|
setPassword(v);
|
||||||
}}
|
}}
|
||||||
placeholder="abCD1234"
|
placeholder="abCD1234"
|
||||||
error={getErrorPasswordMessage()}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="confirm-password"
|
name="confirm-password"
|
||||||
@@ -197,21 +109,16 @@ const Register = () => {
|
|||||||
setConfirmPassword(v);
|
setConfirmPassword(v);
|
||||||
}}
|
}}
|
||||||
placeholder="abCD1234"
|
placeholder="abCD1234"
|
||||||
error={getErrorConfirmPasswordMessage()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className=" flex items-center mt-[10px] h-[24px]">
|
<div className=" flex items-center mt-[10px] h-[24px]">
|
||||||
{/* <Checkbox
|
<Checkbox
|
||||||
onChange={(value: boolean) => {
|
onChange={(value: boolean) => {
|
||||||
setPoliticChecked(value);
|
value;
|
||||||
}}
|
}}
|
||||||
className="p-0 w-fit m-[2.75px]"
|
className="p-0 w-fit m-[2.75px]"
|
||||||
size="md"
|
size="md"
|
||||||
color={
|
color="secondary"
|
||||||
politicChecked || !submitClicked
|
|
||||||
? 'secondary'
|
|
||||||
: 'danger'
|
|
||||||
}
|
|
||||||
variant="default"
|
variant="default"
|
||||||
/>
|
/>
|
||||||
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
|
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
|
||||||
@@ -219,10 +126,7 @@ const Register = () => {
|
|||||||
<Link to={'/home'} className={' underline'}>
|
<Link to={'/home'} className={' underline'}>
|
||||||
политику конфиденциальности
|
политику конфиденциальности
|
||||||
</Link>
|
</Link>
|
||||||
<span className={' underline cursor-pointer'}>
|
|
||||||
политику конфиденциальности
|
|
||||||
</span>
|
</span>
|
||||||
</span> */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[10px]">
|
<div className="mt-[10px]">
|
||||||
@@ -236,6 +140,15 @@ const Register = () => {
|
|||||||
}
|
}
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
/>
|
/>
|
||||||
|
<SecondaryButton className="w-full" onClick={() => {}}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={googleLogo}
|
||||||
|
className="h-[24px] w-[24px] mr-[15px]"
|
||||||
|
/>
|
||||||
|
Регистрация с Google
|
||||||
|
</div>
|
||||||
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mt-[10px]">
|
<div className="flex justify-center mt-[10px]">
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||||
import {
|
import { fetchContestById } from '../../../redux/slices/contests';
|
||||||
fetchContestById,
|
|
||||||
fetchMyAttemptsInContest,
|
|
||||||
} from '../../../redux/slices/contests';
|
|
||||||
import ContestMissions from './Missions';
|
import ContestMissions from './Missions';
|
||||||
import Submissions from './Submissions';
|
import Submissions from './Submissions';
|
||||||
|
|
||||||
@@ -33,7 +30,6 @@ const Contest = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchContestById(contestIdNumber));
|
dispatch(fetchContestById(contestIdNumber));
|
||||||
dispatch(fetchMyAttemptsInContest(contestIdNumber));
|
|
||||||
}, [contestIdNumber]);
|
}, [contestIdNumber]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { cn } from '../../../lib/cn';
|
|||||||
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { toastWarning } from '../../../lib/toastNotification';
|
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
contestId: number;
|
contestId: number;
|
||||||
@@ -12,7 +11,6 @@ export interface MissionItemProps {
|
|||||||
memoryLimit?: number;
|
memoryLimit?: number;
|
||||||
type?: 'first' | 'second';
|
type?: 'first' | 'second';
|
||||||
status?: 'success' | 'error';
|
status?: 'success' | 'error';
|
||||||
attemptsStarted?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMilliseconds(ms: number): string {
|
export function formatMilliseconds(ms: number): string {
|
||||||
@@ -34,7 +32,6 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
memoryLimit = 256 * 1024 * 1024,
|
memoryLimit = 256 * 1024 * 1024,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
attemptsStarted,
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -53,12 +50,7 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (attemptsStarted){
|
|
||||||
navigate(`/mission/${id}?back=${path}&contestId=${contestId}`);
|
navigate(`/mission/${id}?back=${path}&contestId=${contestId}`);
|
||||||
}
|
|
||||||
else{
|
|
||||||
toastWarning("Нужно начать попытку")
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[18px] font-bold">#{id}</div>
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect } from "react";
|
||||||
import MissionItem from './MissionItem';
|
import MissionItem from "./MissionItem";
|
||||||
import {
|
import {
|
||||||
Contest,
|
Contest,
|
||||||
fetchMyAttemptsInContest,
|
|
||||||
fetchMySubmissions,
|
fetchMySubmissions,
|
||||||
setContestStatus,
|
setContestStatus,
|
||||||
startContestAttempt,
|
} from "../../../redux/slices/contests";
|
||||||
} from '../../../redux/slices/contests';
|
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { arrowLeft } from "../../../assets/icons/header";
|
||||||
import { arrowLeft } from '../../../assets/icons/header';
|
|
||||||
import { useQuery } from '../../../hooks/useQuery';
|
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,65 +23,17 @@ interface ContestMissionsProps {
|
|||||||
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { submissions, status } = useAppSelector(
|
||||||
const query = useQuery();
|
(state) => state.contests.fetchMySubmissions
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (contest) dispatch(fetchMySubmissions(contest.id));
|
if (contest) dispatch(fetchMySubmissions(contest.id));
|
||||||
}, [contest]);
|
}, [contest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status == 'successful') {
|
if (status == "successful") {
|
||||||
dispatch(
|
dispatch(setContestStatus({ key: "fetchMySubmissions", status: "idle" }));
|
||||||
setContestStatus({ key: 'fetchMySubmissions', status: 'idle' }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
@@ -93,19 +42,15 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const solvedCount = (contest.missions ?? []).filter((mission) =>
|
const solvedCount = (contest.missions ?? []).filter((mission) =>
|
||||||
submissions?.some(
|
submissions.some(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.solution.missionId === mission.id &&
|
s.solution.missionId === mission.id &&
|
||||||
s.solution.status === 'Accepted: All tests passed',
|
s.solution.status === "Accepted: All tests passed"
|
||||||
),
|
)
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const totalCount = contest.missions?.length ?? 0;
|
const totalCount = contest.missions?.length ?? 0;
|
||||||
|
|
||||||
// форматирование: mm:ss
|
|
||||||
const minutes = String(Math.floor(time / 60)).padStart(2, '0');
|
|
||||||
const seconds = String(time % 60).padStart(2, '0');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]">
|
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]">
|
||||||
<div className="">
|
<div className="">
|
||||||
@@ -118,51 +63,18 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
|||||||
src={arrowLeft}
|
src={arrowLeft}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(url);
|
navigate(`/home/contests`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-liquid-light font-bold text-[18px]">
|
<span className="text-liquid-light font-bold text-[18px]">
|
||||||
Контест #{contest.id}
|
Контест #{contest.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-liquid-light font-bold text-[18px]">
|
<div>{contest.attemptDurationMinutes ?? 0} минут</div>
|
||||||
{attemptsStarted
|
|
||||||
? `${minutes}:${seconds}`
|
|
||||||
: `Длительность попытки: ${
|
|
||||||
contest.attemptDurationMinutes ?? 0
|
|
||||||
} минут. Осталось попыток ${
|
|
||||||
(contest.maxAttempts ?? 0) -
|
|
||||||
(attempts?.length ?? 0)
|
|
||||||
}/${contest.maxAttempts ?? 0}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
|
<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
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/contest/${contest.id}/submissions`);
|
navigate(`/contest/${contest.id}/submissions`);
|
||||||
@@ -170,31 +82,28 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
|||||||
text="Мои посылки"
|
text="Мои посылки"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
|
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(contest.missions ?? []).map((v, i) => {
|
{(contest.missions ?? []).map((v, i) => {
|
||||||
const missionSubmissions = submissions?.filter(
|
const missionSubmissions = submissions.filter(
|
||||||
(s) => s.solution.missionId === v.id,
|
(s) => s.solution.missionId === v.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSuccess = missionSubmissions?.some(
|
const hasSuccess = missionSubmissions.some(
|
||||||
(s) =>
|
(s) => s.solution.status == "Accepted: All tests passed"
|
||||||
s.solution.status ==
|
|
||||||
'Accepted: All tests passed',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(missionSubmissions);
|
||||||
|
|
||||||
const status = hasSuccess
|
const status = hasSuccess
|
||||||
? 'success'
|
? "success"
|
||||||
: missionSubmissions?.length &&
|
: missionSubmissions.length > 0
|
||||||
missionSubmissions.length > 0
|
? "error"
|
||||||
? 'error'
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MissionItem
|
<MissionItem
|
||||||
attemptsStarted={attemptsStarted}
|
|
||||||
contestId={contest.id}
|
contestId={contest.id}
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
id={v.id}
|
||||||
@@ -202,7 +111,7 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
|||||||
timeLimit={v.timeLimitMilliseconds}
|
timeLimit={v.timeLimitMilliseconds}
|
||||||
memoryLimit={v.memoryLimitBytes}
|
memoryLimit={v.memoryLimitBytes}
|
||||||
status={status}
|
status={status}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? "second" : "first"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import SubmissionItem from "./SubmissionItem";
|
||||||
import { FC, useEffect } from 'react';
|
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
||||||
import { Contest, fetchMySubmissions } from '../../../redux/slices/contests';
|
import { FC, useEffect } from "react";
|
||||||
import { arrowLeft } from '../../../assets/icons/header';
|
import {
|
||||||
import { useNavigate } from 'react-router-dom';
|
Contest,
|
||||||
import SubmissionsBlock from './SubmissionsBlock';
|
fetchMySubmissions,
|
||||||
|
setContestStatus,
|
||||||
|
} from "../../../redux/slices/contests";
|
||||||
|
import { arrowLeft } from "../../../assets/icons/header";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
authorId: number;
|
authorId: number;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: 'Easy' | 'Medium' | 'Hard';
|
difficulty: "Easy" | "Medium" | "Hard";
|
||||||
tags: string[];
|
tags: string[];
|
||||||
timeLimit: number;
|
timeLimit: number;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
@@ -25,24 +29,32 @@ const Submissions: FC<SubmissionsProps> = ({ contest }) => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const attempts = useAppSelector(
|
const { submissions, status } = useAppSelector(
|
||||||
(state) => state.contests.fetchMyAttemptsInContest.attempts,
|
(state) => state.contests.fetchMySubmissions
|
||||||
);
|
|
||||||
const submissions = useAppSelector(
|
|
||||||
(state) =>
|
|
||||||
state.contests.fetchMyAttemptsInContest.attempts[0]?.submissions,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contest && contest.id) dispatch(fetchMySubmissions(contest.id));
|
if (contest && contest.id) dispatch(fetchMySubmissions(contest.id));
|
||||||
}, [contest]);
|
}, [contest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status == "successful") {
|
||||||
|
dispatch(setContestStatus({ key: "fetchMySubmissions", status: "idle" }));
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const checkStatus = (status: string) => {
|
||||||
|
if (status == "IncorrectAnswer") return "wronganswer";
|
||||||
|
if (status == "TimeLimitError") return "timelimit";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const solvedCount = (contest.missions ?? []).filter((mission) =>
|
const solvedCount = (contest.missions ?? []).filter((mission) =>
|
||||||
submissions?.some(
|
submissions.some(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.solution.missionId === mission.id &&
|
s.solution.missionId === mission.id &&
|
||||||
s.solution.status === 'Accepted: All tests passed',
|
s.solution.status === "Accepted: All tests passed"
|
||||||
),
|
)
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const totalCount = contest.missions?.length ?? 0;
|
const totalCount = contest.missions?.length ?? 0;
|
||||||
@@ -69,10 +81,46 @@ const Submissions: FC<SubmissionsProps> = ({ contest }) => {
|
|||||||
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
|
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full overflow-y-scroll medium-scrollbar pr-[20px]">
|
|
||||||
{attempts?.map((v, i) => (
|
<div>
|
||||||
<SubmissionsBlock key={i} attempt={v} />
|
<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 mt-[50px]">Вы еще ничего не отсылали</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
114
src/views/home/contests/ContestItem.tsx
Normal file
114
src/views/home/contests/ContestItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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={() => {}} text="Регистрация" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<ReverseButton onClick={() => {}} text="Вы записаны" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContestItem;
|
||||||
@@ -3,40 +3,30 @@ import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ContestsBlock from './ContestsBlock';
|
import ContestsBlock from './ContestsBlock';
|
||||||
import {
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
setContestsNameFilter,
|
import { fetchContests } from '../../../redux/slices/contests';
|
||||||
setMenuActivePage,
|
|
||||||
} from '../../../redux/slices/store';
|
|
||||||
import {
|
|
||||||
fetchContests,
|
|
||||||
fetchMyContests,
|
|
||||||
fetchParticipatingContests,
|
|
||||||
} from '../../../redux/slices/contests';
|
|
||||||
import ModalCreateContest from './ModalCreate';
|
import ModalCreateContest from './ModalCreate';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
import { toastWarning } from '../../../lib/toastNotification';
|
|
||||||
|
|
||||||
const Contests = () => {
|
const Contests = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
const [modalActive, setModalActive] = useState<boolean>(false);
|
const [modalActive, setModalActive] = useState<boolean>(false);
|
||||||
|
|
||||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
// Берём данные из Redux
|
||||||
|
const contests = useAppSelector(
|
||||||
const { contests, status } = useAppSelector(
|
(state) => state.contests.fetchContests.contests,
|
||||||
(state) => state.contests.fetchContests,
|
|
||||||
);
|
);
|
||||||
|
const status = useAppSelector(
|
||||||
const nameFilter = useAppSelector(
|
(state) => state.contests.fetchContests.status,
|
||||||
(state) => state.store.contests.filterName,
|
|
||||||
);
|
);
|
||||||
|
const error = useAppSelector((state) => state.contests.fetchContests.error);
|
||||||
|
|
||||||
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('contests'));
|
dispatch(setMenuActivePage('contests'));
|
||||||
dispatch(fetchContests({}));
|
dispatch(fetchContests({}));
|
||||||
dispatch(fetchParticipatingContests({ pageSize: 100 }));
|
|
||||||
dispatch(fetchMyContests());
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,10 +42,6 @@ const Contests = () => {
|
|||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!jwt){
|
|
||||||
toastWarning("Для создания контеста необходимо авторизоваться")
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setModalActive(true);
|
setModalActive(true);
|
||||||
}}
|
}}
|
||||||
text="Создать контест"
|
text="Создать контест"
|
||||||
@@ -63,46 +49,37 @@ const Contests = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filters
|
<Filters />
|
||||||
onChangeName={(v: string) => {
|
|
||||||
dispatch(setContestsNameFilter(v));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{status == 'loading' && (
|
{status == 'loading' && (
|
||||||
<div className="text-liquid-white p-4">
|
<div className="text-liquid-white p-4">
|
||||||
Загрузка контестов...
|
Загрузка контестов...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{status == 'failed' && (
|
||||||
|
<div className="text-red-500 p-4">Ошибка: {error}</div>
|
||||||
|
)}
|
||||||
{status == 'successful' && (
|
{status == 'successful' && (
|
||||||
<>
|
<>
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Текущие"
|
title="Текущие"
|
||||||
contests={contests
|
contests={contests.filter((contest) => {
|
||||||
.filter((v) =>
|
const endTime = new Date(
|
||||||
v.name
|
contest.endsAt ?? new Date().toDateString(),
|
||||||
.toLocaleLowerCase()
|
).getTime();
|
||||||
.includes(
|
return endTime >= now.getTime();
|
||||||
nameFilter.toLocaleLowerCase(),
|
})}
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter((c) => c.scheduleType != 'AlwaysOpen')}
|
|
||||||
type="upcoming"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Постоянные"
|
title="Прошедшие"
|
||||||
contests={contests
|
contests={contests.filter((contest) => {
|
||||||
.filter((v) =>
|
const endTime = new Date(
|
||||||
v.name
|
contest.endsAt ?? new Date().toDateString(),
|
||||||
.toLocaleLowerCase()
|
).getTime();
|
||||||
.includes(
|
return endTime < now.getTime();
|
||||||
nameFilter.toLocaleLowerCase(),
|
})}
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter((c) => c.scheduleType == 'AlwaysOpen')}
|
|
||||||
type="past"
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import { useState, FC } from 'react';
|
import { useState, FC } from 'react';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../assets/icons/groups';
|
||||||
|
import ContestItem from './ContestItem';
|
||||||
import { Contest } from '../../../redux/slices/contests';
|
import { Contest } from '../../../redux/slices/contests';
|
||||||
import PastContestItem from './PastContestItem';
|
|
||||||
import UpcoingContestItem from './UpcomingContestItem';
|
|
||||||
|
|
||||||
interface ContestsBlockProps {
|
interface ContestsBlockProps {
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
type: 'upcoming' | 'past';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
contests,
|
contests,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
type,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
@@ -36,11 +33,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
setActive(!active);
|
setActive(!active);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className=" select-none">{title}</span>
|
<span>{title}</span>
|
||||||
<img
|
<img
|
||||||
src={ChevroneDown}
|
src={ChevroneDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300 select-none',
|
'transition-all duration-300',
|
||||||
active && 'rotate-180',
|
active && 'rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -53,51 +50,21 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-[10px] pt-[20px]">
|
<div className="pb-[10px] pt-[20px]">
|
||||||
{contests.map((v, i) => {
|
{contests.map((v, i) => (
|
||||||
if (type == 'past') {
|
<ContestItem
|
||||||
return (
|
|
||||||
<PastContestItem
|
|
||||||
key={i}
|
key={i}
|
||||||
contestId={v.id}
|
id={v.id}
|
||||||
scheduleType={v.scheduleType}
|
|
||||||
name={v.name}
|
name={v.name}
|
||||||
startsAt={
|
startAt={v.startsAt ?? new Date().toString()}
|
||||||
v.startsAt ?? new Date().toString()
|
statusRegister={'reg'}
|
||||||
}
|
duration={
|
||||||
endsAt={
|
new Date(v.endsAt ?? new Date().toString()).getTime() -
|
||||||
v.endsAt ?? new Date().toString()
|
new Date(v.startsAt ?? new Date().toString()).getTime()
|
||||||
}
|
|
||||||
attemptDurationMinutes={
|
|
||||||
v.attemptDurationMinutes ?? 0
|
|
||||||
}
|
}
|
||||||
|
members={v.members?.length ?? 0}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
}
|
|
||||||
|
|
||||||
if (type == 'upcoming') {
|
|
||||||
return (
|
|
||||||
<UpcoingContestItem
|
|
||||||
key={i}
|
|
||||||
contestId={v.id}
|
|
||||||
scheduleType={v.scheduleType}
|
|
||||||
name={v.name}
|
|
||||||
startsAt={
|
|
||||||
v.startsAt ?? new Date().toString()
|
|
||||||
}
|
|
||||||
endsAt={
|
|
||||||
v.endsAt ?? new Date().toString()
|
|
||||||
}
|
|
||||||
attemptDurationMinutes={
|
|
||||||
v.attemptDurationMinutes ?? 0
|
|
||||||
}
|
|
||||||
type={i % 2 ? 'second' : 'first'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <></>;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,48 @@
|
|||||||
import { FC } from 'react';
|
import {
|
||||||
|
FilterDropDown,
|
||||||
|
FilterItem,
|
||||||
|
} from '../../../components/drop-down-list/Filter';
|
||||||
|
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
interface ContestFiltersProps {
|
const Filters = () => {
|
||||||
onChangeName: (value: string) => void;
|
const items: FilterItem[] = [
|
||||||
}
|
{ text: 'React', value: 'react' },
|
||||||
|
{ text: 'Vue', value: 'vue' },
|
||||||
|
{ text: 'Angular', value: 'angular' },
|
||||||
|
{ text: 'Svelte', value: 'svelte' },
|
||||||
|
{ text: 'Next.js', value: 'next' },
|
||||||
|
{ text: 'Nuxt', value: 'nuxt' },
|
||||||
|
{ text: 'Solid', value: 'solid' },
|
||||||
|
{ text: 'Qwik', value: 'qwik' },
|
||||||
|
];
|
||||||
|
|
||||||
const Filters: FC<ContestFiltersProps> = ({ onChangeName }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput
|
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
||||||
onChange={(value: string) => {
|
|
||||||
onChangeName(value);
|
<SorterDropDown
|
||||||
}}
|
items={[
|
||||||
placeholder="Поиск контеста"
|
{
|
||||||
|
value: '1',
|
||||||
|
text: 'Сложность',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '2',
|
||||||
|
text: 'Дата создания',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '3',
|
||||||
|
text: 'ID',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(v) => console.log(v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterDropDown
|
||||||
|
items={items}
|
||||||
|
defaultState={[]}
|
||||||
|
onChange={(values) => console.log(values)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,25 +9,8 @@ import {
|
|||||||
setContestStatus,
|
setContestStatus,
|
||||||
} from '../../../redux/slices/contests';
|
} from '../../../redux/slices/contests';
|
||||||
import { CreateContestBody } from '../../../redux/slices/contests';
|
import { CreateContestBody } from '../../../redux/slices/contests';
|
||||||
|
import DateRangeInput from '../../../components/input/DateRangeInput';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { NumberInput } from '../../../components/input/NumberInput';
|
|
||||||
import {
|
|
||||||
DropDownList,
|
|
||||||
DropDownListItem,
|
|
||||||
} from '../../../components/input/DropDownList';
|
|
||||||
import DateInput from '../../../components/input/DateInput';
|
|
||||||
import { cn } from '../../../lib/cn';
|
|
||||||
import { fetchMyGroups } from '../../../redux/slices/groups';
|
|
||||||
|
|
||||||
function toUtc(localDateTime?: string): string {
|
|
||||||
if (!localDateTime) return '';
|
|
||||||
|
|
||||||
// Создаём дату (она автоматически считается как локальная)
|
|
||||||
const date = new Date(localDateTime);
|
|
||||||
|
|
||||||
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
|
|
||||||
return date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModalCreateContestProps {
|
interface ModalCreateContestProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -44,35 +27,15 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
(state) => state.contests.createContest.status,
|
(state) => state.contests.createContest.status,
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibilityItems: DropDownListItem[] = [
|
|
||||||
{ value: 'Public', text: 'Публичный' },
|
|
||||||
{ value: 'GroupPrivate', text: 'Для группы' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const scheduleTypeItems: DropDownListItem[] = [
|
|
||||||
{ value: 'AlwaysOpen', text: 'Всегда открыт' },
|
|
||||||
{ value: 'FixedWindow', text: 'Фиксированое окно' },
|
|
||||||
{ value: 'RollingWindow', text: 'Скользящее окно' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const plus60 = new Date(now.getTime() + 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const toLocal = (d: Date) => {
|
|
||||||
const off = d.getTimezoneOffset();
|
|
||||||
const local = new Date(d.getTime() - off * 60000);
|
|
||||||
return local.toISOString().slice(0, 16);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateContestBody>({
|
const [form, setForm] = useState<CreateContestBody>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
scheduleType: 'AlwaysOpen',
|
scheduleType: 'AlwaysOpen',
|
||||||
visibility: 'Public',
|
visibility: 'Public',
|
||||||
startsAt: toLocal(now),
|
startsAt: '',
|
||||||
endsAt: toLocal(plus60),
|
endsAt: '',
|
||||||
attemptDurationMinutes: 60,
|
attemptDurationMinutes: 0,
|
||||||
maxAttempts: 1,
|
maxAttempts: 0,
|
||||||
allowEarlyFinish: false,
|
allowEarlyFinish: false,
|
||||||
missionIds: [],
|
missionIds: [],
|
||||||
articleIds: [],
|
articleIds: [],
|
||||||
@@ -81,16 +44,6 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
const contest = useAppSelector(
|
const contest = useAppSelector(
|
||||||
(state) => state.contests.createContest.contest,
|
(state) => state.contests.createContest.contest,
|
||||||
);
|
);
|
||||||
const myname = useAppSelector((state) => state.auth.username);
|
|
||||||
const myGroups = useAppSelector(
|
|
||||||
(state) => state.groups.fetchMyGroups.groups,
|
|
||||||
).filter((group) =>
|
|
||||||
group.members.some(
|
|
||||||
(member) =>
|
|
||||||
member.username === myname &&
|
|
||||||
member.role.includes('Administrator'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'successful') {
|
if (status === 'successful') {
|
||||||
@@ -103,35 +56,14 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (active) {
|
|
||||||
dispatch(fetchMyGroups());
|
|
||||||
}
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
const handleChange = (key: keyof CreateContestBody, value: any) => {
|
const handleChange = (key: keyof CreateContestBody, value: any) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
dispatch(
|
dispatch(createContest(form));
|
||||||
createContest({
|
|
||||||
...form,
|
|
||||||
endsAt: toUtc(form.endsAt),
|
|
||||||
startsAt: toUtc(form.startsAt),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const groupItems = myGroups.map((v) => {
|
|
||||||
return {
|
|
||||||
value: '' + v.id,
|
|
||||||
text: v.name,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const groupIdDefaultState =
|
|
||||||
myGroups.find((g) => g.id == form?.groupId) ?? myGroups[0] ?? undefined;
|
|
||||||
|
|
||||||
console.log(groupItems, myGroups, groupIdDefaultState);
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
@@ -165,123 +97,80 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1">
|
<label className="block text-sm mb-1">
|
||||||
Тип контеста
|
Тип расписания
|
||||||
</label>
|
</label>
|
||||||
|
<select
|
||||||
<DropDownList
|
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
||||||
items={scheduleTypeItems}
|
value={form.scheduleType}
|
||||||
onChange={(v) => {
|
onChange={(e) =>
|
||||||
handleChange('scheduleType', v);
|
handleChange(
|
||||||
}}
|
'scheduleType',
|
||||||
weight="w-full"
|
e.target
|
||||||
/>
|
.value as CreateContestBody['scheduleType'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="AlwaysOpen">Всегда открыт</option>
|
||||||
|
<option value="FixedWindow">
|
||||||
|
Фиксированные даты
|
||||||
|
</option>
|
||||||
|
<option value="RollingWindow">
|
||||||
|
Скользящее окно
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1">Видимость</label>
|
<label className="block text-sm mb-1">Видимость</label>
|
||||||
<DropDownList
|
<select
|
||||||
items={visibilityItems}
|
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
||||||
onChange={(v) => {
|
value={form.visibility}
|
||||||
handleChange('visibility', v);
|
onChange={(e) =>
|
||||||
}}
|
handleChange(
|
||||||
weight="w-full"
|
'visibility',
|
||||||
/>
|
e.target
|
||||||
|
.value as CreateContestBody['visibility'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="Public">Публичный</option>
|
||||||
|
<option value="GroupPrivate">Групповой</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<DateInput
|
<DateRangeInput
|
||||||
label="Дата начала"
|
startValue={form.startsAt || ''}
|
||||||
value={form.startsAt}
|
endValue={form.endsAt || ''}
|
||||||
onChange={(v) => handleChange('startsAt', v)}
|
onChange={handleChange}
|
||||||
|
className="mt-[10px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DateInput
|
|
||||||
label="Дата окончания"
|
|
||||||
value={form.endsAt}
|
|
||||||
onChange={(v) => handleChange('endsAt', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Продолжительность и лимиты */}
|
{/* Продолжительность и лимиты */}
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
<NumberInput
|
<Input
|
||||||
defaultState={form.attemptDurationMinutes}
|
|
||||||
name="attemptDurationMinutes"
|
name="attemptDurationMinutes"
|
||||||
|
type="number"
|
||||||
label="Длительность попытки (мин)"
|
label="Длительность попытки (мин)"
|
||||||
placeholder="Например: 60"
|
placeholder="Например: 60"
|
||||||
minValue={1}
|
|
||||||
maxValue={365 * 24 * 60}
|
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
handleChange('attemptDurationMinutes', Number(v))
|
handleChange('attemptDurationMinutes', Number(v))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<Input
|
||||||
defaultState={form.maxAttempts}
|
|
||||||
name="maxAttempts"
|
name="maxAttempts"
|
||||||
|
type="number"
|
||||||
label="Макс. попыток"
|
label="Макс. попыток"
|
||||||
placeholder="Например: 3"
|
placeholder="Например: 3"
|
||||||
minValue={1}
|
|
||||||
maxValue={100}
|
|
||||||
onChange={(v) => handleChange('maxAttempts', Number(v))}
|
onChange={(v) => handleChange('maxAttempts', Number(v))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Разрешить раннее завершение */}
|
{/* Разрешить раннее завершение */}
|
||||||
{/* <div className="flex items-center gap-[10px] mt-[15px]">
|
<div className="flex items-center gap-[10px] mt-[15px]">
|
||||||
<input
|
<input
|
||||||
id="allowEarlyFinish"
|
id="allowEarlyFinish"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -293,7 +182,7 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|||||||
<label htmlFor="allowEarlyFinish">
|
<label htmlFor="allowEarlyFinish">
|
||||||
Разрешить раннее завершение
|
Разрешить раннее завершение
|
||||||
</label>
|
</label>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -5,9 +5,9 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|||||||
import { fetchGroupById } from '../../../redux/slices/groups';
|
import { fetchGroupById } from '../../../redux/slices/groups';
|
||||||
import GroupMenu from './GroupMenu';
|
import GroupMenu from './GroupMenu';
|
||||||
import { Posts } from './posts/Posts';
|
import { Posts } from './posts/Posts';
|
||||||
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
import { Chat } from './chat/Chat';
|
import { Chat } from './chat/Chat';
|
||||||
import { Contests } from './contests/Contests';
|
import { Contests } from './contests/Contests';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
|
||||||
|
|
||||||
interface GroupsBlockProps {}
|
interface GroupsBlockProps {}
|
||||||
|
|
||||||
@@ -21,10 +21,11 @@ const Group: FC<GroupsBlockProps> = () => {
|
|||||||
const group = useAppSelector((state) => state.groups.fetchGroupById.group);
|
const group = useAppSelector((state) => state.groups.fetchGroupById.group);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('groups'));
|
|
||||||
dispatch(fetchGroupById(groupId));
|
dispatch(fetchGroupById(groupId));
|
||||||
}, [groupId]);
|
}, [groupId]);
|
||||||
|
|
||||||
|
console.log(group);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,11 +38,8 @@ const Group: FC<GroupsBlockProps> = () => {
|
|||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="home" element={<Posts groupId={groupId} />} />
|
<Route path="home" element={<Posts groupId={groupId} />} />
|
||||||
<Route path="chat" element={<Chat groupId={groupId} />} />
|
<Route path="chat" element={<Chat />} />
|
||||||
<Route
|
<Route path="contests" element={<Contests />} />
|
||||||
path="contests"
|
|
||||||
element={<Contests groupId={groupId} />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to={`/group/${groupId}/home`} />}
|
element={<Navigate to={`/group/${groupId}/home`} />}
|
||||||
|
|||||||
@@ -1,212 +1,12 @@
|
|||||||
import { FC, useEffect, useRef, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch } from '../../../../redux/hooks';
|
||||||
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
||||||
import {
|
|
||||||
fetchGroupMessages,
|
|
||||||
sendGroupMessage,
|
|
||||||
setGroupChatStatus,
|
|
||||||
} from '../../../../redux/slices/groupChat';
|
|
||||||
import { MessageItem } from './MessageItem';
|
|
||||||
import { Send } from '../../../../assets/icons/input';
|
|
||||||
|
|
||||||
interface GroupChatProps {
|
export const Chat = () => {
|
||||||
groupId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHUNK_SIZE = 10;
|
|
||||||
|
|
||||||
export const Chat: FC<GroupChatProps> = ({ groupId }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const messages = useAppSelector((s) => s.groupchat.messages[groupId] || []);
|
|
||||||
const messagesState = useAppSelector(
|
|
||||||
(state) => state.groupchat.fetchMessages.status,
|
|
||||||
);
|
|
||||||
const lastMessageId = useAppSelector(
|
|
||||||
(state) => state.groupchat.lastMessage[groupId] || 0,
|
|
||||||
);
|
|
||||||
const user = useAppSelector((state) => state.auth);
|
|
||||||
|
|
||||||
const [text, setText] = useState<string>('');
|
|
||||||
const [firstMessagesFetch, setFirctMessagesFetch] = useState<boolean>(true);
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// добавлено: ref для хранения предыдущей высоты
|
|
||||||
const prevHeightRef = useRef(0);
|
|
||||||
|
|
||||||
// активируем таб
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveGroupPage('chat'));
|
dispatch(setMenuActiveGroupPage('chat'));
|
||||||
}, []);
|
}, []);
|
||||||
|
return <></>;
|
||||||
// первичная загрузка
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(
|
|
||||||
fetchGroupMessages({
|
|
||||||
groupId,
|
|
||||||
limit: CHUNK_SIZE,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [groupId]);
|
|
||||||
|
|
||||||
// автоскролл вниз после начальной загрузки (но не при догрузке)
|
|
||||||
useEffect(() => {
|
|
||||||
const div = scrollRef.current;
|
|
||||||
if (!div) return;
|
|
||||||
|
|
||||||
// если prevHeightRef == 0 — значит это не догрузка, а обычная загрузка
|
|
||||||
if (prevHeightRef.current === 0) {
|
|
||||||
div.scrollTop = div.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages.length]);
|
|
||||||
|
|
||||||
// добавлено: компенсирование скролла при догрузке
|
|
||||||
useEffect(() => {
|
|
||||||
const div = scrollRef.current;
|
|
||||||
if (!div) return;
|
|
||||||
|
|
||||||
if (prevHeightRef.current > 0) {
|
|
||||||
const diff = div.scrollHeight - prevHeightRef.current;
|
|
||||||
div.scrollTop = diff; // компенсируем смещение
|
|
||||||
|
|
||||||
prevHeightRef.current = 0; // сбрасываем
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (messagesState == 'successful') {
|
|
||||||
dispatch(
|
|
||||||
setGroupChatStatus({ key: 'fetchMessages', status: 'idle' }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (messagesState == 'failed') {
|
|
||||||
dispatch(
|
|
||||||
setGroupChatStatus({ key: 'fetchMessages', status: 'idle' }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [messagesState]);
|
|
||||||
|
|
||||||
const lastMessageIdRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
lastMessageIdRef.current = lastMessageId;
|
|
||||||
if (lastMessageId && firstMessagesFetch) {
|
|
||||||
setFirctMessagesFetch(false);
|
|
||||||
dispatch(
|
|
||||||
fetchGroupMessages({
|
|
||||||
groupId,
|
|
||||||
afterMessageId: lastMessageIdRef.current,
|
|
||||||
timeoutSeconds: 10,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (lastMessageIdRef.current === null) return;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
fetchGroupMessages({
|
|
||||||
groupId,
|
|
||||||
afterMessageId: lastMessageIdRef.current,
|
|
||||||
timeoutSeconds: 10,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [groupId]);
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
if (!text.trim()) return;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
sendGroupMessage({
|
|
||||||
groupId,
|
|
||||||
content: text.trim(),
|
|
||||||
}),
|
|
||||||
).then(() => {
|
|
||||||
setText('');
|
|
||||||
setTimeout(() => {
|
|
||||||
const div = scrollRef.current;
|
|
||||||
if (div) div.scrollTop = div.scrollHeight;
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// догрузка старых сообщений при скролле вверх
|
|
||||||
const handleScroll = () => {
|
|
||||||
const div = scrollRef.current;
|
|
||||||
if (!div) return;
|
|
||||||
|
|
||||||
// если скролл в верхней точке
|
|
||||||
if (div.scrollTop === 0) {
|
|
||||||
prevHeightRef.current = div.scrollHeight; // запоминаем высоту до загрузки
|
|
||||||
|
|
||||||
const first = messages[0];
|
|
||||||
if (!first || first.id == 1) return;
|
|
||||||
|
|
||||||
const beforeId = first.id - CHUNK_SIZE;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
fetchGroupMessages({
|
|
||||||
groupId,
|
|
||||||
limit: CHUNK_SIZE,
|
|
||||||
afterMessageId: beforeId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full relative">
|
|
||||||
<div className="grid grid-rows-[1fr,40px] h-full relative min-h-0 gap-[20px]">
|
|
||||||
<div
|
|
||||||
className="min-h-0 overflow-y-scroll thin-dark-scrollbar"
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
|
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<MessageItem
|
|
||||||
key={i}
|
|
||||||
message={msg.content}
|
|
||||||
createdAt={msg.createdAt}
|
|
||||||
id={msg.id}
|
|
||||||
groupId={msg.groupId}
|
|
||||||
authorId={msg.authorId}
|
|
||||||
authorUsername={msg.authorUsername}
|
|
||||||
myMessage={msg.authorId == Number(user.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="bg-liquid-lighter rounded-[10px] cursor-text flex items-center px-[16px]">
|
|
||||||
<input
|
|
||||||
className="w-[calc(100%-50px)] outline-none bg-transparent placeholder:text-[16px] placeholder:text-liquid-light placeholder:font-medium"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onKeyDown={handleEnter}
|
|
||||||
placeholder="Введите сообщение"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={Send}
|
|
||||||
className=" absolute cursor-pointer right-[16px] active:scale-90 transition-all duration-300"
|
|
||||||
onClick={() => {
|
|
||||||
handleSend();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { FC } from 'react';
|
|
||||||
import { useAppSelector } from '../../../../redux/hooks';
|
|
||||||
|
|
||||||
function convertDate(isoString: string) {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
|
|
||||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const yyyy = date.getUTCFullYear();
|
|
||||||
|
|
||||||
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
||||||
const min = String(date.getUTCMinutes()).padStart(2, '0');
|
|
||||||
|
|
||||||
return `${dd}.${mm}.${yyyy} ${hh}:${min}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageItemProps {
|
|
||||||
id: number;
|
|
||||||
groupId: number;
|
|
||||||
authorId: number;
|
|
||||||
authorUsername: string;
|
|
||||||
createdAt: string;
|
|
||||||
message: string;
|
|
||||||
myMessage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageItem: FC<MessageItemProps> = ({
|
|
||||||
authorId,
|
|
||||||
authorUsername,
|
|
||||||
createdAt,
|
|
||||||
message,
|
|
||||||
myMessage,
|
|
||||||
}) => {
|
|
||||||
const members = useAppSelector(
|
|
||||||
(state) => state.groups.fetchGroupById.group?.members,
|
|
||||||
);
|
|
||||||
const member = members?.find((m) => m.userId === authorId);
|
|
||||||
|
|
||||||
return myMessage ? (
|
|
||||||
<div className="flex flex-col gap-[20px] items-end leading-[20px] text-[16px]">
|
|
||||||
<div className="w-[50%] flex flex-col gap-[10px]">
|
|
||||||
<div className="h-[20px] w-full flex gap-[10px] relative justify-end ">
|
|
||||||
<div className="font-bold text-liquid-light">
|
|
||||||
{convertDate(createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="bg-liquid-lighter w-fit max-w-full break-words px-[16px] py-[8px] rounded-[10px] ">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-[20px] ">
|
|
||||||
<div className="w-[50%] flex flex-col gap-[10px]">
|
|
||||||
<div className="h-[40px] w-full flex gap-[10px] relative ">
|
|
||||||
<div className="h-[40px] w-[40px] bg-[#D9D9D9] rounded-[10px]"></div>
|
|
||||||
<div className=" leading-[20px] font-bold text-[16px] ">
|
|
||||||
<div>{authorUsername} </div>
|
|
||||||
<div className="text-liquid-light">
|
|
||||||
{member ? member.role : 'роль не найдена'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className=" leading-[20px] font-bold text-[16px] ">
|
|
||||||
<div className="text-liquid-light">
|
|
||||||
{convertDate(createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex">
|
|
||||||
<div className="bg-liquid-lighter w-fit max-w-full break-words px-[16px] py-[8px] rounded-[10px] ">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,75 +1,12 @@
|
|||||||
import { FC, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch } from '../../../../redux/hooks';
|
||||||
import ContestsBlock from './ContestsBlock';
|
|
||||||
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
||||||
import {
|
|
||||||
fetchContests,
|
|
||||||
fetchMyContests,
|
|
||||||
fetchParticipatingContests,
|
|
||||||
} from '../../../../redux/slices/contests';
|
|
||||||
|
|
||||||
interface ContestsProps {
|
export const Contests = () => {
|
||||||
groupId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Contests: FC<ContestsProps> = ({ groupId }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { contests, status } = useAppSelector(
|
|
||||||
(state) => state.contests.fetchContests,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveGroupPage('contests'));
|
dispatch(setMenuActiveGroupPage('contests'));
|
||||||
dispatch(fetchContests({ groupId }));
|
|
||||||
dispatch(fetchParticipatingContests({ pageSize: 100 }));
|
|
||||||
dispatch(fetchMyContests());
|
|
||||||
}, []);
|
}, []);
|
||||||
|
return <></>;
|
||||||
return (
|
|
||||||
<div className="h-full relative">
|
|
||||||
<div className="grid grid-rows-[1fr] h-full relative min-h-0 gap-[20px]">
|
|
||||||
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar pr-[20px]">
|
|
||||||
{status == 'loading' && (
|
|
||||||
<div className="text-liquid-white p-4">
|
|
||||||
Загрузка контестов...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status == 'successful' && (
|
|
||||||
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
|
|
||||||
<ContestsBlock
|
|
||||||
groupId={groupId}
|
|
||||||
className="mb-[20px]"
|
|
||||||
title="Текущие"
|
|
||||||
contests={contests
|
|
||||||
.filter(
|
|
||||||
(c) => c.scheduleType != 'AlwaysOpen',
|
|
||||||
)
|
|
||||||
.filter((c) =>
|
|
||||||
c.endsAt
|
|
||||||
? new Date() < new Date(c.endsAt)
|
|
||||||
: false,
|
|
||||||
)}
|
|
||||||
type="upcoming"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ContestsBlock
|
|
||||||
groupId={groupId}
|
|
||||||
className="mb-[20px]"
|
|
||||||
title="Прошедшие"
|
|
||||||
contests={contests.filter(
|
|
||||||
(c) =>
|
|
||||||
c.scheduleType == 'AlwaysOpen' ||
|
|
||||||
!(c.endsAt
|
|
||||||
? new Date() < new Date(c.endsAt)
|
|
||||||
: false),
|
|
||||||
)}
|
|
||||||
type="past"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useState, FC } from 'react';
|
|
||||||
import { cn } from '../../../../lib/cn';
|
|
||||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
|
||||||
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';
|
|
||||||
groupId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
|
||||||
contests,
|
|
||||||
title,
|
|
||||||
className,
|
|
||||||
type,
|
|
||||||
groupId,
|
|
||||||
}) => {
|
|
||||||
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
|
|
||||||
groupId={groupId}
|
|
||||||
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'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == 'upcoming') {
|
|
||||||
return (
|
|
||||||
<UpcoingContestItem
|
|
||||||
groupId={groupId}
|
|
||||||
key={i}
|
|
||||||
contestId={v.id}
|
|
||||||
scheduleType={v.scheduleType}
|
|
||||||
name={v.name}
|
|
||||||
startsAt={
|
|
||||||
v.startsAt ?? new Date().toString()
|
|
||||||
}
|
|
||||||
endsAt={
|
|
||||||
v.endsAt ?? new Date().toString()
|
|
||||||
}
|
|
||||||
attemptDurationMinutes={
|
|
||||||
v.attemptDurationMinutes ?? 0
|
|
||||||
}
|
|
||||||
type={i % 2 ? 'second' : 'first'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <></>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContestsBlock;
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { Modal } from '../../../../components/modal/Modal';
|
|
||||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
|
||||||
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
|
||||||
import { Input } from '../../../../components/input/Input';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
|
||||||
import {
|
|
||||||
createContest,
|
|
||||||
setContestStatus,
|
|
||||||
} from '../../../../redux/slices/contests';
|
|
||||||
import { CreateContestBody } from '../../../../redux/slices/contests';
|
|
||||||
import DateRangeInput from '../../../../components/input/DateRangeInput';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
function toUtc(localDateTime?: string): string {
|
|
||||||
if (!localDateTime) return '';
|
|
||||||
|
|
||||||
// Создаём дату (она автоматически считается как локальная)
|
|
||||||
const date = new Date(localDateTime);
|
|
||||||
|
|
||||||
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
|
|
||||||
return date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModalCreateContestProps {
|
|
||||||
active: boolean;
|
|
||||||
setActive: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
|
||||||
active,
|
|
||||||
setActive,
|
|
||||||
}) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const status = useAppSelector(
|
|
||||||
(state) => state.contests.createContest.status,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateContestBody>({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
scheduleType: 'AlwaysOpen',
|
|
||||||
visibility: 'Public',
|
|
||||||
startsAt: '',
|
|
||||||
endsAt: '',
|
|
||||||
attemptDurationMinutes: 0,
|
|
||||||
maxAttempts: 0,
|
|
||||||
allowEarlyFinish: false,
|
|
||||||
missionIds: [],
|
|
||||||
articleIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const contest = useAppSelector(
|
|
||||||
(state) => state.contests.createContest.contest,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === 'successful') {
|
|
||||||
dispatch(
|
|
||||||
setContestStatus({ key: 'createContest', status: 'idle' }),
|
|
||||||
);
|
|
||||||
navigate(
|
|
||||||
`/contest/create?back=/home/account/contests&contestId=${contest.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
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),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
|
||||||
onOpenChange={setActive}
|
|
||||||
open={active}
|
|
||||||
backdrop="blur"
|
|
||||||
>
|
|
||||||
<div className="w-[550px]">
|
|
||||||
<div className="font-bold text-[30px] mb-[10px]">
|
|
||||||
Создать контест
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
label="Название"
|
|
||||||
className="mt-[10px]"
|
|
||||||
placeholder="Введите название"
|
|
||||||
onChange={(v) => handleChange('name', v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
label="Описание"
|
|
||||||
className="mt-[10px]"
|
|
||||||
placeholder="Введите описание"
|
|
||||||
onChange={(v) => handleChange('description', v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm mb-1">
|
|
||||||
Тип расписания
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
|
||||||
value={form.scheduleType}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange(
|
|
||||||
'scheduleType',
|
|
||||||
e.target
|
|
||||||
.value as CreateContestBody['scheduleType'],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="AlwaysOpen">Всегда открыт</option>
|
|
||||||
<option value="FixedWindow">
|
|
||||||
Фиксированные даты
|
|
||||||
</option>
|
|
||||||
<option value="RollingWindow">
|
|
||||||
Скользящее окно
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm mb-1">Видимость</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
|
||||||
value={form.visibility}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange(
|
|
||||||
'visibility',
|
|
||||||
e.target
|
|
||||||
.value as CreateContestBody['visibility'],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="Public">Публичный</option>
|
|
||||||
<option value="GroupPrivate">Групповой</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Даты начала и конца */}
|
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
|
||||||
<DateRangeInput
|
|
||||||
startValue={form.startsAt || ''}
|
|
||||||
endValue={form.endsAt || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="mt-[10px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Продолжительность и лимиты */}
|
|
||||||
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
|
||||||
<Input
|
|
||||||
name="attemptDurationMinutes"
|
|
||||||
type="number"
|
|
||||||
label="Длительность попытки (мин)"
|
|
||||||
placeholder="Например: 60"
|
|
||||||
onChange={(v) =>
|
|
||||||
handleChange('attemptDurationMinutes', Number(v))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
name="maxAttempts"
|
|
||||||
type="number"
|
|
||||||
label="Макс. попыток"
|
|
||||||
placeholder="Например: 3"
|
|
||||||
onChange={(v) => handleChange('maxAttempts', Number(v))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Разрешить раннее завершение */}
|
|
||||||
<div className="flex items-center gap-[10px] mt-[15px]">
|
|
||||||
<input
|
|
||||||
id="allowEarlyFinish"
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!form.allowEarlyFinish}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange('allowEarlyFinish', e.target.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label htmlFor="allowEarlyFinish">
|
|
||||||
Разрешить раннее завершение
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопки */}
|
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
handleSubmit();
|
|
||||||
}}
|
|
||||||
text="Создать"
|
|
||||||
disabled={status === 'loading'}
|
|
||||||
/>
|
|
||||||
<SecondaryButton
|
|
||||||
onClick={() => setActive(false)}
|
|
||||||
text="Отмена"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalCreateContest;
|
|
||||||
@@ -1,191 +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';
|
|
||||||
groupId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
groupId,
|
|
||||||
}) => {
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
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: `/group/${groupId}/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>
|
|
||||||
{userId && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
{role == 'Organizer' || role == 'Participant' ? (
|
|
||||||
<ReverseButton
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
back: `/group/${groupId}/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;
|
|
||||||
@@ -1,228 +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';
|
|
||||||
groupId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
groupId,
|
|
||||||
}) => {
|
|
||||||
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 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 items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300 grid grid-flow-col',
|
|
||||||
userId
|
|
||||||
? 'grid-cols-[1fr,1fr,190px,130px,130px,150px]'
|
|
||||||
: 'grid-cols-[1fr,1fr,190px,130px,130px]',
|
|
||||||
|
|
||||||
type == 'first'
|
|
||||||
? ' bg-liquid-lighter'
|
|
||||||
: ' bg-liquid-background',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (!started) {
|
|
||||||
toastWarning('Контест еще не начался');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
back: `/group/${groupId}/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>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{userId && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
{role == 'Organizer' || role == 'Participant' ? (
|
|
||||||
<ReverseButton
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
back: `/group/${groupId}/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;
|
|
||||||
@@ -2,7 +2,9 @@ import { FC, useEffect, useState } from 'react';
|
|||||||
import { Modal } from '../../../../components/modal/Modal';
|
import { Modal } from '../../../../components/modal/Modal';
|
||||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
||||||
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
||||||
|
import { Input } from '../../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import { createGroup } from '../../../../redux/slices/groups';
|
||||||
import MarkdownEditor from '../../../articleeditor/Editor';
|
import MarkdownEditor from '../../../articleeditor/Editor';
|
||||||
import {
|
import {
|
||||||
createPost,
|
createPost,
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { FC, useEffect, useState } from 'react';
|
|||||||
import { Modal } from '../../../../components/modal/Modal';
|
import { Modal } from '../../../../components/modal/Modal';
|
||||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
||||||
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
||||||
|
import { Input } from '../../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import { createGroup } from '../../../../redux/slices/groups';
|
||||||
import MarkdownEditor, { MarkDownPattern } from '../../../articleeditor/Editor';
|
import MarkdownEditor, { MarkDownPattern } from '../../../articleeditor/Editor';
|
||||||
import {
|
import {
|
||||||
|
createPost,
|
||||||
deletePost,
|
deletePost,
|
||||||
fetchPostById,
|
fetchPostById,
|
||||||
setGroupFeedStatus,
|
setGroupFeedStatus,
|
||||||
@@ -52,7 +55,7 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
|
|||||||
}, [statusDelete]);
|
}, [statusDelete]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (postId) dispatch(fetchPostById({ groupId, postId }));
|
dispatch(fetchPostById({ groupId, postId }));
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,10 +32,13 @@ interface PostItemProps {
|
|||||||
|
|
||||||
export const PostItem: FC<PostItemProps> = ({
|
export const PostItem: FC<PostItemProps> = ({
|
||||||
id,
|
id,
|
||||||
|
groupId,
|
||||||
authorId,
|
authorId,
|
||||||
authorUsername,
|
authorUsername,
|
||||||
|
name,
|
||||||
content,
|
content,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
setModalUpdateActive,
|
setModalUpdateActive,
|
||||||
setUpdatePostId,
|
setUpdatePostId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FC, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useAppSelector, useAppDispatch } from '../../../../redux/hooks';
|
import { useAppSelector, useAppDispatch } from '../../../../redux/hooks';
|
||||||
import { fetchGroupPosts } from '../../../../redux/slices/groupfeed';
|
import { fetchGroupPosts } from '../../../../redux/slices/groupfeed';
|
||||||
|
import { SearchInput } from '../../../../components/input/SearchInput';
|
||||||
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
||||||
import { fetchGroupById } from '../../../../redux/slices/groups';
|
import { fetchGroupById } from '../../../../redux/slices/groups';
|
||||||
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
||||||
@@ -53,9 +54,13 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
|
|||||||
const page0 = pages[0];
|
const page0 = pages[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full relative">
|
<div className="h-full overflow-y-scroll thin-dark-scrollbar">
|
||||||
<div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
|
|
||||||
<div className="h-[40px] mb-[20px] relative">
|
<div className="h-[40px] mb-[20px] relative">
|
||||||
|
<SearchInput
|
||||||
|
className="w-[216px]"
|
||||||
|
onChange={(v) => {}}
|
||||||
|
placeholder="Поиск сообщений"
|
||||||
|
/>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
|
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
@@ -68,33 +73,26 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<>
|
|
||||||
{status === 'loading' && <div>Загрузка...</div>}
|
{status === 'loading' && <div>Загрузка...</div>}
|
||||||
{status === 'failed' && <div>Ошибка загрузки постов</div>}
|
{status === 'failed' && <div>Ошибка загрузки постов</div>}
|
||||||
|
|
||||||
{status == 'successful' &&
|
{status == 'successful' &&
|
||||||
page0?.items &&
|
page0?.items &&
|
||||||
page0.items.length > 0 ? (
|
page0.items.length > 0 ? (
|
||||||
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar">
|
<div className="flex flex-col gap-[20px]">
|
||||||
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
|
|
||||||
{page0.items.map((post, i) => (
|
{page0.items.map((post, i) => (
|
||||||
<PostItem
|
<PostItem
|
||||||
{...post}
|
{...post}
|
||||||
key={i}
|
key={i}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
setModalUpdateActive={
|
setModalUpdateActive={setModalUpdateActive}
|
||||||
setModalUpdateActive
|
|
||||||
}
|
|
||||||
setUpdatePostId={setUpdatePostId}
|
setUpdatePostId={setUpdatePostId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : status === 'successful' ? (
|
) : status === 'successful' ? (
|
||||||
<div>Постов пока нет</div>
|
<div>Постов пока нет</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModalCreate
|
<ModalCreate
|
||||||
active={modalCreateActive}
|
active={modalCreateActive}
|
||||||
|
|||||||
@@ -1,16 +1,48 @@
|
|||||||
|
import {
|
||||||
|
FilterDropDown,
|
||||||
|
FilterItem,
|
||||||
|
} from '../../../components/drop-down-list/Filter';
|
||||||
|
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
import { useAppDispatch } from '../../../redux/hooks';
|
|
||||||
import { setGroupFilter } from '../../../redux/slices/store';
|
|
||||||
|
|
||||||
const Filters = () => {
|
const Filters = () => {
|
||||||
const dispatch = useAppDispatch();
|
const items: FilterItem[] = [
|
||||||
|
{ text: 'React', value: 'react' },
|
||||||
|
{ text: 'Vue', value: 'vue' },
|
||||||
|
{ text: 'Angular', value: 'angular' },
|
||||||
|
{ text: 'Svelte', value: 'svelte' },
|
||||||
|
{ text: 'Next.js', value: 'next' },
|
||||||
|
{ text: 'Nuxt', value: 'nuxt' },
|
||||||
|
{ text: 'Solid', value: 'solid' },
|
||||||
|
{ text: 'Qwik', value: 'qwik' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput
|
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
||||||
onChange={(v: string) => {
|
|
||||||
dispatch(setGroupFilter(v));
|
<SorterDropDown
|
||||||
}}
|
items={[
|
||||||
placeholder="Поиск группы"
|
{
|
||||||
|
value: '1',
|
||||||
|
text: 'Сложность',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '2',
|
||||||
|
text: 'Дата создания',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '3',
|
||||||
|
text: 'ID',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(v) => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterDropDown
|
||||||
|
items={items}
|
||||||
|
defaultState={[]}
|
||||||
|
onChange={(values) => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { Book, UserAdd, Edit } from '../../../assets/icons/groups';
|
import {
|
||||||
|
Book,
|
||||||
|
UserAdd,
|
||||||
|
Edit,
|
||||||
|
EyeClosed,
|
||||||
|
EyeOpen,
|
||||||
|
} from '../../../assets/icons/groups';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { GroupInvite, GroupUpdate } from './Groups';
|
import { GroupInvite, GroupUpdate } from './Groups';
|
||||||
import { useAppSelector } from '../../../redux/hooks';
|
|
||||||
|
|
||||||
export interface GroupItemProps {
|
export interface GroupItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
role: 'menager' | 'member' | 'owner' | 'viewer';
|
role: 'menager' | 'member' | 'owner' | 'viewer';
|
||||||
|
visible: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
setUpdateActive: (value: any) => void;
|
setUpdateActive: (value: any) => void;
|
||||||
@@ -37,6 +43,8 @@ const IconComponent: React.FC<IconComponentProps> = ({ src, onClick }) => {
|
|||||||
const GroupItem: React.FC<GroupItemProps> = ({
|
const GroupItem: React.FC<GroupItemProps> = ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
visible,
|
||||||
|
role,
|
||||||
description,
|
description,
|
||||||
setUpdateGroup,
|
setUpdateGroup,
|
||||||
setUpdateActive,
|
setUpdateActive,
|
||||||
@@ -46,61 +54,6 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const filter = useAppSelector(
|
|
||||||
(state) => state.store.group.groupFilter,
|
|
||||||
).toLowerCase();
|
|
||||||
|
|
||||||
const highlightZ = (name: string, filter: string) => {
|
|
||||||
if (!filter) return name;
|
|
||||||
|
|
||||||
const s = filter.toLowerCase();
|
|
||||||
const t = name.toLowerCase();
|
|
||||||
const n = t.length;
|
|
||||||
const m = s.length;
|
|
||||||
|
|
||||||
const mark = Array(n).fill(false);
|
|
||||||
|
|
||||||
// Проходимся с конца и ставим отметки
|
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
|
||||||
if (i + m <= n && t.slice(i, i + m) === s) {
|
|
||||||
for (let j = i; j < i + m; j++) {
|
|
||||||
if (mark[j]) break;
|
|
||||||
mark[j] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Формируем единые жёлтые блоки ===
|
|
||||||
const result: any[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < n) {
|
|
||||||
if (!mark[i]) {
|
|
||||||
// обычный символ
|
|
||||||
result.push(name[i]);
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
// начинаем жёлтый блок
|
|
||||||
let j = i;
|
|
||||||
while (j < n && mark[j]) j++;
|
|
||||||
|
|
||||||
const chunk = name.slice(i, j);
|
|
||||||
result.push(
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="bg-yellow-400 text-black rounded px-1"
|
|
||||||
>
|
|
||||||
{chunk}
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -114,10 +67,7 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
className="bg-liquid-brightmain rounded-[10px]"
|
className="bg-liquid-brightmain rounded-[10px]"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
||||||
<div className="text-[18px] font-bold">
|
<div className="text-[18px] font-bold">{name}</div>
|
||||||
{highlightZ(name, filter)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className=" flex gap-[10px]">
|
<div className=" flex gap-[10px]">
|
||||||
{type == 'manage' && (
|
{type == 'manage' && (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
@@ -137,6 +87,8 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{visible == false && <IconComponent src={EyeOpen} />}
|
||||||
|
{visible == true && <IconComponent src={EyeClosed} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cn } from '../../../lib/cn';
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import GroupsBlock from './GroupsBlock';
|
import GroupsBlock from './GroupsBlock';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { fetchMyGroups, Group } from '../../../redux/slices/groups';
|
import { fetchMyGroups } from '../../../redux/slices/groups';
|
||||||
import ModalCreate from './ModalCreate';
|
import ModalCreate from './ModalCreate';
|
||||||
import ModalUpdate from './ModalUpdate';
|
import ModalUpdate from './ModalUpdate';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
@@ -45,7 +45,6 @@ const Groups = () => {
|
|||||||
const groupsError = useAppSelector(
|
const groupsError = useAppSelector(
|
||||||
(store) => store.groups.fetchMyGroups.error,
|
(store) => store.groups.fetchMyGroups.error,
|
||||||
);
|
);
|
||||||
const filter = useAppSelector((state) => state.store.group.groupFilter);
|
|
||||||
|
|
||||||
// Берём текущего пользователя
|
// Берём текущего пользователя
|
||||||
const currentUserName = useAppSelector((store) => store.auth.username);
|
const currentUserName = useAppSelector((store) => store.auth.username);
|
||||||
@@ -55,21 +54,17 @@ const Groups = () => {
|
|||||||
dispatch(fetchMyGroups());
|
dispatch(fetchMyGroups());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const applyFilter = (groups: Group[], filter: string) => {
|
// Разделяем группы
|
||||||
if (!filter || filter.trim() === '') return groups;
|
const { managedGroups, currentGroups, hiddenGroups } = useMemo(() => {
|
||||||
const normalized = filter.toLowerCase();
|
|
||||||
|
|
||||||
return groups.filter((g) => g.name.toLowerCase().includes(normalized));
|
|
||||||
};
|
|
||||||
const { managedGroups, currentGroups } = useMemo(() => {
|
|
||||||
if (!groups || !currentUserName) {
|
if (!groups || !currentUserName) {
|
||||||
return { managedGroups: [], currentGroups: [] };
|
return { managedGroups: [], currentGroups: [], hiddenGroups: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const managed: typeof groups = [];
|
const managed: typeof groups = [];
|
||||||
const current: typeof groups = [];
|
const current: typeof groups = [];
|
||||||
|
const hidden: typeof groups = []; // пока пустые, без логики
|
||||||
|
|
||||||
applyFilter(groups, filter).forEach((group) => {
|
groups.forEach((group) => {
|
||||||
const me = group.members.find(
|
const me = group.members.find(
|
||||||
(m) => m.username === currentUserName,
|
(m) => m.username === currentUserName,
|
||||||
);
|
);
|
||||||
@@ -85,8 +80,9 @@ const Groups = () => {
|
|||||||
return {
|
return {
|
||||||
managedGroups: managed,
|
managedGroups: managed,
|
||||||
currentGroups: current,
|
currentGroups: current,
|
||||||
|
hiddenGroups: hidden,
|
||||||
};
|
};
|
||||||
}, [groups, currentUserName, filter]);
|
}, [groups, currentUserName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
|
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
|
||||||
@@ -141,6 +137,16 @@ const Groups = () => {
|
|||||||
setInviteGroup={setInviteGroup}
|
setInviteGroup={setInviteGroup}
|
||||||
type="member"
|
type="member"
|
||||||
/>
|
/>
|
||||||
|
<GroupsBlock
|
||||||
|
className="mb-[20px]"
|
||||||
|
title="Скрытые"
|
||||||
|
groups={hiddenGroups} // пока пусто
|
||||||
|
setUpdateActive={setModalUpdateActive}
|
||||||
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
setInviteActive={setModalInviteActive}
|
||||||
|
setInviteGroup={setInviteGroup}
|
||||||
|
type="member"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
|
|||||||
<GroupItem
|
<GroupItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
id={v.id}
|
||||||
|
visible={true}
|
||||||
description={v.description}
|
description={v.description}
|
||||||
setUpdateActive={setUpdateActive}
|
setUpdateActive={setUpdateActive}
|
||||||
setUpdateGroup={setUpdateGroup}
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchGroupJoinLink } from '../../../redux/slices/groups';
|
|||||||
import { Modal } from '../../../components/modal/Modal';
|
import { Modal } from '../../../components/modal/Modal';
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { toastSuccess } from '../../../lib/toastNotification';
|
import { Input } from '../../../components/input/Input';
|
||||||
|
|
||||||
interface ModalInviteProps {
|
interface ModalInviteProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -51,9 +51,10 @@ const ModalInvite: FC<ModalInviteProps> = ({
|
|||||||
if (!inviteLink) return;
|
if (!inviteLink) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(inviteLink);
|
await navigator.clipboard.writeText(inviteLink);
|
||||||
toastSuccess('Приглашение скопировано в буфер обмена!');
|
|
||||||
setActive(false);
|
setActive(false);
|
||||||
} catch (err) {}
|
} catch (err) {
|
||||||
|
console.error('Не удалось скопировать ссылку:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Account,
|
Account,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Cup,
|
Cup,
|
||||||
|
Home,
|
||||||
Openbook,
|
Openbook,
|
||||||
Users,
|
Users,
|
||||||
} from '../../../assets/icons/menu';
|
} from '../../../assets/icons/menu';
|
||||||
@@ -11,6 +12,7 @@ import { useAppSelector } from '../../../redux/hooks';
|
|||||||
|
|
||||||
const Menu = () => {
|
const Menu = () => {
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
{ text: 'Главная', href: '/home', icon: Home, page: 'home' },
|
||||||
{
|
{
|
||||||
text: 'Задачи',
|
text: 'Задачи',
|
||||||
href: '/home/missions',
|
href: '/home/missions',
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
import { FC } from 'react';
|
import {
|
||||||
import { TagFilter } from '../../../components/filters/TagFilter';
|
FilterDropDown,
|
||||||
|
FilterItem,
|
||||||
|
} from '../../../components/drop-down-list/Filter';
|
||||||
|
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
interface MissionFiltersProps {
|
const Filters = () => {
|
||||||
onChangeTags: (value: string[]) => void;
|
const items: FilterItem[] = [
|
||||||
onChangeName: (value: string) => void;
|
{ text: 'React', value: 'react' },
|
||||||
}
|
{ text: 'Vue', value: 'vue' },
|
||||||
|
{ text: 'Angular', value: 'angular' },
|
||||||
|
{ text: 'Svelte', value: 'svelte' },
|
||||||
|
{ text: 'Next.js', value: 'next' },
|
||||||
|
{ text: 'Nuxt', value: 'nuxt' },
|
||||||
|
{ text: 'Solid', value: 'solid' },
|
||||||
|
{ text: 'Qwik', value: 'qwik' },
|
||||||
|
];
|
||||||
|
|
||||||
const Filters: FC<MissionFiltersProps> = ({ onChangeTags, onChangeName }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput
|
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
||||||
onChange={(value: string) => {
|
|
||||||
onChangeName(value);
|
<SorterDropDown
|
||||||
}}
|
items={[
|
||||||
placeholder="Поиск задачи"
|
{
|
||||||
|
value: '1',
|
||||||
|
text: 'Сложность',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '2',
|
||||||
|
text: 'Дата создания',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '3',
|
||||||
|
text: 'ID',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(v) => console.log(v)}
|
||||||
/>
|
/>
|
||||||
<TagFilter
|
|
||||||
onChange={(value: string[]) => {
|
<FilterDropDown
|
||||||
onChangeTags(value);
|
items={items}
|
||||||
}}
|
defaultState={[]}
|
||||||
|
onChange={(values) => console.log(values)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppSelector } from '../../../redux/hooks';
|
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -39,61 +38,6 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const nameFilter = useAppSelector(
|
|
||||||
(state) => state.store.missions.filterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlightZ = (name: string, filter: string) => {
|
|
||||||
if (!filter) return name;
|
|
||||||
|
|
||||||
const s = filter.toLowerCase();
|
|
||||||
const t = name.toLowerCase();
|
|
||||||
const n = t.length;
|
|
||||||
const m = s.length;
|
|
||||||
|
|
||||||
const mark = Array(n).fill(false);
|
|
||||||
|
|
||||||
// Проходимся с конца и ставим отметки
|
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
|
||||||
if (i + m <= n && t.slice(i, i + m) === s) {
|
|
||||||
for (let j = i; j < i + m; j++) {
|
|
||||||
if (mark[j]) break;
|
|
||||||
mark[j] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Формируем единые жёлтые блоки ===
|
|
||||||
const result: any[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < n) {
|
|
||||||
if (!mark[i]) {
|
|
||||||
// обычный символ
|
|
||||||
result.push(name[i]);
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
// начинаем жёлтый блок
|
|
||||||
let j = i;
|
|
||||||
while (j < n && mark[j]) j++;
|
|
||||||
|
|
||||||
const chunk = name.slice(i, j);
|
|
||||||
result.push(
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="bg-yellow-400 text-black rounded px-1"
|
|
||||||
>
|
|
||||||
{chunk}
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -111,9 +55,7 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[18px] font-bold">#{id}</div>
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
<div className="text-[18px] font-bold">
|
<div className="text-[18px] font-bold">{name}</div>
|
||||||
{highlightZ(name, nameFilter)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] text-right">
|
<div className="text-[12px] text-right">
|
||||||
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
|
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
|
||||||
{formatBytesToMB(memoryLimit)}
|
{formatBytesToMB(memoryLimit)}
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ import MissionItem from './MissionItem';
|
|||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
setMenuActivePage,
|
|
||||||
setMissionsNameFilter,
|
|
||||||
setMissionsTagFilter,
|
|
||||||
} from '../../../redux/slices/store';
|
|
||||||
import { fetchMissions } from '../../../redux/slices/missions';
|
import { fetchMissions } from '../../../redux/slices/missions';
|
||||||
import ModalCreate from './ModalCreate';
|
import ModalCreate from './ModalCreate';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
import { toastWarning } from '../../../lib/toastNotification';
|
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -30,28 +25,10 @@ const Missions = () => {
|
|||||||
|
|
||||||
const missions = useAppSelector((state) => state.missions.missions);
|
const missions = useAppSelector((state) => state.missions.missions);
|
||||||
|
|
||||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
|
||||||
const nameFilter = useAppSelector(
|
|
||||||
(state) => state.store.missions.filterName,
|
|
||||||
);
|
|
||||||
const tagsFilter = useAppSelector(
|
|
||||||
(state) => state.store.articles.articleTagFilter,
|
|
||||||
);
|
|
||||||
|
|
||||||
const calcDifficulty = (d: number) => {
|
|
||||||
if (d <= 1200) return 'Easy';
|
|
||||||
if (d <= 2000) return 'Medium';
|
|
||||||
return 'Hard';
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('missions'));
|
dispatch(setMenuActivePage('missions'));
|
||||||
dispatch(fetchMissions({ tags: tagsFilter }));
|
dispatch(fetchMissions({}));
|
||||||
}, []);
|
}, []);
|
||||||
const filterTagsHandler = (value: string[]) => {
|
|
||||||
dispatch(setMissionsTagFilter(value));
|
|
||||||
dispatch(fetchMissions({ tags: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||||
@@ -62,11 +39,6 @@ const Missions = () => {
|
|||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
||||||
if (!jwt){
|
|
||||||
toastWarning("Для загрузки задачи необходимо авторизоваться")
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setModalActive(true);
|
setModalActive(true);
|
||||||
}}
|
}}
|
||||||
text="Добавить задачу"
|
text="Добавить задачу"
|
||||||
@@ -74,39 +46,34 @@ const Missions = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filters
|
<Filters />
|
||||||
onChangeTags={(value: string[]) => {
|
|
||||||
filterTagsHandler(value);
|
|
||||||
}}
|
|
||||||
onChangeName={(value: string) => {
|
|
||||||
dispatch(setMissionsNameFilter(value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{missions
|
{missions.map((v, i) => (
|
||||||
.filter((v) =>
|
|
||||||
v.name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(nameFilter.toLocaleLowerCase()),
|
|
||||||
)
|
|
||||||
.map((v, i) => (
|
|
||||||
<MissionItem
|
<MissionItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
id={v.id}
|
||||||
authorId={v.authorId}
|
authorId={v.authorId}
|
||||||
name={v.name}
|
name={v.name}
|
||||||
difficulty={calcDifficulty(v.difficulty)}
|
difficulty={'Easy'}
|
||||||
tags={v.tags}
|
tags={v.tags}
|
||||||
timeLimit={1000}
|
timeLimit={1000}
|
||||||
memoryLimit={256 * 1024 * 1024}
|
memoryLimit={256 * 1024 * 1024}
|
||||||
createdAt={v.createdAt}
|
createdAt={v.createdAt}
|
||||||
updatedAt={v.updatedAt}
|
updatedAt={v.updatedAt}
|
||||||
type={i % 2 == 0 ? 'first' : 'second'}
|
type={i % 2 == 0 ? 'first' : 'second'}
|
||||||
status={'empty'}
|
status={
|
||||||
|
i == 0 || i == 3 || i == 7
|
||||||
|
? 'success'
|
||||||
|
: i == 2 || i == 4 || i == 9
|
||||||
|
? 'error'
|
||||||
|
: 'empty'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>pages</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalCreate setActive={setModalActive} active={modalActive} />
|
<ModalCreate setActive={setModalActive} active={modalActive} />
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import {
|
|||||||
setMissionsStatus,
|
setMissionsStatus,
|
||||||
uploadMission,
|
uploadMission,
|
||||||
} from '../../../redux/slices/missions';
|
} from '../../../redux/slices/missions';
|
||||||
import { toastSuccess } from '../../../lib/toastNotification';
|
|
||||||
import { cn } from '../../../lib/cn';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { NumberInput } from '../../../components/input/NumberInput';
|
|
||||||
|
|
||||||
interface ModalCreateProps {
|
interface ModalCreateProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -28,8 +24,6 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
const status = useAppSelector((state) => state.missions.statuses.upload);
|
const status = useAppSelector((state) => state.missions.statuses.upload);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [clickSubmit, setClickSubmit] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const addTag = () => {
|
const addTag = () => {
|
||||||
const newTag = tagInput.trim();
|
const newTag = tagInput.trim();
|
||||||
if (newTag && !tags.includes(newTag)) {
|
if (newTag && !tags.includes(newTag)) {
|
||||||
@@ -49,14 +43,13 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setClickSubmit(true);
|
if (!file) return alert('Выберите файл миссии!');
|
||||||
if (!file) return;
|
|
||||||
dispatch(uploadMission({ file, name, difficulty, tags }));
|
dispatch(uploadMission({ file, name, difficulty, tags }));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'successful') {
|
if (status === 'successful') {
|
||||||
toastSuccess('Миссия создана!');
|
alert('Миссия успешно загружена!');
|
||||||
setName('');
|
setName('');
|
||||||
setDifficulty(1);
|
setDifficulty(1);
|
||||||
setTags([]);
|
setTags([]);
|
||||||
@@ -67,18 +60,9 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active == true) {
|
|
||||||
setClickSubmit(false);
|
|
||||||
}
|
|
||||||
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
|
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
const getNameErrorMessage = (): string => {
|
|
||||||
if (!clickSubmit) return '';
|
|
||||||
if (name == '') return 'Поле не может быть пустым';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
@@ -98,17 +82,16 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
defaultState={name}
|
defaultState={name}
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
placeholder="В яблочко"
|
placeholder="В яблочко"
|
||||||
error={getNameErrorMessage()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<Input
|
||||||
name="difficulty"
|
name="difficulty"
|
||||||
|
autocomplete="difficulty"
|
||||||
className="mt-[10px]"
|
className="mt-[10px]"
|
||||||
|
type="number"
|
||||||
label="Сложность"
|
label="Сложность"
|
||||||
defaultState={difficulty}
|
defaultState={'' + difficulty}
|
||||||
minValue={1}
|
onChange={(v) => setDifficulty(Number(v))}
|
||||||
maxValue={3500}
|
|
||||||
onChange={(v) => setDifficulty(v)}
|
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -123,16 +106,6 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-liquid-red text-[14px] h-auto text-left mt-[5px] whitespace-pre-line overflow-hidden ',
|
|
||||||
(!clickSubmit || file) && 'h-0 mt-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Необходимо выбрать файл задачи
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Теги */}
|
{/* Теги */}
|
||||||
@@ -175,17 +148,6 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
Создать пакет задачи можно на платформе{' '}
|
|
||||||
<Link
|
|
||||||
to={'https://polygon.codeforces.com'}
|
|
||||||
target="_blank"
|
|
||||||
className="text-[#7489ff] hover:text-[#8c9dfd] transition-color duration-300"
|
|
||||||
>
|
|
||||||
polygon
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
@@ -197,6 +159,8 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
text="Отмена"
|
text="Отмена"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status == 'failed' && <div>error</div>}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,61 +1,38 @@
|
|||||||
import { FC, Fragment, useEffect } from 'react';
|
import { FC } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|
||||||
import { fetchNewArticles } from '../../../redux/slices/articles';
|
|
||||||
|
|
||||||
export const ArticlesRightPanel: FC = () => {
|
export const ArticlesRightPanel: FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const items = [
|
||||||
|
{
|
||||||
const articles = useAppSelector(
|
name: 'Энтузиаст создал карточки с NFC-метками для знакомства ребёнка с музыкой',
|
||||||
(state) => state.articles.fetchNewArticles.articles,
|
},
|
||||||
);
|
{
|
||||||
|
name: 'Алгоритм Древа Силы, Космический Сортировщик',
|
||||||
useEffect(() => {
|
},
|
||||||
dispatch(fetchNewArticles({ pageSize: 10 }));
|
{
|
||||||
}, []);
|
name: 'Космический Сортировщик',
|
||||||
|
},
|
||||||
const getDaysAgo = (dateString: string) => {
|
{
|
||||||
const updatedDate = new Date(dateString);
|
name: 'Зеркала Многомерности',
|
||||||
const today = new Date();
|
},
|
||||||
// Сбрасываем время для точного сравнения по дням
|
];
|
||||||
updatedDate.setHours(0, 0, 0, 0);
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const diffTime = today.getTime() - updatedDate.getTime();
|
|
||||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) return 'Сегодня';
|
|
||||||
if (diffDays === 1) return '1 день назад';
|
|
||||||
return `${diffDays} дня назад`;
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[10px] flex flex-col">
|
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[10px] flex flex-col">
|
||||||
<div className="text-liquid-white font-bold text-[24px] mb-[10px]">
|
<div className="text-liquid-white font-bold text-[18px]">
|
||||||
Новые статьи
|
Попоулярные статьи
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{articles &&
|
{items.map((v, i) => {
|
||||||
articles
|
|
||||||
.filter((v) => {
|
|
||||||
const updatedDate = new Date(v.updatedAt);
|
|
||||||
const threeDaysAgo = new Date();
|
|
||||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
|
||||||
return updatedDate >= threeDaysAgo;
|
|
||||||
})
|
|
||||||
.map((v, i) => {
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={i}>
|
<>
|
||||||
{
|
{
|
||||||
<div className="font-bold text-liquid-white text-[16px]">
|
<div className="font-bold text-liquid-light text-[16px]">
|
||||||
{v.name}
|
{v.name}
|
||||||
<div className="text-sm text-liquid-light">
|
|
||||||
{getDaysAgo(v.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{i + 1 != articles.length && (
|
{i + 1 != items.length && (
|
||||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
60
src/views/home/rightpanel/Group.tsx
Normal file
60
src/views/home/rightpanel/Group.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
export const GroupRightPanel: FC = () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
name: 'Игнат Герасименко',
|
||||||
|
role: 'Администратор',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Алиса Макаренко',
|
||||||
|
role: 'Модератор',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Федор Картман',
|
||||||
|
role: 'Модератор',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Карина Механаджанович',
|
||||||
|
role: 'Участник',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Михаил Ангрский',
|
||||||
|
role: 'Участник',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'newuser',
|
||||||
|
role: 'Участник (Вы)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[5px] flex flex-col">
|
||||||
|
<div className="text-liquid-white font-bold text-[18px]">
|
||||||
|
Пользователи
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.map((v, i) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
<div className="text-liquid-light text-[16px] grid grid-cols-[40px,1fr] gap-[10px] items-center cursor-pointer hover:bg-liquid-lighter transition-all duration-300 rounded-[10px] p-[5px]">
|
||||||
|
<div className="h-[40px] w-[40px] rounded-[10px] bg-[#D9D9D9]"></div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="text-liquid-white font-bold text-[16px] leading-5">
|
||||||
|
{v.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-liquid-light font-normal text-[16px] leading-5">
|
||||||
|
{v.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{i + 1 != items.length && (
|
||||||
|
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { FC, Fragment, useEffect } from 'react';
|
import { FC } from 'react';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|
||||||
import { fetchNewMissions } from '../../../redux/slices/missions';
|
|
||||||
|
|
||||||
export const MissionsRightPanel: FC = () => {
|
export const MissionsRightPanel: FC = () => {
|
||||||
const items = [
|
const items = [
|
||||||
@@ -26,61 +24,30 @@ export const MissionsRightPanel: FC = () => {
|
|||||||
tags: ['matrix', 'geometry', 'simulation'],
|
tags: ['matrix', 'geometry', 'simulation'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const missions = useAppSelector((state) => state.missions.newMissions);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchNewMissions({ pageSize: 10 }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getDaysAgo = (dateString: string) => {
|
|
||||||
const updatedDate = new Date(dateString);
|
|
||||||
const today = new Date();
|
|
||||||
// Сбрасываем время для точного сравнения по дням
|
|
||||||
updatedDate.setHours(0, 0, 0, 0);
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const diffTime = today.getTime() - updatedDate.getTime();
|
|
||||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) return 'Сегодня';
|
|
||||||
if (diffDays === 1) return '1 день назад';
|
|
||||||
return `${diffDays} дня назад`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calcDifficulty = (d: number) => {
|
|
||||||
if (d <= 1200) return 'Easy';
|
|
||||||
if (d <= 2000) return 'Medium';
|
|
||||||
return 'Hard';
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[10px] flex flex-col">
|
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[10px] flex flex-col">
|
||||||
<div className="text-liquid-white font-bold text-[18px]">
|
<div className="text-liquid-white font-bold text-[18px]">
|
||||||
Новые задачи
|
Новые задачи
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{missions.map((v, i) => {
|
{items.map((v, i) => {
|
||||||
return (
|
return (
|
||||||
<Fragment key={i}>
|
<>
|
||||||
{
|
{
|
||||||
<div className="text-liquid-light text-[16px]">
|
<div className="text-liquid-light text-[16px]">
|
||||||
<div className="font-bold text-liquid-white">
|
<div className="font-bold ">{v.name}</div>
|
||||||
{v.name}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'',
|
'',
|
||||||
calcDifficulty(v.difficulty) ==
|
v.difficulty == 'Hard' &&
|
||||||
'Hard' && 'text-liquid-red',
|
'text-liquid-red',
|
||||||
calcDifficulty(v.difficulty) ==
|
v.difficulty == 'Medium' &&
|
||||||
'Medium' && 'text-liquid-orange',
|
'text-liquid-orange',
|
||||||
calcDifficulty(v.difficulty) ==
|
v.difficulty == 'Easy' &&
|
||||||
'Easy' && 'text-liquid-green',
|
'text-liquid-green',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{calcDifficulty(v.difficulty)}
|
{v.difficulty}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-[10px] overflow-hidden">
|
<div className="flex gap-[10px] overflow-hidden">
|
||||||
{v.tags.slice(0, 2).map((v, i) => (
|
{v.tags.slice(0, 2).map((v, i) => (
|
||||||
@@ -88,15 +55,12 @@ export const MissionsRightPanel: FC = () => {
|
|||||||
))}
|
))}
|
||||||
{v.tags.length > 2 && '...'}
|
{v.tags.length > 2 && '...'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-liquid-light">
|
|
||||||
{getDaysAgo(v.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{i + 1 != items.length && (
|
{i + 1 != items.length && (
|
||||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { FC, Fragment, useEffect, useState } from 'react';
|
|
||||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
|
||||||
import { fetchGroupById, GroupMember } from '../../../../redux/slices/groups';
|
|
||||||
import { Edit } from '../../../../assets/icons/input';
|
|
||||||
import { Logout } from '../../../../assets/icons/group';
|
|
||||||
import ModalLeave from './ModalLeave';
|
|
||||||
import ModalUpdate from './ModalUpdate';
|
|
||||||
|
|
||||||
export const GroupRightPanel: FC = () => {
|
|
||||||
const groupId = Number(useParams<{ groupId: string }>().groupId);
|
|
||||||
if (!groupId) {
|
|
||||||
return <Navigate to="/home/groups" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [user, setUser] = useState<GroupMember | undefined>();
|
|
||||||
const [myUser, setMyUser] = useState<GroupMember | undefined>();
|
|
||||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
|
||||||
const [modalLeaveActive, setModalLeaveActive] = useState<boolean>(false);
|
|
||||||
const [modalUpdateActive, setModalUpdateActive] = useState<boolean>(false);
|
|
||||||
const { id: userId } = useAppSelector((state) => state.auth);
|
|
||||||
|
|
||||||
const { group } = useAppSelector((state) => state.groups.fetchGroupById);
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchGroupById(groupId));
|
|
||||||
}, [groupId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!group) return;
|
|
||||||
|
|
||||||
const isUserAdmin =
|
|
||||||
group.members?.some(
|
|
||||||
(m) =>
|
|
||||||
Number(m.userId) === Number(userId) &&
|
|
||||||
m.role.includes('Administrator'),
|
|
||||||
) || false;
|
|
||||||
|
|
||||||
setIsAdmin(isUserAdmin);
|
|
||||||
|
|
||||||
const member = group.members?.find(
|
|
||||||
(m) => Number(m.userId) === Number(userId),
|
|
||||||
);
|
|
||||||
|
|
||||||
setMyUser(member);
|
|
||||||
}, [group, userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[5px] flex flex-col">
|
|
||||||
<div className="text-liquid-white font-bold text-[18px]">
|
|
||||||
Пользователи
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{group?.members.map((v, i) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{
|
|
||||||
<div
|
|
||||||
className="text-liquid-light text-[16px] grid grid-cols-[40px,1fr] gap-[10px] items-center cursor-pointer hover:bg-liquid-lighter transition-all duration-300 rounded-[10px] p-[5px] group"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(
|
|
||||||
`/home/account/missions?username=${v.username}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="h-[40px] w-[40px] rounded-[10px] bg-[#D9D9D9]"></div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="text-liquid-white font-bold text-[16px] leading-5">
|
|
||||||
{v.username}
|
|
||||||
</div>
|
|
||||||
<div className="text-liquid-light font-normal text-[16px] leading-5">
|
|
||||||
{v.role +
|
|
||||||
(Number(userId) == v.userId
|
|
||||||
? ' (Вы)'
|
|
||||||
: '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(isAdmin || Number(userId) == v.userId) &&
|
|
||||||
!v.role.includes('Creator') && (
|
|
||||||
<div
|
|
||||||
className="h-[34px] w-[34px] absolute right-[34px] opacity-0 group-hover:opacity-100 transition-all duration-300 hover:bg-liquid-light rounded-[10px] p-[5px] active:scale-90"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (
|
|
||||||
Number(userId) == v.userId
|
|
||||||
) {
|
|
||||||
setModalLeaveActive(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isAdmin) {
|
|
||||||
setUser(v);
|
|
||||||
setModalUpdateActive(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Number(userId) == v.userId ? (
|
|
||||||
<img src={Logout} />
|
|
||||||
) : isAdmin ? (
|
|
||||||
<img src={Edit} />
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{i + 1 != group?.members.length && (
|
|
||||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<ModalLeave
|
|
||||||
groupId={groupId}
|
|
||||||
groupName={group?.name}
|
|
||||||
userId={Number(userId)}
|
|
||||||
active={modalLeaveActive}
|
|
||||||
setActive={setModalLeaveActive}
|
|
||||||
/>
|
|
||||||
<ModalUpdate
|
|
||||||
groupId={groupId}
|
|
||||||
groupName={group?.name}
|
|
||||||
userId={Number(userId)}
|
|
||||||
user={user}
|
|
||||||
adminUser={myUser}
|
|
||||||
active={modalUpdateActive}
|
|
||||||
setActive={setModalUpdateActive}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { Modal } from '../../../../components/modal/Modal';
|
|
||||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
|
||||||
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
|
||||||
import {
|
|
||||||
removeGroupMember,
|
|
||||||
setGroupsStatus,
|
|
||||||
} from '../../../../redux/slices/groups';
|
|
||||||
import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
interface ModalLeaveProps {
|
|
||||||
active: boolean;
|
|
||||||
setActive: (value: boolean) => void;
|
|
||||||
groupId: number;
|
|
||||||
groupName?: string;
|
|
||||||
userId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalLeave: FC<ModalLeaveProps> = ({
|
|
||||||
active,
|
|
||||||
setActive,
|
|
||||||
groupName,
|
|
||||||
groupId,
|
|
||||||
userId,
|
|
||||||
}) => {
|
|
||||||
const statusLeave = useAppSelector(
|
|
||||||
(state) => state.groups.removeGroupMember.status,
|
|
||||||
);
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [modalConfirmActive, setModalConfirmActive] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (statusLeave == 'successful') {
|
|
||||||
dispatch(
|
|
||||||
setGroupsStatus({ key: 'removeGroupMember', status: 'idle' }),
|
|
||||||
);
|
|
||||||
setActive(false);
|
|
||||||
navigate('/home/groups');
|
|
||||||
}
|
|
||||||
}, [statusLeave]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
|
||||||
onOpenChange={setActive}
|
|
||||||
open={active}
|
|
||||||
backdrop="blur"
|
|
||||||
>
|
|
||||||
<div className="w-[500px]">
|
|
||||||
<div className="font-bold text-[30px]">
|
|
||||||
Вы действительно хотите покинуть группу:
|
|
||||||
</div>
|
|
||||||
<div className="font-bold text-[20px] mt-[20px]">
|
|
||||||
"{groupName}" #{groupId}?
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
setModalConfirmActive(true);
|
|
||||||
}}
|
|
||||||
text={
|
|
||||||
statusLeave == 'loading'
|
|
||||||
? 'Покинуть...'
|
|
||||||
: 'Покинуть'
|
|
||||||
}
|
|
||||||
disabled={statusLeave == 'loading'}
|
|
||||||
color="error"
|
|
||||||
/>
|
|
||||||
<SecondaryButton
|
|
||||||
onClick={() => {
|
|
||||||
setActive(false);
|
|
||||||
}}
|
|
||||||
text="Отмена"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ConfirmModal
|
|
||||||
className=" fixed top-0 left-0"
|
|
||||||
active={modalConfirmActive}
|
|
||||||
setActive={setModalConfirmActive}
|
|
||||||
title="Подтвердите действия"
|
|
||||||
message="Вы действительно хотите покинуть группу?"
|
|
||||||
confirmColor="error"
|
|
||||||
confirmText="Покинуть"
|
|
||||||
onConfirmClick={() => {
|
|
||||||
dispatch(
|
|
||||||
removeGroupMember({
|
|
||||||
groupId,
|
|
||||||
memberId: userId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalLeave;
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { Modal } from '../../../../components/modal/Modal';
|
|
||||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
|
||||||
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
|
||||||
import {
|
|
||||||
addGroupMember,
|
|
||||||
fetchGroupById,
|
|
||||||
GroupMember,
|
|
||||||
removeGroupMember,
|
|
||||||
setGroupsStatus,
|
|
||||||
} from '../../../../redux/slices/groups';
|
|
||||||
import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
|
||||||
import {
|
|
||||||
DropDownList,
|
|
||||||
DropDownListItem,
|
|
||||||
} from '../../../../components/input/DropDownList';
|
|
||||||
import { ReverseButton } from '../../../../components/button/ReverseButton';
|
|
||||||
|
|
||||||
interface ModalUpdateProps {
|
|
||||||
active: boolean;
|
|
||||||
setActive: (value: boolean) => void;
|
|
||||||
groupId: number;
|
|
||||||
userId: number;
|
|
||||||
user?: GroupMember;
|
|
||||||
adminUser?: GroupMember;
|
|
||||||
groupName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalUpdate: FC<ModalUpdateProps> = ({
|
|
||||||
active,
|
|
||||||
setActive,
|
|
||||||
groupId,
|
|
||||||
user,
|
|
||||||
adminUser,
|
|
||||||
groupName,
|
|
||||||
}) => {
|
|
||||||
const statusLeave = useAppSelector(
|
|
||||||
(state) => state.groups.removeGroupMember.status,
|
|
||||||
);
|
|
||||||
const statusUpdate = useAppSelector(
|
|
||||||
(state) => state.groups.addGroupMember.status,
|
|
||||||
);
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [modalConfirmDeleteUser, setModalConfirmDeleteUser] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
const [modalConfirmRoleActive, setModalConfirmRoleActive] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
const [userRole, setUserRole] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (active) {
|
|
||||||
}
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (statusLeave == 'successful') {
|
|
||||||
dispatch(
|
|
||||||
setGroupsStatus({ key: 'removeGroupMember', status: 'idle' }),
|
|
||||||
);
|
|
||||||
dispatch(fetchGroupById(groupId));
|
|
||||||
setActive(false);
|
|
||||||
}
|
|
||||||
}, [statusLeave]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (statusUpdate == 'successful') {
|
|
||||||
dispatch(
|
|
||||||
setGroupsStatus({ key: 'addGroupMember', status: 'idle' }),
|
|
||||||
);
|
|
||||||
dispatch(fetchGroupById(groupId));
|
|
||||||
setActive(false);
|
|
||||||
}
|
|
||||||
}, [statusUpdate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
setUserRole(
|
|
||||||
user?.role.includes('Creator') ? 'Creator' : user?.role,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const roles: DropDownListItem[] = [
|
|
||||||
{ value: 'Member', text: 'Участник' },
|
|
||||||
{ value: 'Administrator', text: 'Администратор' },
|
|
||||||
];
|
|
||||||
if (adminUser?.role.includes('Creator')) {
|
|
||||||
roles.push({ value: 'Creator', text: 'Владелец' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const casrtRoleMap: Record<'Member' | 'Administrator' | 'Creator', string> =
|
|
||||||
{
|
|
||||||
Member: 'Участник',
|
|
||||||
Administrator: 'Администратор',
|
|
||||||
Creator: 'Владелец',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
|
||||||
onOpenChange={setActive}
|
|
||||||
open={active}
|
|
||||||
backdrop="blur"
|
|
||||||
>
|
|
||||||
<div className="w-[500px]">
|
|
||||||
<div className="font-bold text-[30px]">
|
|
||||||
Управление участниками группы:
|
|
||||||
</div>
|
|
||||||
<div className="font-bold text-[20px]">
|
|
||||||
"{groupName}" #{groupId}
|
|
||||||
</div>
|
|
||||||
<div className="my-[5px]">Пользователь: {user?.username}</div>
|
|
||||||
<div>
|
|
||||||
Текущая роль:{' '}
|
|
||||||
{casrtRoleMap[user?.role as keyof typeof casrtRoleMap]}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row w-full items-center justify-between mt-[20px] gap-[20px]">
|
|
||||||
<div>
|
|
||||||
<DropDownList
|
|
||||||
defaultState={{
|
|
||||||
value: userRole,
|
|
||||||
text: casrtRoleMap[
|
|
||||||
userRole as keyof typeof casrtRoleMap
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
weight="w-[230px]"
|
|
||||||
items={roles}
|
|
||||||
onChange={(v) => {
|
|
||||||
setUserRole(v);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
setModalConfirmRoleActive(true);
|
|
||||||
}}
|
|
||||||
text={
|
|
||||||
statusUpdate == 'loading'
|
|
||||||
? 'Назначить...'
|
|
||||||
: 'Назначить'
|
|
||||||
}
|
|
||||||
disabled={statusUpdate == 'loading'}
|
|
||||||
color="secondary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row w-full items-center justify-between mt-[20px] gap-[20px]">
|
|
||||||
<div className="font-bold text-[24px]">
|
|
||||||
Исключить пользователя?
|
|
||||||
</div>
|
|
||||||
<ReverseButton
|
|
||||||
onClick={() => {
|
|
||||||
setModalConfirmDeleteUser(true);
|
|
||||||
}}
|
|
||||||
text={
|
|
||||||
statusLeave == 'loading'
|
|
||||||
? 'Исключить...'
|
|
||||||
: 'Исключить'
|
|
||||||
}
|
|
||||||
disabled={statusLeave == 'loading'}
|
|
||||||
color="error"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
|
||||||
<SecondaryButton
|
|
||||||
onClick={() => {
|
|
||||||
setActive(false);
|
|
||||||
}}
|
|
||||||
text="Отмена"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
className=" fixed top-0 left-0"
|
|
||||||
active={modalConfirmDeleteUser}
|
|
||||||
setActive={setModalConfirmDeleteUser}
|
|
||||||
title="Подтвердите действия"
|
|
||||||
message={`Вы действительно хотите исключить пользователя ${user?.username}?`}
|
|
||||||
confirmColor="error"
|
|
||||||
confirmText="Исключить"
|
|
||||||
onConfirmClick={() => {
|
|
||||||
if (user) {
|
|
||||||
dispatch(
|
|
||||||
removeGroupMember({
|
|
||||||
groupId,
|
|
||||||
memberId: user.userId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
className=" fixed top-0 left-0"
|
|
||||||
active={modalConfirmRoleActive}
|
|
||||||
setActive={setModalConfirmRoleActive}
|
|
||||||
title="Подтвердите действия"
|
|
||||||
message={`Вы действительно хотите назначить пользователя ${user?.username} в качестве ${userRole}?`}
|
|
||||||
confirmText="Назначить"
|
|
||||||
onConfirmClick={() => {
|
|
||||||
if (user) {
|
|
||||||
dispatch(
|
|
||||||
addGroupMember({
|
|
||||||
groupId,
|
|
||||||
userId: user.userId,
|
|
||||||
role:
|
|
||||||
userRole == 'Creator'
|
|
||||||
? 'Administrator, Creator'
|
|
||||||
: userRole,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalUpdate;
|
|
||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { upload } from '../../../assets/icons/input';
|
import { upload } from '../../../assets/icons/input';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { DropDownList } from '../../../components/input/DropDownList';
|
import { DropDownList } from '../../../components/drop-down-list/DropDownList';
|
||||||
|
|
||||||
const languageMap: Record<string, string> = {
|
const languageMap: Record<string, string> = {
|
||||||
c: 'cpp',
|
c: 'cpp',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
chevroneLeft,
|
chevroneLeft,
|
||||||
chevroneRight,
|
chevroneRight,
|
||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
} from '../../../assets/icons/header';
|
} from '../../../assets/icons/header';
|
||||||
import { Logo } from '../../../assets/logos';
|
import { Logo } from '../../../assets/logos';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|
||||||
import { useQuery } from '../../../hooks/useQuery';
|
|
||||||
import { fetchMyAttemptsInContest } from '../../../redux/slices/contests';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
@@ -17,57 +14,6 @@ interface HeaderProps {
|
|||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
|
const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const query = useQuery();
|
|
||||||
const contestId = Number(query.get('contestId') ?? undefined);
|
|
||||||
|
|
||||||
const attempt = useAppSelector(
|
|
||||||
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [time, setTime] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contestId) return;
|
|
||||||
const calc = (time: string) => {
|
|
||||||
return time != '' && new Date() <= new Date(time);
|
|
||||||
};
|
|
||||||
if (attempt) {
|
|
||||||
if (!calc(attempt.expiresAt)) {
|
|
||||||
navigate('/home/contests');
|
|
||||||
}
|
|
||||||
const diffMs =
|
|
||||||
new Date(attempt.expiresAt).getTime() - new Date().getTime();
|
|
||||||
|
|
||||||
const seconds = Math.floor(diffMs / 1000);
|
|
||||||
|
|
||||||
setTime(seconds);
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setTime((t) => {
|
|
||||||
if (t <= 1) {
|
|
||||||
clearInterval(interval);
|
|
||||||
navigate('/home/contests');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return t - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [attempt]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (contestId) {
|
|
||||||
dispatch(fetchMyAttemptsInContest(contestId));
|
|
||||||
}
|
|
||||||
}, [contestId]);
|
|
||||||
|
|
||||||
const minutes = String(Math.floor(time / 60)).padStart(2, '0');
|
|
||||||
const seconds = String(time % 60).padStart(2, '0');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
||||||
<img
|
<img
|
||||||
@@ -113,9 +59,6 @@ const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!!contestId && !!attempt && (
|
|
||||||
<div className="">{`${minutes}:${seconds}`}</div>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,16 +19,9 @@ interface MissionSubmissionsProps {
|
|||||||
contestId?: number;
|
contestId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MissionSubmissions: FC<MissionSubmissionsProps> = ({
|
const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId, contestId }) => {
|
||||||
missionId,
|
|
||||||
contestId,
|
|
||||||
}) => {
|
|
||||||
const submissions = useAppSelector(
|
const submissions = useAppSelector(
|
||||||
(state) => state.submin.submitsById[missionId] || [],
|
(state) => state.submin.submitsById[missionId] || []
|
||||||
);
|
|
||||||
|
|
||||||
const attempt = useAppSelector(
|
|
||||||
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkStatus = (status: string) => {
|
const checkStatus = (status: string) => {
|
||||||
@@ -39,9 +32,7 @@ const MissionSubmissions: FC<MissionSubmissionsProps> = ({
|
|||||||
|
|
||||||
// Если contestId передан, фильтруем по нему, иначе показываем все
|
// Если contestId передан, фильтруем по нему, иначе показываем все
|
||||||
const filteredSubmissions = contestId
|
const filteredSubmissions = contestId
|
||||||
? attempt?.submissions?.filter(
|
? submissions.filter((v) => v.contestId === contestId)
|
||||||
(v) => v.solution.missionId == missionId,
|
|
||||||
) ?? []
|
|
||||||
: submissions;
|
: submissions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user