Compare commits

..

5 Commits

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

View File

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

2
.gitignore vendored
View File

@@ -11,7 +11,6 @@ node_modules
dist dist
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
View File

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

View File

@@ -2,9 +2,9 @@
<html lang="en"> <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">

View File

@@ -1,5 +0,0 @@
<svg width="62" height="56" viewBox="0 0 62 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.7765 44.9299V16.6675C16.7765 7.46416 9.26353 0 0 0V44.9299C0.00729412 45.9589 0.153177 50.4447 3.64706 53.626C4.92353 54.7927 6.27294 55.4377 7.29412 55.8H23.3412C22.4221 55.4377 21.2113 54.7927 20.0588 53.626C16.9151 50.4447 16.7838 45.9589 16.7765 44.9299Z" fill="#0C8092"/>
<path d="M35.7412 44.9299V27.5377C35.7412 18.3343 28.2282 10.8701 18.9647 10.8701V44.9299C18.972 45.9589 19.1179 50.4447 22.6118 53.626C23.8882 54.7927 25.2376 55.4377 26.2588 55.8H42.3059C41.3868 55.4377 40.176 54.7927 39.0235 53.626C35.8798 50.4447 35.7485 45.9589 35.7412 44.9299Z" fill="#16A7C6"/>
<path d="M58.3529 53.626C54.8591 50.4447 54.7132 45.9589 54.7059 44.9299V39.1325C54.7059 29.9291 47.1929 22.4649 37.9294 22.4649V44.9299C37.9367 45.9589 38.0826 50.4447 41.5765 53.626C42.8529 54.7927 44.2024 55.4377 45.2235 55.8H62C60.9788 55.4377 59.6294 54.7927 58.3529 53.626Z" fill="#00DBD9"/>
</svg>

Before

Width:  |  Height:  |  Size: 993 B

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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 };

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.79562 4H5.1075C4.54856 4 4.01251 4.21071 3.61727 4.58579C3.22204 4.96086 3 5.46957 3 6V18C3 18.5304 3.22204 19.0391 3.61727 19.4142C4.01251 19.7893 4.54856 20 5.1075 20H8.79562M9.0575 12.0007L21 12.0007M21 12.0007L16.4368 7.42931M21 12.0007L16.4368 16.5722" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 456 B

View File

@@ -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 };

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4045 11.5763L6.32338 11.5763M5.06681 2.74198L20.8828 10.4103C21.8567 10.8826 21.8567 12.2701 20.8828 12.7423L5.06681 20.4107C3.98332 20.936 2.83166 19.8284 3.3143 18.7253L6.21474 12.0957C6.3596 11.7646 6.3596 11.388 6.21474 11.0569L3.3143 4.42737C2.83167 3.32419 3.98332 2.21665 5.06681 2.74198Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 472 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6.17647H20M9 3H15M10 16.7647V10.4118M14 16.7647V10.4118M15.5 21H8.5C7.39543 21 6.5 20.0519 6.5 18.8824L6.0434 7.27937C6.01973 6.67783 6.47392 6.17647 7.04253 6.17647H16.9575C17.5261 6.17647 17.9803 6.67783 17.9566 7.27937L17.5 18.8824C17.5 20.0519 16.6046 21 15.5 21Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 467 B

View File

@@ -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],

View File

@@ -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);
}} }}
> >

View File

@@ -1,161 +0,0 @@
import React, { useState } from 'react';
import { cn } from '../../lib/cn';
import { useClickOutside } from '../../hooks/useClickOutside';
import { iconFilter, iconFilterActive } from '../../assets/icons/filters';
import { Input } from '../input/Input';
import { PrimaryButton } from '../button/PrimaryButton';
import { toastError } from '../../lib/toastNotification';
import { SecondaryButton } from '../button/SecondaryButton';
interface TagFilterProps {
disabled?: boolean;
className?: string;
onChange: (items: string[]) => void;
}
export const TagFilter: React.FC<TagFilterProps> = ({
disabled = false,
className = '',
onChange,
}) => {
const [active, setActive] = React.useState(false);
const [tagInput, setTagInput] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
// ==========================
// Теги
// ==========================
const addTag = () => {
if (tags.length > 30) {
setTagInput('');
toastError('Нельзя добавить больше 30 тегов');
return;
}
const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]);
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove));
};
const resetTags = () => {
setTags([]);
};
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => {
setActive(false);
});
React.useEffect(() => {
onChange(tags);
}, [tags]);
return (
<div className={cn('relative', className)} ref={ref}>
<div
className={cn(
'items-center h-[40px] rounded-full bg-liquid-lighter w-[40px] flex',
'text-[18px] font-bold cursor-pointer select-none',
'overflow-hidden',
(active || tags.length > 0) &&
'w-fit border-liquid-brightmain border-[1px] border-solid',
)}
onClick={() => {
if (!disabled) setActive(!active);
}}
>
<div className="text-liquid-brightmain pl-[42px] pr-[16px] w-fit">
{tags.length}
</div>
</div>
{/* Filter icons */}
<img
src={iconFilter}
className={cn(
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none',
)}
/>
<img
src={iconFilterActive}
className={cn(
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
(active || tags.length > 0) && 'opacity-100',
)}
/>
{/* Dropdown */}
<div
className={cn(
'absolute rounded-[10px] bg-liquid-background w-[590px] left-0 top-[48px] z-50 transition-all duration-300',
'grid overflow-hidden border-liquid-lighter border-[3px] border-solid',
active
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden p-[8px]">
<div className="overflow-y-scroll min-h-[130px] thin-scrollbar grid gap-[20px]">
{/* Теги */}
<div className="">
<div className="grid grid-cols-[1fr,140px,130px] items-end gap-2">
<Input
name="articleTag"
autocomplete="articleTag"
className="max-w-[600px] "
type="text"
label="Теги"
onChange={setTagInput}
defaultState={tagInput}
placeholder="arrays"
onKeyDown={(e) => {
if (e.key === 'Enter') addTag();
}}
/>
<PrimaryButton
onClick={addTag}
text="Добавить"
className="h-[40px] w-[140px]"
/>
<SecondaryButton
onClick={resetTags}
text="Сбросить"
className="h-[40px] w-[130px]"
/>
</div>
<div className="flex flex-wrap gap-[10px] mt-2 ">
{tags.length == 0 ? (
<div className="text-liquid-brightmain flex items-center justify-center w-full h-[50px]">
Вы еще не добавили ни одного тега
</div>
) : (
tags.map((tag) => (
<div
key={tag}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
>
<span>{tag}</span>
<button
onClick={() => removeTag(tag)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,35 +0,0 @@
// DateInput.tsx
import React from 'react';
interface DateInputProps {
label?: string;
value?: string;
defaultValue?: string;
onChange: (value: string) => void;
className?: string;
}
const DateInput: React.FC<DateInputProps> = ({
label = 'Дата',
value,
defaultValue,
onChange,
className = '',
}) => {
return (
<div className={`flex flex-col gap-1 ${className}`}>
<label className="block text-sm font-medium text-liquid-white">
{label}
</label>
<input
type="datetime-local"
value={value}
defaultValue={defaultValue}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-[10px] sm:text-sm outline-none p-[8px] text-liquid-white cursor-text bg-liquid-lighter"
/>
</div>
);
};
export default DateInput;

View File

@@ -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',
)} )}
> >

View File

@@ -1,73 +0,0 @@
import React from 'react';
import { cn } from '../../lib/cn';
interface NumberInputProps {
name?: string;
error?: string;
disabled?: boolean;
required?: boolean;
label?: string;
placeholder?: string;
className?: string;
minValue?: number;
maxValue?: number;
onChange: (state: number) => void;
defaultState?: number;
}
export const NumberInput: React.FC<NumberInputProps> = ({
error = '',
// disabled = false,
// required = false,
label = '',
placeholder = '',
className = '',
onChange,
defaultState = 0,
minValue = 0,
maxValue = 365 * 24,
name = '',
}) => {
const [value, setValue] = React.useState<number>(defaultState);
React.useEffect(() => onChange(value), [value]);
React.useEffect(() => setValue(defaultState), [defaultState]);
return (
<div className={cn('relative', className)}>
<div
className={cn(
'text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all',
label == '' && 'h-0 mb-0',
)}
>
{label}
</div>
<div className="relative pr-[5px]">
<input
className={cn(
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] pr-[8px] py-[8px] placeholder:text-liquid-light',
)}
value={value}
name={name}
type={'number'}
min={minValue}
max={maxValue}
placeholder={placeholder}
onChange={(e) => {
setValue(Number(e.target.value));
}}
/>
</div>
<div
className={cn(
'text-liquid-red text-[14px] h-auto text-right mt-[5px] whitespace-pre-line ',
error == '' && 'h-0 mt-0',
)}
>
{error}
</div>
</div>
);
};

View File

@@ -1,62 +0,0 @@
import { FC } from 'react';
import { Modal } from './Modal';
import { PrimaryButton } from '../../components/button/PrimaryButton';
import { SecondaryButton } from '../../components/button/SecondaryButton';
import { cn } from '../../lib/cn';
interface ConfirmModalProps {
active: boolean;
setActive: (value: boolean) => void;
onConfirmClick: () => void;
title?: string;
message?: string;
confirmColor?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
confirmText?: string;
className?: string;
}
const ConfirmModal: FC<ConfirmModalProps> = ({
active,
setActive,
onConfirmClick,
title,
message,
confirmColor = 'secondary',
confirmText = 'Ок',
className,
}) => {
return (
<Modal
className={cn(
'bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white',
className,
)}
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]">
<div className="font-bold text-[30px]">{title}</div>
<div className="font-bold text-[20px] mt-[20px]">{message}</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
onConfirmClick();
setActive(false);
}}
text={confirmText}
color={confirmColor}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ConfirmModal;

View File

@@ -47,7 +47,7 @@ export const Modal: React.FC<ModalProps> = ({
exit={modalbgVariants.closed} 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 &&

View File

@@ -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]"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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);
});
});
}); });
}, },
}); });

View File

@@ -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;

View File

@@ -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);
});
});
}, },
); );
}, },

View File

@@ -1,203 +0,0 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
import { toastError } from '../../lib/toastNotification';
// =========================================
// Типы
// =========================================
export type Status = 'idle' | 'loading' | 'successful' | 'failed';
export interface ChatMessage {
id: number;
groupId: number;
authorId: number;
authorUsername: string;
content: string;
createdAt: string;
}
interface FetchMessagesParams {
groupId: number;
limit?: number;
afterMessageId?: number;
timeoutSeconds?: number;
}
interface SendMessageParams {
groupId: number;
content: string;
}
// =========================================
// State
// =========================================
interface GroupChatState {
messages: Record<number, ChatMessage[]>; // по группам
lastMessage: Record<number, number>;
fetchMessages: {
status: Status;
error?: string;
};
sendMessage: {
status: Status;
error?: string;
};
}
const initialState: GroupChatState = {
messages: {},
lastMessage: {},
fetchMessages: {
status: 'idle',
error: undefined,
},
sendMessage: {
status: 'idle',
error: undefined,
},
};
// =========================================
// Thunks
// =========================================
// Получение сообщений
export const fetchGroupMessages = createAsyncThunk(
'groupChat/fetchGroupMessages',
async (params: FetchMessagesParams, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${params.groupId}/chat`, {
params: {
limit: params.limit,
afterMessageId: params.afterMessageId,
timeoutSeconds: params.timeoutSeconds,
},
});
return {
groupId: params.groupId,
messages: response.data as ChatMessage[],
};
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
);
// Отправка
export const sendGroupMessage = createAsyncThunk(
'groupChat/sendGroupMessage',
async ({ groupId, content }: SendMessageParams, { rejectWithValue }) => {
try {
const response = await axios.post(`/groups/${groupId}/chat`, {
content,
});
return response.data as ChatMessage;
} catch (err: any) {
return rejectWithValue(err.response?.data);
}
},
);
// =========================================
// Slice
// =========================================
const groupChatSlice = createSlice({
name: 'groupChat',
initialState,
reducers: {
clearChat(state, action: PayloadAction<number>) {
delete state.messages[action.payload];
},
setGroupChatStatus: (
state,
action: PayloadAction<{
key: keyof GroupChatState;
status: Status;
}>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// fetch messages
builder.addCase(fetchGroupMessages.pending, (state) => {
state.fetchMessages.status = 'loading';
});
builder.addCase(
fetchGroupMessages.fulfilled,
(
state,
action: PayloadAction<{
groupId: number;
messages: ChatMessage[];
}>,
) => {
const { groupId, messages } = action.payload;
const existing = state.messages[groupId] || [];
const ids = new Set(existing.map((m) => m.id));
const filtered = messages.filter((m) => !ids.has(m.id));
state.messages[groupId] = [...existing, ...filtered].sort(
(a, b) => a.id - b.id,
);
if (state.messages[groupId].length) {
state.lastMessage[groupId] =
state.messages[groupId][
state.messages[groupId].length - 1
].id;
}
state.fetchMessages.status = 'successful';
},
);
builder.addCase(fetchGroupMessages.rejected, (state, action: any) => {
state.fetchMessages.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
// send message
builder.addCase(sendGroupMessage.pending, (state) => {
state.sendMessage.status = 'loading';
});
builder.addCase(
sendGroupMessage.fulfilled,
(state, action: PayloadAction<ChatMessage>) => {
const msg = action.payload;
if (!state.messages[msg.groupId])
state.messages[msg.groupId] = [];
state.messages[msg.groupId].push(msg);
state.sendMessage.status = 'successful';
},
);
builder.addCase(sendGroupMessage.rejected, (state, action: any) => {
state.sendMessage.status = 'failed';
const errors = action.payload.errors as Record<string, string[]>;
Object.values(errors).forEach((messages) => {
messages.forEach((msg) => {
toastError(msg);
});
});
});
},
});
export const { clearChat, setGroupChatStatus } = groupChatSlice.actions;
export const groupChatReducer = groupChatSlice.reducer;

View File

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

View File

@@ -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);
});
});
}); });
}, },
}); });

View File

@@ -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);
});
});
}, },
); );
}, },

View File

@@ -1,395 +0,0 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
// Основной профиль
export interface ProfileIdentity {
userId: number;
username: string;
email: string;
createdAt: string;
}
export interface ProfileSolutions {
totalSolved: number;
solvedLast7Days: number;
}
export interface ProfileContestsInfo {
totalParticipations: number;
participationsLast7Days: number;
}
export interface ProfileCreationStats {
missions: { total: number; last7Days: number };
contests: { total: number; last7Days: number };
articles: { total: number; last7Days: number };
}
export interface ProfileResponse {
identity: ProfileIdentity;
solutions: ProfileSolutions;
contests: ProfileContestsInfo;
creation: ProfileCreationStats;
}
// Missions
export interface MissionsBucket {
key: string;
label: string;
solved: number;
total: number;
}
export interface MissionItem {
missionId: number;
missionName: string;
difficultyLabel: string;
difficultyValue: number;
createdAt: string;
timeLimitMilliseconds: number;
memoryLimitBytes: number;
}
export interface MissionsResponse {
summary: {
total: MissionsBucket;
buckets: MissionsBucket[];
};
recent: {
items: MissionItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
};
authored: {
items: MissionItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
};
}
// Articles
export interface ProfileArticleItem {
articleId: number;
title: string;
createdAt: string;
updatedAt: string;
}
export interface ProfileArticlesResponse {
articles: {
items: ProfileArticleItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
};
}
// Contests
export interface ContestItem {
contestId: number;
name: string;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: 'None' | 'Participant' | 'Organizer';
}
export interface ContestsList {
items: ContestItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
}
export interface ProfileContestsResponse {
upcoming: ContestsList;
past: ContestsList;
mine: ContestsList;
}
// =====================
// Состояние
// =====================
interface ProfileState {
profile: {
data?: ProfileResponse;
status: Status;
error?: string;
};
missions: {
data?: MissionsResponse;
status: Status;
error?: string;
};
articles: {
data?: ProfileArticlesResponse;
status: Status;
error?: string;
};
contests: {
data?: ProfileContestsResponse;
status: Status;
error?: string;
};
}
const initialState: ProfileState = {
profile: {
data: undefined,
status: 'idle',
error: undefined,
},
missions: {
data: undefined,
status: 'idle',
error: undefined,
},
articles: {
data: undefined,
status: 'idle',
error: undefined,
},
contests: {
data: undefined,
status: 'idle',
error: undefined,
},
};
// =====================
// Async Thunks
// =====================
// Основной профиль
export const fetchProfile = createAsyncThunk(
'profile/fetch',
async (username: string, { rejectWithValue }) => {
try {
const res = await axios.get<ProfileResponse>(
`/profile/${username}`,
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки профиля',
);
}
},
);
// Missions
export const fetchProfileMissions = createAsyncThunk(
'profile/fetchMissions',
async (
{
username,
recentPage = 0,
recentPageSize = 100,
authoredPage = 0,
authoredPageSize = 100,
}: {
username: string;
recentPage?: number;
recentPageSize?: number;
authoredPage?: number;
authoredPageSize?: number;
},
{ rejectWithValue },
) => {
try {
const res = await axios.get<MissionsResponse>(
`/profile/${username}/missions`,
{
params: {
recentPage,
recentPageSize,
authoredPage,
authoredPageSize,
},
},
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки задач',
);
}
},
);
// Articles
export const fetchProfileArticles = createAsyncThunk(
'profile/fetchArticles',
async (
{
username,
page = 0,
pageSize = 100,
}: { username: string; page?: number; pageSize?: number },
{ rejectWithValue },
) => {
try {
const res = await axios.get<ProfileArticlesResponse>(
`/profile/${username}/articles`,
{ params: { page, pageSize } },
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки статей',
);
}
},
);
// Contests
export const fetchProfileContests = createAsyncThunk(
'profile/fetchContests',
async (
{
username,
upcomingPage = 0,
upcomingPageSize = 100,
pastPage = 0,
pastPageSize = 100,
minePage = 0,
minePageSize = 100,
}: {
username: string;
upcomingPage?: number;
upcomingPageSize?: number;
pastPage?: number;
pastPageSize?: number;
minePage?: number;
minePageSize?: number;
},
{ rejectWithValue },
) => {
try {
const res = await axios.get<ProfileContestsResponse>(
`/profile/${username}/contests`,
{
params: {
upcomingPage,
upcomingPageSize,
pastPage,
pastPageSize,
minePage,
minePageSize,
},
},
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки контестов',
);
}
},
);
// =====================
// Slice
// =====================
const profileSlice = createSlice({
name: 'profile',
initialState,
reducers: {
setProfileStatus: (
state,
action: PayloadAction<{
key: keyof ProfileState;
status: Status;
}>,
) => {
state[action.payload.key].status = action.payload.status;
},
},
extraReducers: (builder) => {
// PROFILE
builder.addCase(fetchProfile.pending, (state) => {
state.profile.status = 'loading';
state.profile.error = undefined;
});
builder.addCase(
fetchProfile.fulfilled,
(state, action: PayloadAction<ProfileResponse>) => {
state.profile.status = 'successful';
state.profile.data = action.payload;
},
);
builder.addCase(fetchProfile.rejected, (state, action: any) => {
state.profile.status = 'failed';
state.profile.error = action.payload;
});
// MISSIONS
builder.addCase(fetchProfileMissions.pending, (state) => {
state.missions.status = 'loading';
state.missions.error = undefined;
});
builder.addCase(
fetchProfileMissions.fulfilled,
(state, action: PayloadAction<MissionsResponse>) => {
state.missions.status = 'successful';
state.missions.data = action.payload;
},
);
builder.addCase(fetchProfileMissions.rejected, (state, action: any) => {
state.missions.status = 'failed';
state.missions.error = action.payload;
});
// ARTICLES
builder.addCase(fetchProfileArticles.pending, (state) => {
state.articles.status = 'loading';
state.articles.error = undefined;
});
builder.addCase(
fetchProfileArticles.fulfilled,
(state, action: PayloadAction<ProfileArticlesResponse>) => {
state.articles.status = 'successful';
state.articles.data = action.payload;
},
);
builder.addCase(fetchProfileArticles.rejected, (state, action: any) => {
state.articles.status = 'failed';
state.articles.error = action.payload;
});
// CONTESTS
builder.addCase(fetchProfileContests.pending, (state) => {
state.contests.status = 'loading';
state.contests.error = undefined;
});
builder.addCase(
fetchProfileContests.fulfilled,
(state, action: PayloadAction<ProfileContestsResponse>) => {
state.contests.status = 'successful';
state.contests.data = action.payload;
},
);
builder.addCase(fetchProfileContests.rejected, (state, action: any) => {
state.contests.status = 'failed';
state.contests.error = action.payload;
});
},
});
export const { setProfileStatus } = profileSlice.actions;
export const profileReducer = profileSlice.reducer;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,
}, },
}); });

View File

@@ -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);
}
} }
} }
}; };

View File

@@ -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">

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -1,146 +0,0 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
import { toastWarning } from '../../../../lib/toastNotification';
import { Edit } from '../../../../assets/icons/input';
export interface EditContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: string;
type: 'first' | 'second';
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatDurationTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const EditContestItem: React.FC<EditContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const started = new Date(startsAt) <= new Date();
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
username == myname
? 'grid-cols-[1fr,150px,190px,110px,130px,24px]'
: 'grid-cols-[1fr,150px,190px,110px,130px]',
)}
onClick={() => {
if (!started && username != myname) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: '/home/account/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{new Date() < new Date(startsAt) ? (
<>{'Не начался'}</>
) : (
<>
{scheduleType == 'AlwaysOpen'
? 'Открыт'
: new Date() < new Date(endsAt)
? 'Идет'
: 'Завершен'}
</>
)}
</div>
{username == myname && (
<img
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
src={Edit}
onClick={(e) => {
e.stopPropagation();
navigate(
`/contest/create?back=/home/account/contests&contestId=${contestId}`,
);
}}
/>
)}
</div>
);
};
export default EditContestItem;

View File

@@ -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;

View File

@@ -1,112 +0,0 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
export interface PastContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: string;
type: 'first' | 'second';
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatDurationTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const PastContestItem: React.FC<PastContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,150px,190px,120px,150px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
const params = new URLSearchParams({
back: '/home/account/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'}
</div>
</div>
);
};
export default PastContestItem;

View File

@@ -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;

View File

@@ -1,160 +0,0 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
import { toastWarning } from '../../../../lib/toastNotification';
export interface UpcoingContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: string;
type: 'first' | 'second';
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatDurationTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
function formatWaitTime(ms: number): string {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const started = new Date(startsAt) <= new Date();
const finished = new Date(endsAt) <= new Date();
const waitTime = !started
? new Date(startsAt).getTime() - new Date().getTime()
: new Date(endsAt).getTime() - new Date().getTime();
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,150px,190px,110px,110px,130px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: '/home/account/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
{!started ? (
<div className="text-center whitespace-pre-line ">
{'До начала\n' + formatWaitTime(waitTime)}
</div>
) : (
!finished && (
<div className="text-center whitespace-pre-line ">
{'До конца\n' + formatWaitTime(waitTime)}
</div>
)
)}
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{new Date() < new Date(startsAt) ? (
<>{'Не начался'}</>
) : (
<>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}</>
)}
</div>
</div>
);
};
export default UpcoingContestItem;

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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]">

View File

@@ -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]">

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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"}
/> />
); );
})} })}

View File

@@ -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>
); );

View File

@@ -1,75 +0,0 @@
import SubmissionItem from './SubmissionItem';
import { FC } from 'react';
import { Attempt } from '../../../redux/slices/contests';
interface SubmissionsBlockProps {
attempt: Attempt;
}
const SubmissionsBlock: FC<SubmissionsBlockProps> = ({ attempt }) => {
const submissions = attempt?.submissions;
const isFinished = new Date(attempt.expiresAt) < new Date();
const checkStatus = (status: string) => {
if (status == 'IncorrectAnswer') return 'wronganswer';
if (status == 'TimeLimitError') return 'timelimit';
return undefined;
};
return (
<div className="mb-[50px]">
<div className="flex items-center justify-center text-liquid-white font-bold text-[20px]">{`Попытка #${attempt.attemptId}`}</div>
{!submissions || submissions.length == 0 ? (
<></>
) : (
<div className="grid grid-cols-7 text-center items-center h-[43px] mb-[10px] text-[16px] font-bold text-liquid-white">
<div>Посылка</div>
<div>Когда</div>
<div>Задача</div>
<div>Язык</div>
<div>Вердикт</div>
<div>Время</div>
<div>Память</div>
</div>
)}
{!submissions || submissions.length == 0 ? (
<div className="text-liquid-brightmain text-[16px] font-medium text-center">
{isFinished
? 'Вы ничего не посылали в этот сеанс'
: 'Вы еще ничего не отсылали'}
</div>
) : (
<>
{submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id ?? 0}
datetime={v.solution.time}
missionId={v.solution.missionId}
language={v.solution.language}
verdict={
v.solution.testerMessage?.includes(
'Compilation failed',
)
? 'Compilation failed'
: v.solution.testerMessage
}
duration={1000}
memory={256 * 1024 * 1024}
type={i % 2 ? 'second' : 'first'}
status={
v.solution.testerMessage == 'All tests passed'
? 'success'
: checkStatus(v.solution.testerErrorCode)
}
/>
))}
</>
)}
<div className="h-[1px] bg-liquid-lighter mt-[50px]"></div>
</div>
);
};
export default SubmissionsBlock;

View File

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

View File

@@ -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"
/> />
</> </>
)} )}

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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]">

View File

@@ -1,246 +0,0 @@
import { cn } from '../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useQuery } from '../../../hooks/useQuery';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { toastWarning } from '../../../lib/toastNotification';
import { useEffect, useState } from 'react';
import {
addOrUpdateContestMember,
fetchParticipatingContests,
} from '../../../redux/slices/contests';
export interface PastContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
type: 'first' | 'second';
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatDurationTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
type Role = 'None' | 'Participant' | 'Organizer';
const PastContestItem: React.FC<PastContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [role, setRole] = useState<Role>('None');
const myname = useAppSelector((state) => state.auth.username);
const userId = useAppSelector((state) => state.auth.id);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const { contests: myContests } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
const { contests: participantContests } = useAppSelector(
(state) => state.contests.fetchParticipating,
);
const nameFilter = useAppSelector(
(state) => state.store.contests.filterName,
);
const highlightZ = (name: string, filter: string) => {
if (!filter) return name;
const s = filter.toLowerCase();
const t = name.toLowerCase();
const n = t.length;
const m = s.length;
const mark = Array(n).fill(false);
// Проходимся с конца и ставим отметки
for (let i = n - 1; i >= 0; i--) {
if (i + m <= n && t.slice(i, i + m) === s) {
for (let j = i; j < i + m; j++) {
if (mark[j]) break;
mark[j] = true;
}
}
}
// === Формируем единые жёлтые блоки ===
const result: any[] = [];
let i = 0;
while (i < n) {
if (!mark[i]) {
// обычный символ
result.push(name[i]);
i++;
} else {
// начинаем жёлтый блок
let j = i;
while (j < n && mark[j]) j++;
const chunk = name.slice(i, j);
result.push(
<span
key={i}
className="bg-yellow-400 text-black rounded px-1"
>
{chunk}
</span>,
);
i = j;
}
}
return result;
};
useEffect(() => {
setRole(
(() => {
if (myContests?.some((c) => c.id === contestId)) {
return 'Organizer';
}
if (participantContests?.some((c) => c.id === contestId)) {
return 'Participant';
}
return 'None';
})(),
);
}, [myContests, participantContests]);
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
userId
? 'grid-cols-[1fr,150px,190px,120px,150px,150px]'
: 'grid-cols-[1fr,150px,190px,120px,150px]',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (role == 'None') {
toastWarning('Нужно зарегистрироваться на контест');
return;
}
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">
{highlightZ(name, nameFilter)}
</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'}
</div>
{userId && (
<div className="flex items-center justify-center">
{role == 'Organizer' || role == 'Participant' ? (
<ReverseButton
onClick={() => {
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
text="Войти"
/>
) : (
<PrimaryButton
onClick={() => {
dispatch(
addOrUpdateContestMember({
contestId: contestId,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
)
.unwrap()
.then(() =>
dispatch(
fetchParticipatingContests({}),
),
);
}}
text="Регистрация"
/>
)}
</div>
)}
</div>
);
};
export default PastContestItem;

View File

@@ -1,290 +0,0 @@
import { cn } from '../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useQuery } from '../../../hooks/useQuery';
import { toastWarning } from '../../../lib/toastNotification';
import { useEffect, useState } from 'react';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import {
addOrUpdateContestMember,
fetchParticipatingContests,
} from '../../../redux/slices/contests';
type Role = 'None' | 'Participant' | 'Organizer';
export interface UpcoingContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
type: 'first' | 'second';
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatDurationTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
function formatWaitTime(ms: number): string {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [role, setRole] = useState<Role>('None');
const myname = useAppSelector((state) => state.auth.username);
const { contests: myContests } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
const { contests: participantContests } = useAppSelector(
(state) => state.contests.fetchParticipating,
);
const nameFilter = useAppSelector(
(state) => state.store.contests.filterName,
);
const highlightZ = (name: string, filter: string) => {
if (!filter) return name;
const s = filter.toLowerCase();
const t = name.toLowerCase();
const n = t.length;
const m = s.length;
const mark = Array(n).fill(false);
// Проходимся с конца и ставим отметки
for (let i = n - 1; i >= 0; i--) {
if (i + m <= n && t.slice(i, i + m) === s) {
for (let j = i; j < i + m; j++) {
if (mark[j]) break;
mark[j] = true;
}
}
}
// === Формируем единые жёлтые блоки ===
const result: any[] = [];
let i = 0;
while (i < n) {
if (!mark[i]) {
// обычный символ
result.push(name[i]);
i++;
} else {
// начинаем жёлтый блок
let j = i;
while (j < n && mark[j]) j++;
const chunk = name.slice(i, j);
result.push(
<span
key={i}
className="bg-yellow-400 text-black rounded px-1"
>
{chunk}
</span>,
);
i = j;
}
}
return result;
};
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const userId = useAppSelector((state) => state.auth.id);
const started = new Date(startsAt) <= new Date();
const finished = new Date(endsAt) <= new Date();
const waitTime = !started
? new Date(startsAt).getTime() - new Date().getTime()
: new Date(endsAt).getTime() - new Date().getTime();
useEffect(() => {
setRole(
(() => {
if (myContests?.some((c) => c.id === contestId)) {
return 'Organizer';
}
if (participantContests?.some((c) => c.id === contestId)) {
return 'Participant';
}
return 'None';
})(),
);
}, [myContests, participantContests]);
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
userId
? 'grid-cols-[1fr,1fr,220px,130px,130px,140px,150px]'
: 'grid-cols-[1fr,1fr,220px,130px,130px,130px]',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">
{highlightZ(name, nameFilter)}
</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
{!started ? (
<div className="text-center whitespace-pre-line ">
{'До начала\n' + formatWaitTime(waitTime)}
</div>
) : (
!finished && (
<div className="text-center whitespace-pre-line ">
{'До конца\n' + formatWaitTime(waitTime)}
</div>
)
)}
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{new Date() < new Date(startsAt) ? (
<>{'Не начался'}</>
) : (
<>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}</>
)}
</div>
{userId && (
<div className="flex items-center justify-center">
{role == 'Organizer' || role == 'Participant' ? (
<ReverseButton
onClick={() => {
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
text="Войти"
/>
) : (
<PrimaryButton
onClick={() => {
dispatch(
addOrUpdateContestMember({
contestId: contestId,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
)
.unwrap()
.then(() =>
dispatch(
fetchParticipatingContests({}),
),
);
}}
text="Регистрация"
/>
)}
</div>
)}
</div>
);
};
export default UpcoingContestItem;

View File

@@ -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`} />}

View File

@@ -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>
);
}; };

View File

@@ -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>
);
};

View File

@@ -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>
);
}; };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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}

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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',

View File

@@ -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>
); );

View File

@@ -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)}

View File

@@ -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} />

View File

@@ -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>
); );

View File

@@ -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>

View 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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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>
); );
}; };

View File

@@ -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