Merge remote-tracking branch 'origin/dev'
Some checks failed
Build and Push Docker Image / build (push) Failing after 48s

This commit is contained in:
2025-11-19 21:10:58 +03:00
96 changed files with 5676 additions and 1488 deletions

1436
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V6.17C1.99986 6.58294 2.08497 6.99147 2.25 7.37V7.43C2.39128 7.75097 2.59139 8.04266 2.84 8.29L9 14.41V21C8.99966 21.1699 9.04264 21.3372 9.12487 21.4859C9.20711 21.6346 9.32589 21.7599 9.47 21.85C9.62914 21.9486 9.81277 22.0006 10 22C10.1565 21.9991 10.3107 21.9614 10.45 21.89L14.45 19.89C14.6149 19.8069 14.7536 19.6798 14.8507 19.5227C14.9478 19.3656 14.9994 19.1847 15 19V14.41L21.12 8.29C21.3686 8.04266 21.5687 7.75097 21.71 7.43V7.37C21.8888 6.99443 21.9876 6.58578 22 6.17V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM13.29 13.29C13.1973 13.3834 13.124 13.4943 13.0742 13.6161C13.0245 13.7379 12.9992 13.8684 13 14V18.38L11 19.38V14C11.0008 13.8684 10.9755 13.7379 10.9258 13.6161C10.876 13.4943 10.8027 13.3834 10.71 13.29L5.41 8H18.59L13.29 13.29ZM20 6H4V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V6Z" fill="#00DBD9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V6.17C1.99986 6.58294 2.08497 6.99147 2.25 7.37V7.43C2.39128 7.75097 2.59139 8.04266 2.84 8.29L9 14.41V21C8.99966 21.1699 9.04264 21.3372 9.12487 21.4859C9.20711 21.6346 9.32589 21.7599 9.47 21.85C9.62914 21.9486 9.81277 22.0006 10 22C10.1565 21.9991 10.3107 21.9614 10.45 21.89L14.45 19.89C14.6149 19.8069 14.7536 19.6798 14.8507 19.5227C14.9478 19.3656 14.9994 19.1847 15 19V14.41L21.12 8.29C21.3686 8.04266 21.5687 7.75097 21.71 7.43V7.37C21.8888 6.99443 21.9876 6.58578 22 6.17V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM13.29 13.29C13.1973 13.3834 13.124 13.4943 13.0742 13.6161C13.0245 13.7379 12.9992 13.8684 13 14V18.38L11 19.38V14C11.0008 13.8684 10.9755 13.7379 10.9258 13.6161C10.876 13.4943 10.8027 13.3834 10.71 13.29L5.41 8H18.59L13.29 13.29ZM20 6H4V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V6Z" fill="#576466"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,7 @@
import iconFilterActive from './filters-active.svg';
import iconFilter from './filters.svg';
import iconSort from './sort.svg';
import iconSortActive from './sort-active.svg';
import iconSearch from './search.svg';
export { iconFilter, iconFilterActive, iconSort, iconSortActive, iconSearch };

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.927 17.04L20.4001 20.4M19.2801 11.44C19.2801 15.7699 15.77 19.28 11.4401 19.28C7.11019 19.28 3.6001 15.7699 3.6001 11.44C3.6001 7.11009 7.11019 3.60001 11.4401 3.60001C15.77 3.60001 19.2801 7.11009 19.2801 11.44Z" stroke="#576466" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.4415 6.62732H22M13.4415 11.4421H19.5547M13.4415 16.2569H17.1094M5.80564 6V18M5.80564 18L2 14.3317M5.80564 18L9.7566 14.3317" stroke="#00DBD9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.4415 6.62732H22M13.4415 11.4421H19.5547M13.4415 16.2569H17.1094M5.80564 6V18M5.80564 18L2 14.3317M5.80564 18L9.7566 14.3317" stroke="#576466" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 4H18V3C18 2.73478 17.8946 2.48043 17.7071 2.29289C17.5196 2.10536 17.2652 2 17 2H7C6.73478 2 6.48043 2.10536 6.29289 2.29289C6.10536 2.48043 6 2.73478 6 3V4H3C2.73478 4 2.48043 4.10536 2.29289 4.29289C2.10536 4.48043 2 4.73478 2 5V8C2 9.06087 2.42143 10.0783 3.17157 10.8284C3.92172 11.5786 4.93913 12 6 12H7.54C8.44453 13.0091 9.66406 13.6824 11 13.91V16H10C9.20435 16 8.44129 16.3161 7.87868 16.8787C7.31607 17.4413 7 18.2044 7 19V21C7 21.2652 7.10536 21.5196 7.29289 21.7071C7.48043 21.8946 7.73478 22 8 22H16C16.2652 22 16.5196 21.8946 16.7071 21.7071C16.8946 21.5196 17 21.2652 17 21V19C17 18.2044 16.6839 17.4413 16.1213 16.8787C15.5587 16.3161 14.7956 16 14 16H13V13.91C14.3359 13.6824 15.5555 13.0091 16.46 12H18C19.0609 12 20.0783 11.5786 20.8284 10.8284C21.5786 10.0783 22 9.06087 22 8V5C22 4.73478 21.8946 4.48043 21.7071 4.29289C21.5196 4.10536 21.2652 4 21 4ZM6 10C5.46957 10 4.96086 9.78929 4.58579 9.41421C4.21071 9.03914 4 8.53043 4 8V6H6V8C6.0022 8.68171 6.12056 9.35806 6.35 10H6ZM14 18C14.2652 18 14.5196 18.1054 14.7071 18.2929C14.8946 18.4804 15 18.7348 15 19V20H9V19C9 18.7348 9.10536 18.4804 9.29289 18.2929C9.48043 18.1054 9.73478 18 10 18H14ZM16 8C16 9.06087 15.5786 10.0783 14.8284 10.8284C14.0783 11.5786 13.0609 12 12 12C10.9391 12 9.92172 11.5786 9.17157 10.8284C8.42143 10.0783 8 9.06087 8 8V4H16V8ZM20 8C20 8.53043 19.7893 9.03914 19.4142 9.41421C19.0391 9.78929 18.5304 10 18 10H17.65C17.8794 9.35806 17.9978 8.68171 18 8V6H20V8Z" fill="#EDF6F7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 17.0625H16.5M11.3046 3.21117L3.50457 8.48603C3.18802 8.7001 3 9.04666 3 9.41605V19.2882C3 20.2336 3.80589 21 4.8 21H19.2C20.1941 21 21 20.2336 21 19.2882V9.41605C21 9.04665 20.812 8.7001 20.4954 8.48603L12.6954 3.21117C12.2791 2.92961 11.7209 2.92961 11.3046 3.21117Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,5 @@
import Cup from './cup.svg';
import Home from './home.svg';
import MessageChat from './message-chat.svg';
export { Cup, MessageChat, Home };

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 10.5V6C19 4.89543 18.1046 4 17 4H5C3.89543 4 3 4.89543 3 6V13.8261C3 14.9307 3.89543 15.8261 5 15.8261H6.56522V20L10.7391 15.8261H11M16.163 18.3913L18.7717 21V18.3913H19C20.1046 18.3913 21 17.4959 21 16.3913V13C21 11.8954 20.1046 11 19 11H13C11.8954 11 11 11.8954 11 13V16.3913C11 17.4959 11.8954 18.3913 13 18.3913H16.163Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { cn } from '../../lib/cn';
import { checkMark } from '../../assets/icons/input';
import { useClickOutside } from '../../hooks/useClickOutside';
import { iconFilter, iconFilterActive } from '../../assets/icons/filters';
export interface FilterItem {
text: string;
value: string;
}
interface FilterProps {
disabled?: boolean;
className?: string;
onChange: (items: FilterItem[]) => void;
defaultState?: FilterItem[];
items: FilterItem[];
}
export const FilterDropDown: React.FC<FilterProps> = ({
disabled = false,
className = '',
onChange,
defaultState = [],
items = [],
}) => {
const [value, setValue] = React.useState<FilterItem[]>(defaultState);
const [active, setActive] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => {
setActive(false);
});
React.useEffect(() => {
onChange(value);
}, [value]);
const toggleItem = (item: FilterItem) => {
const exists = value.some((val) => val.value === item.value);
if (exists) {
setValue(value.filter((val) => val.value !== item.value));
} else {
setValue([...value, item]);
}
};
return (
<div className={cn('relative', className)} ref={ref}>
<div
className={cn(
'items-center h-[40px] rounded-full bg-liquid-lighter w-[40px] flex',
'text-[18px] font-bold cursor-pointer select-none',
'overflow-hidden',
(active || value.length > 0) &&
'w-fit border-liquid-brightmain border-[1px] border-solid',
)}
onClick={() => {
if (!disabled) setActive(!active);
}}
>
<div className="text-liquid-brightmain pl-[42px] pr-[16px] w-fit">
{value.length}
</div>
</div>
{/* Filter icons */}
<img
src={iconFilter}
className={cn(
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none',
)}
/>
<img
src={iconFilterActive}
className={cn(
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
(active || value.length > 0) && 'opacity-100',
)}
/>
{/* Dropdown */}
<div
className={cn(
'absolute rounded-[10px] bg-liquid-lighter w-[460px] left-0 top-[48px] z-50 transition-all duration-300',
'grid overflow-hidden',
active
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden p-[8px]">
<div className="overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] grid grid-cols-2 gap-[20px]">
{items.map((v) => {
const selected = value.some(
(val) => val.value === v.value,
);
return (
<div
key={v.value}
className={cn(
'cursor-pointer h-[36px] relative transition-all duration-300',
'text-[16px] font-medium select-none flex items-center pl-[8px]',
'hover:bg-liquid-background rounded-[10px]',
selected && 'bg-liquid-background/50',
)}
onClick={() => toggleItem(v)}
>
{v.text}
{selected && (
<img
src={checkMark}
className="absolute right-[8px] h-[20px] w-[20px]"
/>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import { FC, useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/cn';
import { checkMark } from '../../assets/icons/input';
import { useClickOutside } from '../../hooks/useClickOutside';
import { iconSort, iconSortActive } from '../../assets/icons/filters';
export interface SorterItem {
text: string;
value: string;
}
interface SorterProps {
disabled?: boolean;
className?: string;
onChange: (state: string) => void;
defaultState?: SorterItem;
items: SorterItem[];
}
export const SorterDropDown: FC<SorterProps> = ({
// disabled = false,
className = '',
onChange,
defaultState,
items = [{ text: '', value: '' }],
}) => {
if (items.length == 0) items.push({ text: '', value: '' });
const [value, setValue] = useState<SorterItem>(
defaultState != undefined ? defaultState : items[0],
);
const [active, setActive] = useState<boolean>(false);
const [activate, setActivate] = useState<Boolean>(false);
useEffect(() => onChange(value.value), [value]);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => {
setActive(false);
});
return (
<div className={cn('relative', className)} ref={ref}>
<div
className={cn(
'grid items-center h-[40px] rounded-full bg-liquid-lighter grid-cols-[40px]',
'text-[18px] font-bold cursor-pointer select-none',
'overflow-hidden',
(active || activate) &&
' grid-cols-[1fr] border-liquid-brightmain border-[1px] border-solid',
)}
onClick={() => {
setActive(!active);
}}
>
<div
className={cn(
'text-liquid-brightmain pl-[42px] pr-[16px]',
active && '',
)}
>
{' '}
{value.text}
</div>
</div>
<img
src={iconSort}
className={cn(
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none',
)}
/>
<img
src={iconSortActive}
className={cn(
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
(active || activate) && ' opacity-100',
)}
/>
<div
className={cn(
' absolute rounded-[10px] bg-liquid-lighter w-[220px] left-0 top-[48px] z-50 transition-all duration-300',
'grid overflow-hidden',
active
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
>
<div className=" overflow-hidden p-[8px]">
<div
className={cn(
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
)}
>
{items.map((v, i) => (
<div
key={i}
className={cn(
'cursor-pointer h-[36px] relative transition-all duration-300',
i + 1 != items.length &&
'border-b-liquid-light border-b-[1px]',
'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
'hover:bg-liquid-background',
'first:rounded-t-[6px] last:rounded-b-[6px]',
)}
onClick={() => {
setValue(v);
setActive(false);
setActivate(true);
}}
>
{v.text}
{v.text == value.text && (
<img
src={checkMark}
className=" absolute right-[8px]"
/>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { cn } from '../../lib/cn';
import { iconSearch } from '../../assets/icons/filters';
interface searchInputProps {
name?: string;
error?: string;
disabled?: boolean;
required?: boolean;
label?: string;
placeholder?: string;
className?: string;
onChange: (state: string) => void;
defaultState?: string;
autocomplete?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
export const SearchInput: React.FC<searchInputProps> = ({
placeholder = '',
className = '',
onChange,
defaultState = '',
name = '',
autocomplete = '',
onKeyDown,
}) => {
const [value, setValue] = React.useState<string>(defaultState);
React.useEffect(() => onChange(value), [value]);
React.useEffect(() => setValue(defaultState), [defaultState]);
return (
<label
className={cn(
'relative bg-liquid-lighter w-[200px] h-[40px] flex rounded-full px-[16px] pl-[50px] cursor-text',
className,
)}
>
<input
className={cn(
'placeholder:text-liquid-light h-[28px] w-[200px] bg-transparent outline-none text-liquid-white my-[6px]',
)}
value={value}
name={name}
autoComplete={autocomplete}
type="text"
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) onKeyDown(e);
}}
/>
<img
src={iconSearch}
className=" absolute top-[8px] left-[16px] w-[24px] h-[24px]"
/>
</label>
);
};

View File

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

View File

@@ -0,0 +1,34 @@
import { toast } from 'react-toastify';
export const toastSuccess = (mes: string, autoClose: number = 3000) => {
toast.success(mes, {
position: 'top-right',
autoClose: autoClose,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
});
};
export const toastWarning = (mes: string, autoClose: number = 3000) => {
toast.warning(mes, {
position: 'top-right',
autoClose: autoClose,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
});
};
export const toastError = (mes: string, autoClose: number = 3000) => {
toast.error(mes, {
position: 'top-right',
autoClose: autoClose,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
});
};

View File

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

View File

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

View File

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

368
src/pages/ContestEditor.tsx Normal file
View File

@@ -0,0 +1,368 @@
import { useEffect, useState } from 'react';
import Header from '../views/articleeditor/Header';
import { PrimaryButton } from '../components/button/PrimaryButton';
import { Input } from '../components/input/Input';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import {
CreateContestBody,
deleteContest,
fetchContestById,
setContestStatus,
updateContest,
} from '../redux/slices/contests';
import DateRangeInput from '../components/input/DateRangeInput';
import { useQuery } from '../hooks/useQuery';
import { Navigate, useNavigate } from 'react-router-dom';
import { fetchMissionById } from '../redux/slices/missions';
import { ReverseButton } from '../components/button/ReverseButton';
interface Mission {
id: number;
name: string;
}
/**
* Страница создания / редактирования контеста
*/
const ContestEditor = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const query = useQuery();
const back = query.get('back') ?? undefined;
const contestId = Number(query.get('contestId') ?? undefined);
const refactor = !!contestId;
if (!refactor) {
return <Navigate to="/home/account/acontest" />;
}
const status = useAppSelector(
(state) => state.contests.createContest.status,
);
const [missionIdInput, setMissionIdInput] = useState<string>('');
const [contest, setContest] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: '',
endsAt: '',
attemptDurationMinutes: 60,
maxAttempts: 1,
allowEarlyFinish: true,
missionIds: [],
articleIds: [],
});
const [missions, setMissions] = useState<Mission[]>([]);
const statusDelete = useAppSelector(
(state) => state.contests.deleteContest.status,
);
const statusUpdate = useAppSelector(
(state) => state.contests.updateContest.status,
);
const { contest: contestById, status: contestByIdstatus } = useAppSelector(
(state) => state.contests.fetchContestById,
);
useEffect(() => {
if (status === 'successful') {
}
}, [status]);
const handleChange = (key: keyof CreateContestBody, value: any) => {
setContest((prev) => ({ ...prev, [key]: value }));
};
const handleUpdateContest = () => {
dispatch(updateContest({ ...contest, contestId }));
};
const handleDeleteContest = () => {
dispatch(deleteContest(contestId));
};
const addMission = () => {
const id = Number(missionIdInput.trim());
if (!id || contest.missionIds?.includes(id)) return;
dispatch(fetchMissionById(id))
.unwrap()
.then((mission) => {
setMissions((prev) => [...prev, mission]);
setContest((prev) => ({
...prev,
missionIds: [...(prev.missionIds ?? []), id],
}));
setMissionIdInput('');
})
.catch((err) => {});
};
const removeMission = (removeId: number) => {
setContest({
...contest,
missionIds: contest.missionIds?.filter((v) => v !== removeId),
});
setMissions(missions.filter((v) => v.id != removeId));
};
useEffect(() => {
if (statusDelete == 'successful') {
dispatch(
setContestStatus({ key: 'deleteContest', status: 'idle' }),
);
navigate('/home/account/contests');
}
}, [statusDelete]);
useEffect(() => {
if (statusUpdate == 'successful') {
dispatch(
setContestStatus({ key: 'updateContest', status: 'idle' }),
);
navigate('/home/account/contests');
}
}, [statusUpdate]);
useEffect(() => {
if (refactor) {
dispatch(fetchContestById(contestId));
}
}, [refactor]);
useEffect(() => {
if (refactor && contestByIdstatus == 'successful' && contestById) {
setContest({
...contestById,
// groupIds: contestById.groups.map(group => group.groupId),
missionIds: contestById.missions?.map((mission) => mission.id),
articleIds: contestById.articles?.map(
(article) => article.articleId,
),
visibility: 'Public',
scheduleType: 'AlwaysOpen',
});
setMissions(contestById.missions ?? []);
}
}, [contestById]);
return (
<div className="h-screen grid grid-rows-[60px,1fr] text-liquid-white">
<Header backClick={() => navigate(back || '/home/contests')} />
<div className="grid grid-cols-2 h-full min-h-0">
{/* Левая панешь */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100"></h2>
<div className="">
<div className="font-bold text-[30px] mb-[10px]">
{refactor
? `Редактирвоание контеста #${contestId} \"${contestById?.name}\"`
: 'Создать контест'}
</div>
<Input
name="name"
type="text"
label="Название"
className="mt-[10px]"
placeholder="Введите название"
onChange={(v) => handleChange('name', v)}
defaultState={contest.name ?? ''}
/>
<Input
name="description"
type="text"
label="Описание"
className="mt-[10px]"
placeholder="Введите описание"
onChange={(v) => handleChange('description', v)}
defaultState={contest.description ?? ''}
/>
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<div>
<label className="block text-sm mb-1">
Тип расписания
</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={contest.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={contest.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={contest.startsAt || ''}
endValue={contest.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={!!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]">
<PrimaryButton
onClick={handleUpdateContest}
text="Сохранить"
disabled={status === 'loading'}
/>
<ReverseButton
color="error"
onClick={handleDeleteContest}
text="Удалить"
disabled={statusDelete === 'loading'}
/>
</div>
</div>
</div>
</div>
{/* Правая панель */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100"></h2>
{/* Блок для тегов */}
<div className="mt-[20px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
name="missionId"
autocomplete="missionId"
className="mt-[20px] max-w-[600px]"
type="number"
label="ID миссии"
onChange={(v) => {
setMissionIdInput(v);
}}
defaultState={missionIdInput}
placeholder="458"
onKeyDown={(e) => {
if (e.key == 'Enter') addMission();
}}
/>
<PrimaryButton
onClick={addMission}
text="Добавить"
className="h-[40px] w-[140px]"
/>
</div>
<div className="flex flex-wrap gap-[10px] mt-2">
{missions.map((v, i) => (
<div
key={i}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
>
<span>{v.id}</span>
<span>{v.name}</span>
<button
onClick={() => removeMission(v.id)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ContestEditor;

View File

@@ -1,4 +1,4 @@
// import React from "react";
// src/pages/Home.tsx
import { Route, Routes } from 'react-router-dom';
import Login from '../views/home/auth/Login';
import Register from '../views/home/auth/Register';
@@ -11,10 +11,19 @@ import Articles from '../views/home/articles/Articles';
import Groups from '../views/home/groups/Groups';
import Contests from '../views/home/contests/Contests';
import { PrimaryButton } from '../components/button/PrimaryButton';
import Group from '../views/home/groups/Group';
import Group from '../views/home/group/Group';
import Contest from '../views/home/contest/Contest';
import Account from '../views/home/account/Account';
import ProtectedRoute from '../components/router/ProtectedRoute';
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
import { GroupRightPanel } from '../views/home/rightpanel/Group';
import GroupInvite from '../views/home/groupinviter/GroupInvite';
import {
toastError,
toastSuccess,
toastWarning,
} from '../lib/toastNotification';
const Home = () => {
const name = useAppSelector((state) => state.auth.username);
@@ -34,14 +43,18 @@ const Home = () => {
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="account/*" element={<Account />} />
<Route
path="group-invite/*"
element={<GroupInvite />}
/>
<Route path="group/:groupId/*" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
</Route>
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
<Route path="missions/*" element={<Missions />} />
<Route path="articles/*" element={<Articles />} />
<Route path="group/:groupId" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
<Route path="contests/*" element={<Contests />} />
<Route path="contest/:contestId/*" element={<Contest />} />
<Route
@@ -51,8 +64,10 @@ const Home = () => {
<p>{jwt}</p>
<PrimaryButton
onClick={() => {
if (jwt)
if (jwt) {
navigator.clipboard.writeText(jwt);
alert(jwt);
}
}}
text="скопировать токен"
className="pt-[20px]"
@@ -65,6 +80,30 @@ const Home = () => {
>
выйти
</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>
</>
}
/>
@@ -72,7 +111,12 @@ const Home = () => {
</div>
{
<Routes>
<Route path="articles/*" element={<div></div>} />
<Route path="articles/*" element={<ArticlesRightPanel />} />
<Route path="missions/*" element={<MissionsRightPanel />} />
<Route
path="group/:groupId/*"
element={<GroupRightPanel />}
/>
</Routes>
}
</div>

View File

@@ -20,6 +20,7 @@ const Mission = () => {
const query = useQuery();
const back = query.get('back') ?? undefined;
const contestId = Number(query.get('contestId') ?? undefined);
if (!missionId || isNaN(missionIdNumber)) {
if (back) return <Navigate to={back} replace />;
@@ -148,9 +149,7 @@ const Mission = () => {
html: htmlStatement.statementTexts['problem.html'],
mediaFiles: latexStatement.mediaFiles,
};
} catch (err) {
console.error('Ошибка парсинга statementTexts:', err);
}
} catch (err) {}
return (
<div className="h-screen grid grid-rows-[60px,1fr]">
@@ -185,7 +184,7 @@ const Mission = () => {
language: language,
languageVersion: 'latest',
sourceCode: code,
contestId: null,
contestId: contestId,
}),
).unwrap();
dispatch(
@@ -198,7 +197,10 @@ const Mission = () => {
</div>
<div className="h-full w-full ">
<MissionSubmissions missionId={missionIdNumber} />
<MissionSubmissions
missionId={missionIdNumber}
contestId={contestId}
/>
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
// =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -15,39 +17,145 @@ export interface Article {
updatedAt: string;
}
interface ArticlesState {
articles: Article[];
currentArticle?: Article;
interface ArticlesResponse {
hasNextPage: boolean;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchAll: Status;
fetchById: Status;
articles: Article[];
}
// =====================
// Состояние
// =====================
interface ArticlesState {
fetchArticles: {
articles: Article[];
hasNextPage: boolean;
status: Status;
error?: string;
};
fetchArticleById: {
article?: Article;
status: Status;
error?: string;
};
createArticle: {
article?: Article;
status: Status;
error?: string;
};
updateArticle: {
article?: Article;
status: Status;
error?: string;
};
deleteArticle: {
status: Status;
error?: string;
};
fetchMyArticles: {
articles: Article[];
status: Status;
error?: string;
};
error: string | null;
}
const initialState: ArticlesState = {
articles: [],
currentArticle: undefined,
hasNextPage: false,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchAll: 'idle',
fetchById: 'idle',
fetchArticles: {
articles: [],
hasNextPage: false,
status: 'idle',
error: undefined,
},
fetchArticleById: {
article: undefined,
status: 'idle',
error: undefined,
},
createArticle: {
article: undefined,
status: 'idle',
error: undefined,
},
updateArticle: {
article: undefined,
status: 'idle',
error: undefined,
},
deleteArticle: {
status: 'idle',
error: undefined,
},
fetchMyArticles: {
articles: [],
status: 'idle',
error: undefined,
},
error: null,
};
// ─── Async Thunks ─────────────────────────────────────
// =====================
// Async Thunks
// =====================
// POST /articles
// Все статьи
export const fetchArticles = createAsyncThunk(
'articles/fetchArticles',
async (
{
page = 0,
pageSize = 10,
tags,
}: { page?: number; pageSize?: number; tags?: string[] } = {},
{ rejectWithValue },
) => {
try {
const params: any = { page, pageSize };
if (tags && tags.length > 0) params.tags = tags;
const response = await axios.get<ArticlesResponse>('/articles', {
params,
});
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статей',
);
}
},
);
// Мои статьи
export const fetchMyArticles = createAsyncThunk(
'articles/fetchMyArticles',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get<Article[]>('/articles/my');
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при получении моих статей',
);
}
},
);
// Статья по ID
export const fetchArticleById = createAsyncThunk(
'articles/fetchById',
async (articleId: number, { rejectWithValue }) => {
try {
const response = await axios.get<Article>(`/articles/${articleId}`);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статьи',
);
}
},
);
// Создание статьи
export const createArticle = createAsyncThunk(
'articles/createArticle',
'articles/create',
async (
{
name,
@@ -57,12 +165,12 @@ export const createArticle = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const response = await axios.post('/articles', {
const response = await axios.post<Article>('/articles', {
name,
content,
tags,
});
return response.data as Article;
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при создании статьи',
@@ -71,9 +179,9 @@ export const createArticle = createAsyncThunk(
},
);
// PUT /articles/{articleId}
// Обновление статьи
export const updateArticle = createAsyncThunk(
'articles/updateArticle',
'articles/update',
async (
{
articleId,
@@ -84,12 +192,15 @@ export const updateArticle = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const response = await axios.put(`/articles/${articleId}`, {
name,
content,
tags,
});
return response.data as Article;
const response = await axios.put<Article>(
`/articles/${articleId}`,
{
name,
content,
tags,
},
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при обновлении статьи',
@@ -98,9 +209,9 @@ export const updateArticle = createAsyncThunk(
},
);
// DELETE /articles/{articleId}
// Удаление статьи
export const deleteArticle = createAsyncThunk(
'articles/deleteArticle',
'articles/delete',
async (articleId: number, { rejectWithValue }) => {
try {
await axios.delete(`/articles/${articleId}`);
@@ -113,186 +224,136 @@ export const deleteArticle = createAsyncThunk(
},
);
// GET /articles
export const fetchArticles = createAsyncThunk(
'articles/fetchArticles',
async (
{
page = 0,
pageSize = 10,
tags,
}: { page?: number; pageSize?: number; tags?: string[] },
{ rejectWithValue },
) => {
try {
const params: any = { page, pageSize };
if (tags && tags.length > 0) params.tags = tags;
const response = await axios.get('/articles', { params });
return response.data as {
hasNextPage: boolean;
articles: Article[];
};
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статей',
);
}
},
);
// GET /articles/{articleId}
export const fetchArticleById = createAsyncThunk(
'articles/fetchArticleById',
async (articleId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/articles/${articleId}`);
return response.data as Article;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении статьи',
);
}
},
);
// ─── Slice ────────────────────────────────────────────
// =====================
// Slice
// =====================
const articlesSlice = createSlice({
name: 'articles',
initialState,
reducers: {
clearCurrentArticle: (state) => {
state.currentArticle = undefined;
},
setArticlesStatus: (
state,
action: PayloadAction<{
key: keyof ArticlesState['statuses'];
status: Status;
}>,
action: PayloadAction<{ key: keyof ArticlesState; status: Status }>,
) => {
const { key, status } = action.payload;
state.statuses[key] = status;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// ─── CREATE ARTICLE ───
builder.addCase(createArticle.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.create = 'successful';
state.articles.push(action.payload);
},
);
builder.addCase(
createArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
},
);
// ─── UPDATE ARTICLE ───
builder.addCase(updateArticle.pending, (state) => {
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.update = 'successful';
const index = state.articles.findIndex(
(a) => a.id === action.payload.id,
);
if (index !== -1) state.articles[index] = action.payload;
if (state.currentArticle?.id === action.payload.id)
state.currentArticle = action.payload;
},
);
builder.addCase(
updateArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.update = 'failed';
state.error = action.payload;
},
);
// ─── DELETE ARTICLE ───
builder.addCase(deleteArticle.pending, (state) => {
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteArticle.fulfilled,
(state, action: PayloadAction<number>) => {
state.statuses.delete = 'successful';
state.articles = state.articles.filter(
(a) => a.id !== action.payload,
);
if (state.currentArticle?.id === action.payload)
state.currentArticle = undefined;
},
);
builder.addCase(
deleteArticle.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH ARTICLES ───
// fetchArticles
builder.addCase(fetchArticles.pending, (state) => {
state.statuses.fetchAll = 'loading';
state.error = null;
state.fetchArticles.status = 'loading';
state.fetchArticles.error = undefined;
});
builder.addCase(
fetchArticles.fulfilled,
(
state,
action: PayloadAction<{
hasNextPage: boolean;
articles: Article[];
}>,
) => {
state.statuses.fetchAll = 'successful';
state.articles = action.payload.articles;
state.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(
fetchArticles.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchAll = 'failed';
state.error = action.payload;
(state, action: PayloadAction<ArticlesResponse>) => {
state.fetchArticles.status = 'successful';
state.fetchArticles.articles = action.payload.articles;
state.fetchArticles.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(fetchArticles.rejected, (state, action: any) => {
state.fetchArticles.status = 'failed';
state.fetchArticles.error = action.payload;
});
// ─── FETCH ARTICLE BY ID ───
// fetchMyArticles
builder.addCase(fetchMyArticles.pending, (state) => {
state.fetchMyArticles.status = 'loading';
state.fetchMyArticles.error = undefined;
});
builder.addCase(
fetchMyArticles.fulfilled,
(state, action: PayloadAction<Article[]>) => {
state.fetchMyArticles.status = 'successful';
state.fetchMyArticles.articles = action.payload;
},
);
builder.addCase(fetchMyArticles.rejected, (state, action: any) => {
state.fetchMyArticles.status = 'failed';
state.fetchMyArticles.error = action.payload;
});
// fetchArticleById
builder.addCase(fetchArticleById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
state.fetchArticleById.status = 'loading';
state.fetchArticleById.error = undefined;
});
builder.addCase(
fetchArticleById.fulfilled,
(state, action: PayloadAction<Article>) => {
state.statuses.fetchById = 'successful';
state.currentArticle = action.payload;
state.fetchArticleById.status = 'successful';
state.fetchArticleById.article = action.payload;
},
);
builder.addCase(fetchArticleById.rejected, (state, action: any) => {
state.fetchArticleById.status = 'failed';
state.fetchArticleById.error = action.payload;
});
// createArticle
builder.addCase(createArticle.pending, (state) => {
state.createArticle.status = 'loading';
state.createArticle.error = undefined;
});
builder.addCase(
fetchArticleById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
createArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.createArticle.status = 'successful';
state.createArticle.article = action.payload;
},
);
builder.addCase(createArticle.rejected, (state, action: any) => {
state.createArticle.status = 'failed';
state.createArticle.error = action.payload;
});
// updateArticle
builder.addCase(updateArticle.pending, (state) => {
state.updateArticle.status = 'loading';
state.updateArticle.error = undefined;
});
builder.addCase(
updateArticle.fulfilled,
(state, action: PayloadAction<Article>) => {
state.updateArticle.status = 'successful';
state.updateArticle.article = action.payload;
},
);
builder.addCase(updateArticle.rejected, (state, action: any) => {
state.updateArticle.status = 'failed';
state.updateArticle.error = action.payload;
});
// deleteArticle
builder.addCase(deleteArticle.pending, (state) => {
state.deleteArticle.status = 'loading';
state.deleteArticle.error = undefined;
});
builder.addCase(
deleteArticle.fulfilled,
(state, action: PayloadAction<number>) => {
state.deleteArticle.status = 'successful';
state.fetchArticles.articles =
state.fetchArticles.articles.filter(
(a) => a.id !== action.payload,
);
state.fetchMyArticles.articles =
state.fetchMyArticles.articles.filter(
(a) => a.id !== action.payload,
);
},
);
builder.addCase(deleteArticle.rejected, (state, action: any) => {
state.deleteArticle.status = 'failed';
state.deleteArticle.error = action.payload;
});
},
});
export const { clearCurrentArticle, setArticlesStatus } = articlesSlice.actions;
export const { setArticlesStatus } = articlesSlice.actions;
export const articlesReducer = articlesSlice.reducer;

View File

@@ -121,11 +121,26 @@ export const refreshToken = createAsyncThunk(
export const fetchWhoAmI = createAsyncThunk(
'auth/whoami',
async (_, { rejectWithValue }) => {
async (_, { dispatch, getState, rejectWithValue }) => {
try {
const response = await axios.get('/authentication/whoami');
return response.data;
} catch (err: any) {
const state: any = getState();
const refresh = state.auth.refreshToken;
if (refresh) {
// пробуем refresh
const result = await dispatch(
refreshToken({ refreshToken: refresh }),
);
// если успешный, повторить whoami
if (refreshToken.fulfilled.match(result)) {
const retry = await axios.get('/authentication/whoami');
return retry.data;
}
}
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch user info',
);
@@ -269,6 +284,22 @@ const authSlice = createSlice({
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
// Если пользователь не авторизован (401), делаем logout и пытаемся refresh
if (
action.payload === 'Unauthorized' ||
action.payload === 'Failed to fetch user info'
) {
// Вызов logout
state.jwt = null;
state.refreshToken = null;
state.username = null;
state.email = null;
state.id = null;
localStorage.removeItem('jwt');
localStorage.removeItem('refreshToken');
delete axios.defaults.headers.common['Authorization'];
}
});
},
});

View File

@@ -5,17 +5,43 @@ import axios from '../../axios';
// Типы
// =====================
// =====================
// Типы для посылок
// =====================
export interface Solution {
id: number;
missionId: number;
language: string;
languageVersion: string;
sourceCode: string;
status: string;
time: string;
testerState: string;
testerErrorCode: string;
testerMessage: string;
currentTest: number;
amountOfTests: number;
}
export interface Submission {
id: number;
userId: number;
solution: Solution;
contestId: number;
contestName: string;
sourceType: string;
}
export interface Mission {
id: number;
authorId: number;
name: string;
difficulty: number;
tags: string[];
createdAt: string;
updatedAt: string;
timeLimitMilliseconds: number;
memoryLimitBytes: number;
statements: null;
statements: string;
}
export interface Member {
@@ -24,21 +50,27 @@ export interface Member {
role: string;
}
export interface Group {
groupId: number;
groupName: string;
}
export interface Contest {
id: number;
name: string;
description: string;
scheduleType: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number | null;
maxAttempts: number | null;
allowEarlyFinish: boolean | null;
groupId: number | null;
groupName: string | null;
missions: Mission[];
articles: any[];
members: Member[];
description?: string;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: 'Public' | 'GroupPrivate';
startsAt?: string;
endsAt?: string;
attemptDurationMinutes?: number;
maxAttempts?: number;
allowEarlyFinish?: boolean;
groupId?: number;
groupName?: string;
missions?: Mission[];
articles?: any[];
members?: Member[];
}
interface ContestsResponse {
@@ -47,20 +79,19 @@ interface ContestsResponse {
}
export interface CreateContestBody {
name?: string | null;
description?: string | null;
name: string;
description?: string;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: 'Public' | 'GroupPrivate';
startsAt?: string | null;
endsAt?: string | null;
attemptDurationMinutes?: number | null;
maxAttempts?: number | null;
allowEarlyFinish?: boolean | null;
groupId?: number | null;
missionIds?: number[] | null;
articleIds?: number[] | null;
participantIds?: number[] | null;
organizerIds?: number[] | null;
startsAt?: string;
endsAt?: string;
attemptDurationMinutes?: number;
maxAttempts?: number;
allowEarlyFinish?: boolean;
groupId?: number;
groupName?: string;
missionIds?: number[];
articleIds?: number[];
}
// =====================
@@ -70,33 +101,164 @@ export interface CreateContestBody {
type Status = 'idle' | 'loading' | 'successful' | 'failed';
interface ContestsState {
contests: Contest[];
selectedContest: Contest | null;
hasNextPage: boolean;
statuses: {
fetchList: Status;
fetchById: Status;
create: Status;
fetchContests: {
contests: Contest[];
hasNextPage: boolean;
status: Status;
error?: string;
};
fetchContestById: {
contest: Contest;
status: Status;
error?: string;
};
createContest: {
contest: Contest;
status: Status;
error?: string;
};
fetchMySubmissions: {
submissions: Submission[];
status: Status;
error?: string;
};
updateContest: {
contest: Contest;
status: Status;
error?: string;
};
deleteContest: {
status: Status;
error?: string;
};
fetchMyContests: {
contests: Contest[];
status: Status;
error?: string;
};
fetchRegisteredContests: {
contests: Contest[];
hasNextPage: boolean;
status: Status;
error?: string;
};
error: string | null;
}
const initialState: ContestsState = {
contests: [],
selectedContest: null,
hasNextPage: false,
statuses: {
fetchList: 'idle',
fetchById: 'idle',
create: 'idle',
fetchContests: {
contests: [],
hasNextPage: false,
status: 'idle',
error: undefined,
},
fetchContestById: {
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,
},
fetchMySubmissions: {
submissions: [],
status: 'idle',
error: undefined,
},
createContest: {
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,
},
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: [],
hasNextPage: false,
status: 'idle',
error: undefined,
},
error: null,
};
// =====================
// Async Thunks
// =====================
// Мои посылки в контесте
export const fetchMySubmissions = createAsyncThunk(
'contests/fetchMySubmissions',
async (contestId: number, { rejectWithValue }) => {
try {
const response = await axios.get<Submission[]>(
`/contests/${contestId}/submissions/my`,
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch my submissions',
);
}
},
);
// Все контесты
export const fetchContests = createAsyncThunk(
'contests/fetchAll',
async (
@@ -121,6 +283,7 @@ export const fetchContests = createAsyncThunk(
},
);
// Контест по ID
export const fetchContestById = createAsyncThunk(
'contests/fetchById',
async (id: number, { rejectWithValue }) => {
@@ -135,6 +298,7 @@ export const fetchContestById = createAsyncThunk(
},
);
// Создание контеста
export const createContest = createAsyncThunk(
'contests/create',
async (contestData: CreateContestBody, { rejectWithValue }) => {
@@ -152,6 +316,83 @@ export const createContest = createAsyncThunk(
},
);
// 🆕 Обновление контеста
export const updateContest = createAsyncThunk(
'contests/update',
async (
{
contestId,
...contestData
}: { contestId: number } & CreateContestBody,
{ rejectWithValue },
) => {
try {
const response = await axios.put<Contest>(
`/contests/${contestId}`,
contestData,
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to update contest',
);
}
},
);
// 🆕 Удаление контеста
export const deleteContest = createAsyncThunk(
'contests/delete',
async (contestId: number, { rejectWithValue }) => {
try {
await axios.delete(`/contests/${contestId}`);
return contestId;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to delete contest',
);
}
},
);
// Контесты, созданные мной
export const fetchMyContests = createAsyncThunk(
'contests/fetchMyContests',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get<Contest[]>('/contests/my');
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch my contests',
);
}
},
);
// Контесты, где я зарегистрирован
export const fetchRegisteredContests = createAsyncThunk(
'contests/fetchRegisteredContests',
async (
params: { page?: number; pageSize?: number } = {},
{ rejectWithValue },
) => {
try {
const { page = 0, pageSize = 10 } = params;
const response = await axios.get<ContestsResponse>(
'/contests/registered',
{ params: { page, pageSize } },
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Failed to fetch registered contests',
);
}
},
);
// =====================
// Slice
// =====================
@@ -160,78 +401,166 @@ const contestsSlice = createSlice({
name: 'contests',
initialState,
reducers: {
clearSelectedContest: (state) => {
state.selectedContest = null;
},
// 🆕 Сброс статусов
setContestStatus: (
state,
action: PayloadAction<{
key: keyof ContestsState['statuses'];
status: Status;
}>,
action: PayloadAction<{ key: keyof ContestsState; status: Status }>,
) => {
state.statuses[action.payload.key] = action.payload.status;
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// 🆕 fetchMySubmissions
builder.addCase(fetchMySubmissions.pending, (state) => {
state.fetchMySubmissions.status = 'loading';
state.fetchMySubmissions.error = undefined;
});
builder.addCase(
fetchMySubmissions.fulfilled,
(state, action: PayloadAction<Submission[]>) => {
state.fetchMySubmissions.status = 'successful';
state.fetchMySubmissions.submissions = action.payload;
},
);
builder.addCase(fetchMySubmissions.rejected, (state, action: any) => {
state.fetchMySubmissions.status = 'failed';
state.fetchMySubmissions.error = action.payload;
});
// fetchContests
builder.addCase(fetchContests.pending, (state) => {
state.statuses.fetchList = 'loading';
state.error = null;
state.fetchContests.status = 'loading';
state.fetchContests.error = undefined;
});
builder.addCase(
fetchContests.fulfilled,
(state, action: PayloadAction<ContestsResponse>) => {
state.statuses.fetchList = 'successful';
state.contests = action.payload.contests;
state.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(
fetchContests.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchList = 'failed';
state.error = action.payload;
state.fetchContests.status = 'successful';
state.fetchContests.contests = action.payload.contests;
state.fetchContests.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(fetchContests.rejected, (state, action: any) => {
state.fetchContests.status = 'failed';
state.fetchContests.error = action.payload;
});
// fetchContestById
builder.addCase(fetchContestById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
state.fetchContestById.status = 'loading';
state.fetchContestById.error = undefined;
});
builder.addCase(
fetchContestById.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.statuses.fetchById = 'successful';
state.selectedContest = action.payload;
},
);
builder.addCase(
fetchContestById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
state.fetchContestById.status = 'successful';
state.fetchContestById.contest = action.payload;
},
);
builder.addCase(fetchContestById.rejected, (state, action: any) => {
state.fetchContestById.status = 'failed';
state.fetchContestById.error = action.payload;
});
// createContest
builder.addCase(createContest.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
state.createContest.status = 'loading';
state.createContest.error = undefined;
});
builder.addCase(
createContest.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.statuses.create = 'successful';
state.contests.unshift(action.payload);
state.createContest.status = 'successful';
state.createContest.contest = action.payload;
},
);
builder.addCase(createContest.rejected, (state, action: any) => {
state.createContest.status = 'failed';
state.createContest.error = action.payload;
});
// 🆕 updateContest
builder.addCase(updateContest.pending, (state) => {
state.updateContest.status = 'loading';
state.updateContest.error = undefined;
});
builder.addCase(
updateContest.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.updateContest.status = 'successful';
state.updateContest.contest = action.payload;
},
);
builder.addCase(updateContest.rejected, (state, action: any) => {
state.updateContest.status = 'failed';
state.updateContest.error = action.payload;
});
// 🆕 deleteContest
builder.addCase(deleteContest.pending, (state) => {
state.deleteContest.status = 'loading';
state.deleteContest.error = undefined;
});
builder.addCase(
deleteContest.fulfilled,
(state, action: PayloadAction<number>) => {
state.deleteContest.status = 'successful';
// Удалим контест из списков
state.fetchContests.contests =
state.fetchContests.contests.filter(
(c) => c.id !== action.payload,
);
state.fetchMyContests.contests =
state.fetchMyContests.contests.filter(
(c) => c.id !== action.payload,
);
},
);
builder.addCase(deleteContest.rejected, (state, action: any) => {
state.deleteContest.status = 'failed';
state.deleteContest.error = action.payload;
});
// fetchMyContests
builder.addCase(fetchMyContests.pending, (state) => {
state.fetchMyContests.status = 'loading';
state.fetchMyContests.error = undefined;
});
builder.addCase(
fetchMyContests.fulfilled,
(state, action: PayloadAction<Contest[]>) => {
state.fetchMyContests.status = 'successful';
state.fetchMyContests.contests = action.payload;
},
);
builder.addCase(fetchMyContests.rejected, (state, action: any) => {
state.fetchMyContests.status = 'failed';
state.fetchMyContests.error = action.payload;
});
// fetchRegisteredContests
builder.addCase(fetchRegisteredContests.pending, (state) => {
state.fetchRegisteredContests.status = 'loading';
state.fetchRegisteredContests.error = undefined;
});
builder.addCase(
fetchRegisteredContests.fulfilled,
(state, action: PayloadAction<ContestsResponse>) => {
state.fetchRegisteredContests.status = 'successful';
state.fetchRegisteredContests.contests =
action.payload.contests;
state.fetchRegisteredContests.hasNextPage =
action.payload.hasNextPage;
},
);
builder.addCase(
createContest.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
fetchRegisteredContests.rejected,
(state, action: any) => {
state.fetchRegisteredContests.status = 'failed';
state.fetchRegisteredContests.error = action.payload;
},
);
},
@@ -241,5 +570,5 @@ const contestsSlice = createSlice({
// Экспорты
// =====================
export const { clearSelectedContest, setContestStatus } = contestsSlice.actions;
export const { setContestStatus } = contestsSlice.actions;
export const contestsReducer = contestsSlice.reducer;

View File

@@ -0,0 +1,347 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
export interface Post {
id: number;
groupId: number;
authorId: number;
authorUsername: string;
name: string;
content: string;
createdAt: string;
updatedAt: string;
}
export interface PostsPage {
items: Post[];
hasNext: boolean;
}
// =====================
// Состояние
// =====================
interface PostsState {
fetchPosts: {
pages: Record<number, PostsPage>; // страница => данные
status: Status;
error?: string;
};
fetchPostById: {
post?: Post;
status: Status;
error?: string;
};
createPost: {
post?: Post;
status: Status;
error?: string;
};
updatePost: {
post?: Post;
status: Status;
error?: string;
};
deletePost: {
deletedId?: number;
status: Status;
error?: string;
};
}
const initialState: PostsState = {
fetchPosts: {
pages: {},
status: 'idle',
error: undefined,
},
fetchPostById: {
post: undefined,
status: 'idle',
error: undefined,
},
createPost: {
post: undefined,
status: 'idle',
error: undefined,
},
updatePost: {
post: undefined,
status: 'idle',
error: undefined,
},
deletePost: {
deletedId: undefined,
status: 'idle',
error: undefined,
},
};
// =====================
// Async Thunks
// =====================
// Получить посты группы (пагинация)
export const fetchGroupPosts = createAsyncThunk(
'posts/fetchGroupPosts',
async (
{
groupId,
page = 0,
pageSize = 20,
}: { groupId: number; page?: number; pageSize?: number },
{ rejectWithValue },
) => {
try {
const response = await axios.get(
`/groups/${groupId}/feed?page=${page}&pageSize=${pageSize}`,
);
return { page, data: response.data as PostsPage };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки постов',
);
}
},
);
// Получить один пост
export const fetchPostById = createAsyncThunk(
'posts/fetchPostById',
async (
{ groupId, postId }: { groupId: number; postId: number },
{ rejectWithValue },
) => {
try {
const response = await axios.get(
`/groups/${groupId}/feed/${postId}`,
);
return response.data as Post;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки поста',
);
}
},
);
// Создать пост
export const createPost = createAsyncThunk(
'posts/createPost',
async (
{
groupId,
name,
content,
}: { groupId: number; name: string; content: string },
{ rejectWithValue },
) => {
try {
const response = await axios.post(`/groups/${groupId}/feed`, {
name,
content,
});
return response.data as Post;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка создания поста',
);
}
},
);
// Обновить пост
export const updatePost = createAsyncThunk(
'posts/updatePost',
async (
{
groupId,
postId,
name,
content,
}: {
groupId: number;
postId: number;
name: string;
content: string;
},
{ rejectWithValue },
) => {
try {
const response = await axios.put(
`/groups/${groupId}/feed/${postId}`,
{
name,
content,
},
);
return response.data as Post;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка обновления поста',
);
}
},
);
// Удалить пост
export const deletePost = createAsyncThunk(
'posts/deletePost',
async (
{ groupId, postId }: { groupId: number; postId: number },
{ rejectWithValue },
) => {
try {
await axios.delete(`/groups/${groupId}/feed/${postId}`);
return postId;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка удаления поста',
);
}
},
);
// =====================
// Slice
// =====================
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
setGroupFeedStatus: (
state,
action: PayloadAction<{ key: keyof PostsState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// fetchGroupPosts
builder.addCase(fetchGroupPosts.pending, (state) => {
state.fetchPosts.status = 'loading';
});
builder.addCase(
fetchGroupPosts.fulfilled,
(
state,
action: PayloadAction<{ page: number; data: PostsPage }>,
) => {
const { page, data } = action.payload;
state.fetchPosts.status = 'successful';
state.fetchPosts.pages[page] = data;
},
);
builder.addCase(fetchGroupPosts.rejected, (state, action: any) => {
state.fetchPosts.status = 'failed';
state.fetchPosts.error = action.payload;
});
// fetchPostById
builder.addCase(fetchPostById.pending, (state) => {
state.fetchPostById.status = 'loading';
});
builder.addCase(
fetchPostById.fulfilled,
(state, action: PayloadAction<Post>) => {
state.fetchPostById.status = 'successful';
state.fetchPostById.post = action.payload;
},
);
builder.addCase(fetchPostById.rejected, (state, action: any) => {
state.fetchPostById.status = 'failed';
state.fetchPostById.error = action.payload;
});
// createPost
builder.addCase(createPost.pending, (state) => {
state.createPost.status = 'loading';
});
builder.addCase(
createPost.fulfilled,
(state, action: PayloadAction<Post>) => {
state.createPost.status = 'successful';
state.createPost.post = action.payload;
// добавляем сразу в первую страницу (page = 0)
if (state.fetchPosts.pages[0]) {
state.fetchPosts.pages[0].items.unshift(action.payload);
}
},
);
builder.addCase(createPost.rejected, (state, action: any) => {
state.createPost.status = 'failed';
state.createPost.error = action.payload;
});
// updatePost
builder.addCase(updatePost.pending, (state) => {
state.updatePost.status = 'loading';
});
builder.addCase(
updatePost.fulfilled,
(state, action: PayloadAction<Post>) => {
state.updatePost.status = 'successful';
state.updatePost.post = action.payload;
// обновим в списках
for (const page of Object.values(state.fetchPosts.pages)) {
const index = page.items.findIndex(
(p) => p.id === action.payload.id,
);
if (index !== -1) page.items[index] = action.payload;
}
// обновим если открыт одиночный пост
if (state.fetchPostById.post?.id === action.payload.id) {
state.fetchPostById.post = action.payload;
}
},
);
builder.addCase(updatePost.rejected, (state, action: any) => {
state.updatePost.status = 'failed';
state.updatePost.error = action.payload;
});
// deletePost
builder.addCase(deletePost.pending, (state) => {
state.deletePost.status = 'loading';
});
builder.addCase(
deletePost.fulfilled,
(state, action: PayloadAction<number>) => {
state.deletePost.status = 'successful';
state.deletePost.deletedId = action.payload;
// удалить из всех страниц
for (const page of Object.values(state.fetchPosts.pages)) {
page.items = page.items.filter(
(p) => p.id !== action.payload,
);
}
// если открыт индивидуальный пост
if (state.fetchPostById.post?.id === action.payload) {
state.fetchPostById.post = undefined;
}
},
);
builder.addCase(deletePost.rejected, (state, action: any) => {
state.deletePost.status = 'failed';
state.deletePost.error = action.payload;
});
},
});
export const { setGroupFeedStatus } = postsSlice.actions;
export const groupFeedReducer = postsSlice.reducer;

View File

@@ -1,7 +1,9 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
// =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -19,39 +21,106 @@ export interface Group {
contests: any[];
}
// =====================
// Состояние
// =====================
interface GroupsState {
groups: Group[];
currentGroup: Group | null;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchMy: Status;
fetchById: Status;
addMember: Status;
removeMember: Status;
fetchMyGroups: {
groups: Group[];
status: Status;
error?: string;
};
fetchGroupById: {
group?: Group;
status: Status;
error?: string;
};
createGroup: {
group?: Group;
status: Status;
error?: string;
};
updateGroup: {
group?: Group;
status: Status;
error?: string;
};
deleteGroup: {
deletedId?: number;
status: Status;
error?: string;
};
addGroupMember: {
status: Status;
error?: string;
};
removeGroupMember: {
status: Status;
error?: string;
};
fetchGroupJoinLink: {
joinLink?: { token: string; expiresAt: string };
status: Status;
error?: string;
};
joinGroupByToken: {
group?: Group;
status: Status;
error?: string;
};
error: string | null;
}
const initialState: GroupsState = {
groups: [],
currentGroup: null,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchMy: 'idle',
fetchById: 'idle',
addMember: 'idle',
removeMember: 'idle',
fetchMyGroups: {
groups: [],
status: 'idle',
error: undefined,
},
fetchGroupById: {
group: undefined,
status: 'idle',
error: undefined,
},
createGroup: {
group: undefined,
status: 'idle',
error: undefined,
},
updateGroup: {
group: undefined,
status: 'idle',
error: undefined,
},
deleteGroup: {
deletedId: undefined,
status: 'idle',
error: undefined,
},
addGroupMember: {
status: 'idle',
error: undefined,
},
removeGroupMember: {
status: 'idle',
error: undefined,
},
fetchGroupJoinLink: {
joinLink: undefined,
status: 'idle',
error: undefined,
},
joinGroupByToken: {
group: undefined,
status: 'idle',
error: undefined,
},
error: null,
};
// ─── Async Thunks ─────────────────────────────────────
// =====================
// Async Thunks
// =====================
// POST /groups
export const createGroup = createAsyncThunk(
'groups/createGroup',
async (
@@ -69,7 +138,6 @@ export const createGroup = createAsyncThunk(
},
);
// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk(
'groups/updateGroup',
async (
@@ -94,7 +162,6 @@ export const updateGroup = createAsyncThunk(
},
);
// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk(
'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => {
@@ -109,7 +176,6 @@ export const deleteGroup = createAsyncThunk(
},
);
// GET /groups/my
export const fetchMyGroups = createAsyncThunk(
'groups/fetchMyGroups',
async (_, { rejectWithValue }) => {
@@ -124,7 +190,6 @@ export const fetchMyGroups = createAsyncThunk(
},
);
// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk(
'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => {
@@ -139,16 +204,22 @@ export const fetchGroupById = createAsyncThunk(
},
);
// POST /groups/members
export const addGroupMember = createAsyncThunk(
'groups/addGroupMember',
async (
{ userId, role }: { userId: number; role: string },
{
groupId,
userId,
role,
}: { groupId: number; userId: number; role: string },
{ rejectWithValue },
) => {
try {
await axios.post('/groups/members', { userId, role });
return { userId, role };
const response = await axios.post(`/groups/${groupId}/members`, {
userId,
role,
});
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
@@ -158,7 +229,6 @@ export const addGroupMember = createAsyncThunk(
},
);
// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk(
'groups/removeGroupMember',
async (
@@ -176,147 +246,169 @@ export const removeGroupMember = createAsyncThunk(
},
);
// ─── Slice ────────────────────────────────────────────
// =====================
// Новые Async Thunks
// =====================
// Получение актуальной ссылки для присоединения к группе
export const fetchGroupJoinLink = createAsyncThunk(
'groups/fetchGroupJoinLink',
async (groupId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${groupId}/join-link`);
return response.data as { token: string; expiresAt: string };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при получении ссылки для присоединения',
);
}
},
);
// Присоединение к группе по токену приглашения
export const joinGroupByToken = createAsyncThunk(
'groups/joinGroupByToken',
async (token: string, { rejectWithValue }) => {
try {
const response = await axios.post(`/groups/join/${token}`);
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при присоединении к группе по ссылке',
);
}
},
);
// =====================
// Slice
// =====================
const groupsSlice = createSlice({
name: 'groups',
initialState,
reducers: {
clearCurrentGroup: (state) => {
state.currentGroup = null;
setGroupsStatus: (
state,
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// ─── CREATE GROUP ───
builder.addCase(createGroup.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.create = 'successful';
state.groups.push(action.payload);
},
);
builder.addCase(
createGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
},
);
// ─── UPDATE GROUP ───
builder.addCase(updateGroup.pending, (state) => {
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.update = 'successful';
const index = state.groups.findIndex(
(g) => g.id === action.payload.id,
);
if (index !== -1) state.groups[index] = action.payload;
if (state.currentGroup?.id === action.payload.id) {
state.currentGroup = action.payload;
}
},
);
builder.addCase(
updateGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.update = 'failed';
state.error = action.payload;
},
);
// ─── DELETE GROUP ───
builder.addCase(deleteGroup.pending, (state) => {
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteGroup.fulfilled,
(state, action: PayloadAction<number>) => {
state.statuses.delete = 'successful';
state.groups = state.groups.filter(
(g) => g.id !== action.payload,
);
if (state.currentGroup?.id === action.payload)
state.currentGroup = null;
},
);
builder.addCase(
deleteGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH MY GROUPS ───
// fetchMyGroups
builder.addCase(fetchMyGroups.pending, (state) => {
state.statuses.fetchMy = 'loading';
state.error = null;
state.fetchMyGroups.status = 'loading';
});
builder.addCase(
fetchMyGroups.fulfilled,
(state, action: PayloadAction<Group[]>) => {
state.statuses.fetchMy = 'successful';
state.groups = action.payload;
},
);
builder.addCase(
fetchMyGroups.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
state.error = action.payload;
state.fetchMyGroups.status = 'successful';
state.fetchMyGroups.groups = action.payload;
},
);
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
state.fetchMyGroups.status = 'failed';
state.fetchMyGroups.error = action.payload;
});
// ─── FETCH GROUP BY ID ───
// fetchGroupById
builder.addCase(fetchGroupById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
state.fetchGroupById.status = 'loading';
});
builder.addCase(
fetchGroupById.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.fetchById = 'successful';
state.currentGroup = action.payload;
},
);
builder.addCase(
fetchGroupById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
state.fetchGroupById.status = 'successful';
state.fetchGroupById.group = action.payload;
},
);
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
state.fetchGroupById.status = 'failed';
state.fetchGroupById.error = action.payload;
});
// ─── ADD MEMBER ───
// createGroup
builder.addCase(createGroup.pending, (state) => {
state.createGroup.status = 'loading';
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.createGroup.status = 'successful';
state.createGroup.group = action.payload;
state.fetchMyGroups.groups.push(action.payload);
},
);
builder.addCase(createGroup.rejected, (state, action: any) => {
state.createGroup.status = 'failed';
state.createGroup.error = action.payload;
});
// updateGroup
builder.addCase(updateGroup.pending, (state) => {
state.updateGroup.status = 'loading';
});
builder.addCase(
updateGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.updateGroup.status = 'successful';
state.updateGroup.group = action.payload;
const index = state.fetchMyGroups.groups.findIndex(
(g) => g.id === action.payload.id,
);
if (index !== -1)
state.fetchMyGroups.groups[index] = action.payload;
if (state.fetchGroupById.group?.id === action.payload.id)
state.fetchGroupById.group = action.payload;
},
);
builder.addCase(updateGroup.rejected, (state, action: any) => {
state.updateGroup.status = 'failed';
state.updateGroup.error = action.payload;
});
// deleteGroup
builder.addCase(deleteGroup.pending, (state) => {
state.deleteGroup.status = 'loading';
});
builder.addCase(
deleteGroup.fulfilled,
(state, action: PayloadAction<number>) => {
state.deleteGroup.status = 'successful';
state.deleteGroup.deletedId = action.payload;
state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter(
(g) => g.id !== action.payload,
);
if (state.fetchGroupById.group?.id === action.payload)
state.fetchGroupById.group = undefined;
},
);
builder.addCase(deleteGroup.rejected, (state, action: any) => {
state.deleteGroup.status = 'failed';
state.deleteGroup.error = action.payload;
});
// addGroupMember
builder.addCase(addGroupMember.pending, (state) => {
state.statuses.addMember = 'loading';
state.error = null;
state.addGroupMember.status = 'loading';
});
builder.addCase(addGroupMember.fulfilled, (state) => {
state.statuses.addMember = 'successful';
state.addGroupMember.status = 'successful';
});
builder.addCase(addGroupMember.rejected, (state, action: any) => {
state.addGroupMember.status = 'failed';
state.addGroupMember.error = action.payload;
});
builder.addCase(
addGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.addMember = 'failed';
state.error = action.payload;
},
);
// ─── REMOVE MEMBER ───
// removeGroupMember
builder.addCase(removeGroupMember.pending, (state) => {
state.statuses.removeMember = 'loading';
state.error = null;
state.removeGroupMember.status = 'loading';
});
builder.addCase(
removeGroupMember.fulfilled,
@@ -324,27 +416,60 @@ const groupsSlice = createSlice({
state,
action: PayloadAction<{ groupId: number; memberId: number }>,
) => {
state.statuses.removeMember = 'successful';
state.removeGroupMember.status = 'successful';
if (
state.currentGroup &&
state.currentGroup.id === action.payload.groupId
state.fetchGroupById.group &&
state.fetchGroupById.group.id === action.payload.groupId
) {
state.currentGroup.members =
state.currentGroup.members.filter(
state.fetchGroupById.group.members =
state.fetchGroupById.group.members.filter(
(m) => m.userId !== action.payload.memberId,
);
}
},
);
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
state.removeGroupMember.status = 'failed';
state.removeGroupMember.error = action.payload;
});
// fetchGroupJoinLink
builder.addCase(fetchGroupJoinLink.pending, (state) => {
state.fetchGroupJoinLink.status = 'loading';
});
builder.addCase(
removeGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.removeMember = 'failed';
state.error = action.payload;
fetchGroupJoinLink.fulfilled,
(
state,
action: PayloadAction<{ token: string; expiresAt: string }>,
) => {
state.fetchGroupJoinLink.status = 'successful';
state.fetchGroupJoinLink.joinLink = action.payload;
},
);
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
state.fetchGroupJoinLink.status = 'failed';
state.fetchGroupJoinLink.error = action.payload;
});
// joinGroupByToken
builder.addCase(joinGroupByToken.pending, (state) => {
state.joinGroupByToken.status = 'loading';
});
builder.addCase(
joinGroupByToken.fulfilled,
(state, action: PayloadAction<Group>) => {
state.joinGroupByToken.status = 'successful';
state.joinGroupByToken.group = action.payload;
state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список
},
);
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
state.joinGroupByToken.status = 'failed';
state.joinGroupByToken.error = action.payload;
});
},
});
export const { clearCurrentGroup } = groupsSlice.actions;
export const { setGroupsStatus } = groupsSlice.actions;
export const groupsReducer = groupsSlice.reducer;

View File

@@ -20,6 +20,8 @@ export interface Mission {
tags: string[];
createdAt: string;
updatedAt: string;
timeLimit: number;
memoryLimit: number;
statements?: Statement[];
}
@@ -31,6 +33,7 @@ interface MissionsState {
fetchList: Status;
fetchById: Status;
upload: Status;
fetchMy: Status;
};
error: string | null;
}
@@ -45,6 +48,7 @@ const initialState: MissionsState = {
fetchList: 'idle',
fetchById: 'idle',
upload: 'idle',
fetchMy: 'idle',
},
error: null,
};
@@ -90,6 +94,22 @@ export const fetchMissionById = createAsyncThunk(
},
);
// ✅ GET /missions/my
export const fetchMyMissions = createAsyncThunk(
'missions/fetchMyMissions',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/missions/my');
return response.data as Mission[]; // массив миссий пользователя
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при получении моих миссий',
);
}
},
);
// POST /missions/upload
export const uploadMission = createAsyncThunk(
'missions/uploadMission',
@@ -127,9 +147,6 @@ const missionsSlice = createSlice({
name: 'missions',
initialState,
reducers: {
clearCurrentMission: (state) => {
state.currentMission = null;
},
setMissionsStatus: (
state,
action: PayloadAction<{
@@ -189,6 +206,26 @@ const missionsSlice = createSlice({
},
);
// ✅ FETCH MY MISSIONS ───
builder.addCase(fetchMyMissions.pending, (state) => {
state.statuses.fetchMy = 'loading';
state.error = null;
});
builder.addCase(
fetchMyMissions.fulfilled,
(state, action: PayloadAction<Mission[]>) => {
state.statuses.fetchMy = 'successful';
state.missions = action.payload;
},
);
builder.addCase(
fetchMyMissions.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
state.error = action.payload;
},
);
// ─── UPLOAD MISSION ───
builder.addCase(uploadMission.pending, (state) => {
state.statuses.upload = 'loading';
@@ -211,5 +248,5 @@ const missionsSlice = createSlice({
},
});
export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions;
export const { setMissionsStatus } = missionsSlice.actions;
export const missionsReducer = missionsSlice.reducer;

View File

@@ -5,6 +5,7 @@ interface StorState {
menu: {
activePage: string;
activeProfilePage: string;
activeGroupPage: string;
};
}
@@ -13,6 +14,7 @@ const initialState: StorState = {
menu: {
activePage: '',
activeProfilePage: '',
activeGroupPage: '',
},
};
@@ -30,9 +32,19 @@ const storeSlice = createSlice({
) => {
state.menu.activeProfilePage = activeProfilePage.payload;
},
setMenuActiveGroupPage: (
state,
activeGroupPage: PayloadAction<string>,
) => {
state.menu.activeGroupPage = activeGroupPage.payload;
},
},
});
export const { setMenuActivePage, setMenuActiveProfilePage } =
storeSlice.actions;
export const {
setMenuActivePage,
setMenuActiveProfilePage,
setMenuActiveGroupPage,
} = storeSlice.actions;
export const storeReducer = storeSlice.reducer;

View File

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

View File

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

View File

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

32
src/styles/toast.css Normal file
View File

@@ -0,0 +1,32 @@
.Toastify__progress-bar--success {
background: #10be59 !important;
}
.Toastify__toast--success .Toastify__toast-icon svg path {
fill: #10be59 !important;
}
.Toastify__progress-bar--error {
background: #f13e5f !important;
}
.Toastify__toast--error .Toastify__toast-icon svg path {
fill: #f13e5f !important;
}
.Toastify__progress-bar--success {
background: #10be59 !important;
}
.Toastify__toast--success .Toastify__toast-icon svg path {
fill: #10be59 !important;
}
.Toastify__toast {
background: #292929 !important;
color: var(--color-liquid-white);
}
.Toastify__toast > button > svg {
fill: var(--color-liquid-white);
}

View File

@@ -4,18 +4,7 @@ import 'highlight.js/styles/github-dark.css';
import MarkdownPreview from './MarckDownPreview';
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue ||
`# 🌙 Добро пожаловать в Markdown-редактор
export const MarkDownPattern = `# 🌙 Добро пожаловать в Markdown-редактор
Добро пожаловать в **Markdown-редактор**!
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
@@ -209,13 +198,29 @@ print(greet("Мир"))
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
`,
`;
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue || MarkDownPattern,
);
useEffect(() => {
onChange(markdown);
}, [markdown]);
useEffect(() => {
setMarkdown(defaultValue || MarkDownPattern);
}, [defaultValue]);
// Обработчик вставки
const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement>,

View File

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

View File

@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AccountMenu from './AccoutMenu';
import RightPanel from './RightPanel';
import MissionsBlock from './MissionsBlock';
import ContestsBlock from './ContestsBlock';
import ArticlesBlock from './ArticlesBlock';
import Missions from './missions/Missions';
import Contests from './contests/Contests';
import ArticlesBlock from './articles/ArticlesBlock';
import { useAppDispatch } from '../../../redux/hooks';
import { useEffect } from 'react';
import { setMenuActivePage } from '../../../redux/slices/store';
@@ -24,18 +24,12 @@ const Account = () => {
</div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px] ">
<Routes>
<Route
path="missions"
element={<MissionsBlock />}
/>
<Route path="missions" element={<Missions />} />
<Route
path="articles"
element={<ArticlesBlock />}
/>
<Route
path="contests"
element={<ContestsBlock />}
/>
<Route path="contests" element={<Contests />} />
<Route
path="*"
element={

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
import { FC, useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
import { cn } from '../../../lib/cn';
import { ChevroneDown, Edit } from '../../../assets/icons/groups';
import { fetchArticles } from '../../../redux/slices/articles';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
import { cn } from '../../../../lib/cn';
import { ChevroneDown, Edit } from '../../../../assets/icons/groups';
import { fetchMyArticles } from '../../../../redux/slices/articles';
import { useNavigate } from 'react-router-dom';
export interface ArticleItemProps {
@@ -13,21 +12,21 @@ export interface ArticleItemProps {
tags: string[];
}
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
const ArticleItem: FC<ArticleItemProps> = ({ id, name, tags }) => {
const navigate = useNavigate();
return (
<div
className={cn(
'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
'gap-[20px] px-[20px] py-[10px] box-border ',
'gap-[20px] px-[20px] py-[10px] box-border',
'border-b-[1px] border-b-liquid-lighter cursor-pointer hover:bg-liquid-lighter transition-all duration-300',
)}
onClick={() => {
navigate(`/article/${id}?back=/home/account/articles`);
}}
onClick={() =>
navigate(`/article/${id}?back=/home/account/articles`)
}
>
<div className="h-[23px] flex ">
<div className="h-[23px] flex">
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
#{id}
</div>
@@ -35,13 +34,14 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
{name}
</div>
</div>
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
{tags.map((v, i) => (
<div
key={i}
className={cn(
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
v == 'Sertificated' && 'text-liquid-green',
v === 'Sertificated' && 'text-liquid-green',
)}
>
{v}
@@ -50,8 +50,9 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
</div>
<img
className=" absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
className="absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
src={Edit}
alt="Редактировать"
onClick={(e) => {
e.stopPropagation();
navigate(
@@ -69,49 +70,79 @@ interface ArticlesBlockProps {
const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
const dispatch = useAppDispatch();
const articles = useAppSelector((state) => state.articles.articles);
const [active, setActive] = useState<boolean>(true);
// ✅ Берём только "мои статьи"
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(() => {
dispatch(setMenuActiveProfilePage('articles'));
dispatch(fetchArticles({}));
}, []);
dispatch(fetchMyArticles());
}, [dispatch]);
return (
<div className="h-full w-full relative p-[20px]">
<div
className={cn(
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
'border-b-[1px] border-b-liquid-lighter rounded-[10px]',
className,
)}
>
{/* Заголовок */}
<div
className={cn(
' h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300',
active && ' border-b-liquid-lighter',
'h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300',
active && 'border-b-liquid-lighter',
)}
onClick={() => {
setActive(!active);
}}
onClick={() => setActive(!active)}
>
<span>Мои статьи</span>
<img
src={ChevroneDown}
alt="toggle"
className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div>
{/* Контент */}
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
'grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
active && 'grid-rows-[1fr] opacity-100',
)}
>
<div className="overflow-hidden">
<div className="grid gap-[20px] pt-[20px] pb-[20px] box-border">
{articles.map((v, i) => (
<ArticleItem key={i} {...v} />
{status === 'loading' && (
<div className="text-liquid-light">
Загрузка статей...
</div>
)}
{status === 'failed' && (
<div className="text-liquid-red">
Ошибка:{' '}
{error || 'Не удалось загрузить статьи'}
</div>
)}
{status === 'successful' &&
articles.length === 0 && (
<div className="text-liquid-light">
У вас пока нет статей
</div>
)}
{articles.map((v) => (
<ArticleItem key={v.id} {...v} />
))}
</div>
</div>

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
import {
fetchMyContests,
fetchRegisteredContests,
} from '../../../../redux/slices/contests';
import ContestsBlock from './ContestsBlock';
const Contests = () => {
const dispatch = useAppDispatch();
// Redux-состояния
const myContestsState = useAppSelector(
(state) => state.contests.fetchMyContests,
);
// При загрузке страницы — выставляем вкладку и подгружаем контесты
useEffect(() => {
dispatch(setMenuActiveProfilePage('contests'));
dispatch(fetchMyContests());
dispatch(fetchRegisteredContests({}));
}, []);
return (
<div className="h-full w-full relative flex flex-col text-[60px] font-bold p-[20px] gap-[20px]">
{/* Контесты, в которых я участвую */}
<div>
<ContestsBlock
className="mb-[20px]"
title="Предстоящие контесты"
type="reg"
// contests={regContestsState.contests}
contests={[]}
/>
</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
className="mb-[20px]"
title="Мои контесты"
type="my"
contests={myContestsState.contests}
/>
)}
</div>
</div>
);
};
export default Contests;

View File

@@ -0,0 +1,93 @@
import { useState, FC } from 'react';
import { cn } from '../../../../lib/cn';
import { ChevroneDown } from '../../../../assets/icons/groups';
import MyContestItem from './MyContestItem';
import RegisterContestItem from './RegisterContestItem';
import { Contest } from '../../../../redux/slices/contests';
interface ContestsBlockProps {
contests: Contest[];
title: string;
className?: string;
type?: 'my' | 'reg';
}
const ContestsBlock: FC<ContestsBlockProps> = ({
contests,
title,
className,
type = 'my',
}) => {
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>{title}</span>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div>
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
active && 'grid-rows-[1fr] opacity-100',
)}
>
<div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]">
{contests.map((v, i) => {
return type == 'my' ? (
<MyContestItem
key={i}
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'}
/>
);
})}
</div>
</div>
</div>
</div>
);
};
export default ContestsBlock;

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

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

@@ -0,0 +1,109 @@
import { FC, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
import { cn } from '../../../../lib/cn';
import MissionsBlock from './MissionsBlock';
import {
fetchMyMissions,
setMissionsStatus,
} from '../../../../redux/slices/missions';
interface ItemProps {
count: number;
totalCount: number;
title: string;
color?: 'default' | 'red' | 'green' | 'orange';
}
const Item: FC<ItemProps> = ({
count,
totalCount,
title,
color = 'default',
}) => {
return (
<div
className={cn(
'flex flex-row rounded-full bg-liquid-lighter px-[16px] py-[8px] gap-[10px] text-[14px]',
color == 'default' && 'text-liquid-light',
color == 'red' && 'text-liquid-red',
color == 'green' && 'text-liquid-green',
color == 'orange' && 'text-liquid-orange',
)}
>
<div>
{count}/{totalCount}
</div>
<div>{title}</div>
</div>
);
};
const Missions = () => {
const dispatch = useAppDispatch();
const missions = useAppSelector((state) => state.missions.missions);
const status = useAppSelector((state) => state.missions.statuses.fetchMy);
useEffect(() => {
dispatch(setMenuActiveProfilePage('missions'));
dispatch(fetchMyMissions());
}, []);
useEffect(() => {
dispatch(setMissionsStatus({ key: 'fetchMy', status: 'idle' }));
}, [status]);
return (
<div className="h-full w-full relative overflow-y-scroll medium-scrollbar">
<div className="w-full flex flex-col">
<div className="p-[20px] flex flex-col gap-[20px]">
<div className="text-[24px] font-bold text-liquid-white">
Решенные задачи
</div>
<div className="flex flex-row justify-between items-start">
<div className="flex gap-[10px]">
<Item count={14} totalCount={123} title="Задачи" />
</div>
<div className="flex gap-[20px]">
<Item
count={14}
totalCount={123}
title="Easy"
color="green"
/>
<Item
count={14}
totalCount={123}
title="Medium"
color="orange"
/>
<Item
count={14}
totalCount={123}
title="Hard"
color="red"
/>
</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 className="p-[20px]">
<MissionsBlock
missions={missions ?? []}
title="Мои миссии"
/>
</div>
</div>
</div>
);
};
export default Missions;

View File

@@ -0,0 +1,71 @@
import { useState, FC } from 'react';
import { cn } from '../../../../lib/cn';
import { ChevroneDown } from '../../../../assets/icons/groups';
import MyMissionItem from './MyMissionItem';
import { Mission } from '../../../../redux/slices/missions';
interface MissionsBlockProps {
missions: Mission[];
title: string;
className?: string;
}
const MissionsBlock: FC<MissionsBlockProps> = ({
missions,
title,
className,
}) => {
const [active, setActive] = useState<boolean>(true);
return (
<div
className={cn(
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
className,
)}
>
<div
className={cn(
' h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300',
active && 'border-b-liquid-lighter',
)}
onClick={() => {
setActive(!active);
}}
>
<span>{title}</span>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div>
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
active && 'grid-rows-[1fr] opacity-100',
)}
>
<div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]">
{missions.map((v, i) => (
<MyMissionItem
key={i}
id={v.id}
name={v.name}
timeLimit={v.timeLimit}
memoryLimit={v.memoryLimit}
difficulty={v.difficulty}
type={i % 2 ? 'second' : 'first'}
/>
))}
</div>
</div>
</div>
</div>
);
};
export default MissionsBlock;

View File

@@ -0,0 +1,89 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { Edit } from '../../../../assets/icons/input';
export interface MissionItemProps {
id: number;
authorId?: number;
name: string;
difficulty: number;
tags?: string[];
timeLimit?: number;
memoryLimit?: number;
createdAt?: string;
updatedAt?: string;
type?: 'first' | 'second';
status?: 'empty' | 'success' | 'error';
}
export function formatMilliseconds(ms: number): string {
const rounded = Math.round(ms) / 1000;
const formatted = rounded.toString().replace(/\.?0+$/, '');
return `${formatted} c`;
}
export function formatBytesToMB(bytes: number): string {
const megabytes = Math.floor(bytes / (1024 * 1024));
return `${megabytes} МБ`;
}
const MissionItem: React.FC<MissionItemProps> = ({
id,
name,
difficulty,
timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024,
type,
status,
}) => {
const navigate = useNavigate();
const difficultyItems = ['Easy', 'Medium', 'Hard'];
const difficultyString =
difficultyItems[Math.min(Math.max(0, difficulty - 1), 2)];
return (
<div
className={cn(
'min-h-[44px] w-full relative rounded-[10px] text-liquid-white py-[8px]',
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
'grid grid-cols-[80px,2fr,3fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center',
status == 'error' &&
'border-l-[11px] border-l-liquid-red pl-[9px]',
status == 'success' &&
'border-l-[11px] border-l-liquid-green pl-[9px]',
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
)}
onClick={() => {
navigate(`/mission/${id}?back=/home/account/missions`);
}}
>
<div className="text-[18px] font-bold">#{id}</div>
<div className="text-[18px] font-bold">{name}</div>
<div className="text-[12px] text-right">
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
{formatBytesToMB(memoryLimit)}
</div>
<div
className={cn(
'text-center text-[18px]',
difficultyString == 'Hard' && 'text-liquid-red',
difficultyString == 'Medium' && 'text-liquid-orange',
difficultyString == 'Easy' && 'text-liquid-green',
)}
>
{difficultyString}
</div>
<div className="h-[24px] w-[24px]">
<img
src={Edit}
className="hover:bg-liquid-light rounded-[8px] transition-all duration-300"
onClick={(e) => {
e.stopPropagation();
}}
/>
</div>
</div>
);
};
export default MissionItem;

View File

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

View File

@@ -0,0 +1,51 @@
import {
FilterDropDown,
FilterItem,
} from '../../../components/drop-down-list/Filter';
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
import { SearchInput } from '../../../components/input/SearchInput';
const Filters = () => {
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 (
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
<SorterDropDown
items={[
{
value: '1',
text: 'Сложность',
},
{
value: '2',
text: 'Дата создания',
},
{
value: '3',
text: 'ID',
},
]}
onChange={(v) => {}}
/>
<FilterDropDown
items={items}
defaultState={[]}
onChange={(values) => {}}
/>
</div>
);
};
export default Filters;

View File

@@ -1,8 +1,9 @@
// src/views/home/auth/Login.tsx
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store';
@@ -13,6 +14,7 @@ import { googleLogo } from '../../../assets/icons/input';
const Login = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
@@ -25,12 +27,14 @@ const Login = () => {
// После успешного логина
useEffect(() => {
dispatch(setMenuActivePage('account'));
console.log(submitClicked);
submitClicked;
}, []);
useEffect(() => {
if (jwt) {
navigate('/home/account'); // или другая страница после входа
const from = location.state?.from;
const path = from ? from.pathname + from.search : '/home/account';
navigate(path, { replace: true });
}
}, [jwt]);

View File

@@ -1,8 +1,9 @@
// src/views/home/auth/Register.tsx
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store';
@@ -15,6 +16,7 @@ import { googleLogo } from '../../../assets/icons/input';
const Register = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState<string>('');
const [email, setEmail] = useState<string>('');
@@ -32,9 +34,11 @@ const Register = () => {
useEffect(() => {
if (jwt) {
navigate('/home/account');
const from = location.state?.from;
const path = from ? from.pathname + from.search : '/home/account';
navigate(path, { replace: true });
}
console.log(submitClicked);
submitClicked;
}, [jwt]);
const handleRegister = () => {

View File

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

View File

@@ -4,12 +4,13 @@ import { useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
export interface MissionItemProps {
contestId: number;
id: number;
name: string;
timeLimit?: number;
memoryLimit?: number;
type?: 'first' | 'second';
status?: 'empty' | 'success' | 'error';
status?: 'success' | 'error';
}
export function formatMilliseconds(ms: number): string {
@@ -24,6 +25,7 @@ export function formatBytesToMB(bytes: number): string {
}
const MissionItem: React.FC<MissionItemProps> = ({
contestId,
id,
name,
timeLimit = 1000,
@@ -48,7 +50,7 @@ const MissionItem: React.FC<MissionItemProps> = ({
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
)}
onClick={() => {
navigate(`/mission/${id}?back=${path}`);
navigate(`/mission/${id}?back=${path}&contestId=${contestId}`);
}}
>
<div className="text-[18px] font-bold">#{id}</div>

View File

@@ -1,43 +1,124 @@
import { FC } from 'react';
import MissionItem from './MissionItem';
import { Contest } from '../../../redux/slices/contests';
import { FC, useEffect } from "react";
import MissionItem from "./MissionItem";
import {
Contest,
fetchMySubmissions,
setContestStatus,
} from "../../../redux/slices/contests";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { useNavigate } from "react-router-dom";
import { arrowLeft } from "../../../assets/icons/header";
export interface Article {
id: number;
name: string;
tags: string[];
id: number;
name: string;
tags: string[];
}
interface ContestMissionsProps {
contest: Contest | null;
contest?: Contest;
}
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
if (!contest) {
return <></>;
}
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { submissions, status } = useAppSelector(
(state) => state.contests.fetchMySubmissions
);
return (
<div className=" h-screen grid grid-rows-[74px,1fr] p-[20px] gap-[20px]">
<div className=""></div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className="h-[40px] w-ufll ">
{contest?.name} {contest.id}
</div>
<div className="w-full">
{contest.missions.map((v, i) => (
<MissionItem
id={v.id}
name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
type={i % 2 ? 'second' : 'first'}
/>
))}
</div>
</div>
useEffect(() => {
if (contest) dispatch(fetchMySubmissions(contest.id));
}, [contest]);
useEffect(() => {
if (status == "successful") {
dispatch(setContestStatus({ key: "fetchMySubmissions", status: "idle" }));
}
}, [status]);
if (!contest) {
return <></>;
}
const solvedCount = (contest.missions ?? []).filter((mission) =>
submissions.some(
(s) =>
s.solution.missionId === mission.id &&
s.solution.status === "Accepted: All tests passed"
)
).length;
const totalCount = contest.missions?.length ?? 0;
return (
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]">
<div className="">
<div className="h-[50px] text-[40px] text-liquid-white font-bold">
{contest.name}
</div>
);
<div className="flex justify-between h-[24px] items-center gap-[10px]">
<div className="flex items-center">
<img
src={arrowLeft}
className="cursor-pointer"
onClick={() => {
navigate(`/home/contests`);
}}
/>
<span className="text-liquid-light font-bold text-[18px]">
Контест #{contest.id}
</span>
</div>
<div>{contest.attemptDurationMinutes ?? 0} минут</div>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
<PrimaryButton
onClick={() => {
navigate(`/contest/${contest.id}/submissions`);
}}
text="Мои посылки"
/>
</div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className="w-full">
{(contest.missions ?? []).map((v, i) => {
const missionSubmissions = submissions.filter(
(s) => s.solution.missionId === v.id
);
const hasSuccess = missionSubmissions.some(
(s) => s.solution.status == "Accepted: All tests passed"
);
console.log(missionSubmissions);
const status = hasSuccess
? "success"
: missionSubmissions.length > 0
? "error"
: undefined;
return (
<MissionItem
contestId={contest.id}
key={i}
id={v.id}
name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
status={status}
type={i % 2 ? "second" : "first"}
/>
);
})}
</div>
</div>
</div>
);
};
export default ContestMissions;

View File

@@ -0,0 +1,94 @@
import { cn } from '../../../lib/cn';
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
// import { useNavigate } from "react-router-dom";
export interface SubmissionItemProps {
id: number;
datetime: string;
missionId: number;
language: string;
verdict: string;
duration: number;
memory: number;
type: 'first' | 'second';
status?: 'success' | 'wronganswer' | 'timelimit';
}
export function formatMilliseconds(ms: number): string {
const rounded = Math.round(ms) / 1000;
const formatted = rounded.toString().replace(/\.?0+$/, '');
return `${formatted} c`;
}
export function formatBytesToMB(bytes: number): string {
const megabytes = Math.floor(bytes / (1024 * 1024));
return `${megabytes} МБ`;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
const SubmissionItem: React.FC<SubmissionItemProps> = ({
id,
datetime,
missionId,
language,
verdict,
duration,
memory,
type,
status
}) => {
// const navigate = useNavigate();
return (
<div
className={cn(
' w-full relative rounded-[10px] text-liquid-white text-center text-bold text-[16px] py-[8px]',
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
'grid grid-cols-7 grid-flow-col gap-[20px] px-[20px] box-border items-center',
status == 'wronganswer' &&
'border-l-[11px] border-l-liquid-red pl-[9px]',
status == 'timelimit' &&
'border-l-[11px] border-l-liquid-orange pl-[9px]',
status == 'success' &&
'border-l-[11px] border-l-liquid-green pl-[9px]',
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
)}
onClick={() => {}}
>
<div className="text-[18px] font-bold">#{id}</div>
<div className="text-[18px] font-bold text-center">
{formatDate(datetime)}
</div>
<div>{missionId} </div>
<div className="text-[18px] font-bold text-center">{language}</div>
<div
className={cn(
'text-[18px] font-bold text-center',
status == 'wronganswer' && 'text-liquid-red',
status == 'timelimit' && 'text-liquid-orange',
status == 'success' && 'text-liquid-green',
)}
>
{verdict}
</div>
<div>{formatMilliseconds(duration)}</div>
<div>
{formatBytesToMB(memory)}
</div>
</div>
);
};
export default SubmissionItem;

View File

@@ -0,0 +1,129 @@
import SubmissionItem from "./SubmissionItem";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { FC, useEffect } from "react";
import {
Contest,
fetchMySubmissions,
setContestStatus,
} from "../../../redux/slices/contests";
import { arrowLeft } from "../../../assets/icons/header";
import { useNavigate } from "react-router-dom";
export interface Mission {
id: number;
authorId: number;
name: string;
difficulty: "Easy" | "Medium" | "Hard";
tags: string[];
timeLimit: number;
memoryLimit: number;
createdAt: string;
updatedAt: string;
}
interface SubmissionsProps {
contest: Contest;
}
const Submissions: FC<SubmissionsProps> = ({ contest }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { submissions, status } = useAppSelector(
(state) => state.contests.fetchMySubmissions
);
useEffect(() => {
if (contest && contest.id) dispatch(fetchMySubmissions(contest.id));
}, [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) =>
submissions.some(
(s) =>
s.solution.missionId === mission.id &&
s.solution.status === "Accepted: All tests passed"
)
).length;
const totalCount = contest.missions?.length ?? 0;
return (
<div className="h-full w-[calc(100%+250px)] box-border overflow-y-scroll overflow-x-hidden thin-scrollbar p-[20px] flex flex-col gap-[20px]">
<div className="">
<div className="h-[50px] text-[40px] text-liquid-white font-bold">
{contest.name}
</div>
<div className="flex justify-between h-[24px] items-center gap-[10px]">
<div className="flex items-center">
<img
src={arrowLeft}
className="cursor-pointer"
onClick={() => {
navigate(`/contest/${contest.id}`);
}}
/>
<span className="text-liquid-light font-bold text-[18px]">
Контест #{contest.id}
</span>
</div>
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
</div>
</div>
<div>
<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>
);
};
export default Submissions;

View File

@@ -98,22 +98,12 @@ const ContestItem: React.FC<ContestItemProps> = ({
{statusRegister == 'reg' ? (
<>
{' '}
<PrimaryButton
onClick={(e) => {
e.stopPropagation();
}}
text="Регистрация"
/>
<PrimaryButton onClick={() => {}} text="Регистрация" />
</>
) : (
<>
{' '}
<ReverseButton
onClick={(e) => {
e.stopPropagation();
}}
text="Вы записаны"
/>
<ReverseButton onClick={() => {}} text="Вы записаны" />
</>
)}
</div>

View File

@@ -6,6 +6,7 @@ import ContestsBlock from './ContestsBlock';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchContests } from '../../../redux/slices/contests';
import ModalCreateContest from './ModalCreate';
import Filters from './Filter';
const Contests = () => {
const dispatch = useAppDispatch();
@@ -14,9 +15,13 @@ const Contests = () => {
const [modalActive, setModalActive] = useState<boolean>(false);
// Берём данные из Redux
const contests = useAppSelector((state) => state.contests.contests);
const status = useAppSelector((state) => state.contests.statuses.create);
const error = useAppSelector((state) => state.contests.error);
const contests = useAppSelector(
(state) => state.contests.fetchContests.contests,
);
const status = useAppSelector(
(state) => state.contests.fetchContests.status,
);
const error = useAppSelector((state) => state.contests.fetchContests.error);
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => {
@@ -24,16 +29,6 @@ const Contests = () => {
dispatch(fetchContests({}));
}, []);
if (status == 'loading') {
return (
<div className="text-liquid-white p-4">Загрузка контестов...</div>
);
}
if (error) {
return <div className="text-red-500 p-4">Ошибка: {error}</div>;
}
return (
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
<div className="h-full box-border">
@@ -54,25 +49,40 @@ const Contests = () => {
/>
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]" />
<Filters />
{status == 'loading' && (
<div className="text-liquid-white p-4">
Загрузка контестов...
</div>
)}
{status == 'failed' && (
<div className="text-red-500 p-4">Ошибка: {error}</div>
)}
{status == 'successful' && (
<>
<ContestsBlock
className="mb-[20px]"
title="Текущие"
contests={contests.filter((contest) => {
const endTime = new Date(
contest.endsAt ?? new Date().toDateString(),
).getTime();
return endTime >= now.getTime();
})}
/>
<ContestsBlock
className="mb-[20px]"
title="Текущие"
contests={contests.filter((contest) => {
const endTime = new Date(contest.endsAt).getTime();
return endTime >= now.getTime();
})}
/>
<ContestsBlock
className="mb-[20px]"
title="Прошедшие"
contests={contests.filter((contest) => {
const endTime = new Date(contest.endsAt).getTime();
return endTime < now.getTime();
})}
/>
<ContestsBlock
className="mb-[20px]"
title="Прошедшие"
contests={contests.filter((contest) => {
const endTime = new Date(
contest.endsAt ?? new Date().toDateString(),
).getTime();
return endTime < now.getTime();
})}
/>
</>
)}
</div>
<ModalCreateContest

View File

@@ -55,13 +55,13 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
key={i}
id={v.id}
name={v.name}
startAt={v.startsAt}
startAt={v.startsAt ?? new Date().toString()}
statusRegister={'reg'}
duration={
new Date(v.endsAt).getTime() -
new Date(v.startsAt).getTime()
new Date(v.endsAt ?? new Date().toString()).getTime() -
new Date(v.startsAt ?? new Date().toString()).getTime()
}
members={v.members.length}
members={v.members?.length ?? 0}
type={i % 2 ? 'second' : 'first'}
/>
))}

View File

@@ -0,0 +1,51 @@
import {
FilterDropDown,
FilterItem,
} from '../../../components/drop-down-list/Filter';
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
import { SearchInput } from '../../../components/input/SearchInput';
const Filters = () => {
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 (
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
<SorterDropDown
items={[
{
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>
);
};
export default Filters;

View File

@@ -4,9 +4,13 @@ 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 } from '../../../redux/slices/contests';
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';
interface ModalCreateContestProps {
active: boolean;
@@ -18,28 +22,37 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
setActive,
}) => {
const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.contests.statuses.create);
const navigate = useNavigate();
const status = useAppSelector(
(state) => state.contests.createContest.status,
);
const [form, setForm] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: null,
endsAt: null,
attemptDurationMinutes: null,
maxAttempts: null,
startsAt: '',
endsAt: '',
attemptDurationMinutes: 0,
maxAttempts: 0,
allowEarlyFinish: false,
groupId: null,
missionIds: null,
articleIds: null,
participantIds: null,
organizerIds: null,
missionIds: [],
articleIds: [],
});
const contest = useAppSelector(
(state) => state.contests.createContest.contest,
);
useEffect(() => {
if (status === 'successful') {
setActive(false);
dispatch(
setContestStatus({ key: 'createContest', status: 'idle' }),
);
navigate(
`/contest/create?back=/home/account/contests&contestId=${contest.id}`,
);
}
}, [status]);
@@ -174,7 +187,9 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleSubmit}
onClick={() => {
handleSubmit();
}}
text="Создать"
disabled={status === 'loading'}
/>

View File

@@ -0,0 +1,52 @@
import { FC, useEffect } from 'react';
import { cn } from '../../../lib/cn';
import { useParams, Navigate, Routes, Route } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { fetchGroupById } from '../../../redux/slices/groups';
import GroupMenu from './GroupMenu';
import { Posts } from './posts/Posts';
import { SearchInput } from '../../../components/input/SearchInput';
import { Chat } from './chat/Chat';
import { Contests } from './contests/Contests';
interface GroupsBlockProps {}
const Group: FC<GroupsBlockProps> = () => {
const groupId = Number(useParams<{ groupId: string }>().groupId);
if (!groupId) {
return <Navigate to="/home/groups" replace />;
}
const dispatch = useAppDispatch();
const group = useAppSelector((state) => state.groups.fetchGroupById.group);
useEffect(() => {
dispatch(fetchGroupById(groupId));
}, [groupId]);
console.log(group);
return (
<div
className={cn(
' h-screen w-full text-liquid-white p-[20px] flex gap-[20px] flex-col',
)}
>
<div className="font-bold text-[40px]">{group?.name}</div>
<GroupMenu groupId={groupId} />
<Routes>
<Route path="home" element={<Posts groupId={groupId} />} />
<Route path="chat" element={<Chat />} />
<Route path="contests" element={<Contests />} />
<Route
path="*"
element={<Navigate to={`/group/${groupId}/home`} />}
/>
</Routes>
</div>
);
};
export default Group;

View File

@@ -0,0 +1,96 @@
import { MessageChat, Home, Cup } from '../../../assets/icons/group';
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import {
setMenuActivePage,
setMenuActiveProfilePage,
} from '../../../redux/slices/store';
interface MenuItemProps {
icon: string;
text: string;
href: string;
page: string;
profilePage: string;
active?: boolean;
}
const MenuItem: React.FC<MenuItemProps> = ({
icon,
text = '',
href = '',
active = false,
page = '',
profilePage = '',
}) => {
const dispatch = useAppDispatch();
return (
<Link
to={href}
className={`
flex items-center gap-3 p-[16px] rounded-[10px] h-[40px] text-[18px] font-bold
transition-all duration-300 text-liquid-white
active:scale-95 hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-light hover:ring-inset
${active && 'bg-liquid-lighter '}
`}
onClick={() => {
dispatch(setMenuActivePage(page));
dispatch(setMenuActiveProfilePage(profilePage));
}}
>
<img src={icon} />
<span>{text}</span>
</Link>
);
};
interface GroupMenuProps {
groupId: number;
}
const GroupMenu: FC<GroupMenuProps> = ({ groupId }) => {
const menuItems = [
{
text: 'Главная',
href: `/group/${groupId}/home`,
icon: Home,
page: 'group',
profilePage: 'home',
},
{
text: 'Чат',
href: `/group/${groupId}/chat`,
icon: MessageChat,
page: 'group',
profilePage: 'chat',
},
{
text: 'Контесты',
href: `/group/${groupId}/contests`,
icon: Cup,
page: 'group',
profilePage: 'contests',
},
];
const activeGroupPage = useAppSelector(
(state) => state.store.menu.activeGroupPage,
);
return (
<div className="w-full relative flex gap-[10px]">
{menuItems.map((v, i) => (
<MenuItem
{...v}
key={i}
active={activeGroupPage == v.profilePage}
/>
))}
</div>
);
};
export default GroupMenu;

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useAppDispatch } from '../../../../redux/hooks';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
export const Chat = () => {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setMenuActiveGroupPage('chat'));
}, []);
return <></>;
};

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useAppDispatch } from '../../../../redux/hooks';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
export const Contests = () => {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setMenuActiveGroupPage('contests'));
}, []);
return <></>;
};

View File

@@ -0,0 +1,72 @@
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 { createGroup } from '../../../../redux/slices/groups';
import MarkdownEditor from '../../../articleeditor/Editor';
import {
createPost,
setGroupFeedStatus,
} from '../../../../redux/slices/groupfeed';
interface ModalCreateProps {
groupId: number;
active: boolean;
setActive: (value: boolean) => void;
}
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive, groupId }) => {
// const [name, setName] = useState<string>('');
const [content, setContent] = useState<string>('');
const status = useAppSelector((state) => state.groupfeed.createPost.status);
const dispatch = useAppDispatch();
useEffect(() => {
if (status == 'successful') {
setActive(false);
dispatch(setGroupFeedStatus({ key: 'createPost', status: 'idle' }));
}
}, [status]);
return (
<Modal
className="bg-liquid-background h-[calc(100vh-30%)] border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white overflow-hidden"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="max-w-[1400px] h-full overflow-hidden">
<div className="font-bold text-[30px]">Создать пост</div>
<div className="h-[calc(100%-45px-60px)]">
<MarkdownEditor
onChange={(v) => {
setContent(v);
}}
/>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(
createPost({ name: '', content, groupId }),
);
}}
text={status == 'idle' ? 'Опубликовать' : 'Загрузка...'}
disabled={status == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalCreate;

View File

@@ -0,0 +1,140 @@
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 { createGroup } from '../../../../redux/slices/groups';
import MarkdownEditor, { MarkDownPattern } from '../../../articleeditor/Editor';
import {
createPost,
deletePost,
fetchPostById,
setGroupFeedStatus,
updatePost,
} from '../../../../redux/slices/groupfeed';
import { ReverseButton } from '../../../../components/button/ReverseButton';
import { cn } from '../../../../lib/cn';
interface ModalUpdateProps {
groupId: number;
postId: number;
active: boolean;
setActive: (value: boolean) => void;
}
const ModalUpdate: FC<ModalUpdateProps> = ({
active,
setActive,
groupId,
postId,
}) => {
// const [name, setName] = useState<string>('');
const [content, setContent] = useState<string>('');
const status = useAppSelector((state) => state.groupfeed.updatePost.status);
const statusDelete = useAppSelector(
(state) => state.groupfeed.deletePost.status,
);
const { post, status: statusPost } = useAppSelector(
(state) => state.groupfeed.fetchPostById,
);
const dispatch = useAppDispatch();
useEffect(() => {
if (status == 'successful') {
setActive(false);
dispatch(setGroupFeedStatus({ key: 'updatePost', status: 'idle' }));
}
}, [status]);
useEffect(() => {
if (statusDelete == 'successful') {
setActive(false);
dispatch(setGroupFeedStatus({ key: 'deletePost', status: 'idle' }));
}
}, [statusDelete]);
useEffect(() => {
dispatch(fetchPostById({ groupId, postId }));
}, [postId]);
return (
<Modal
className="bg-liquid-background h-[calc(100vh-30%)] border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white overflow-hidden"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="max-w-[1400px] h-full overflow-hidden transition-all duratoin-300">
<div className="font-bold text-[30px]">
Обновить пост #{post?.id}
</div>
<div
className={cn(
' absolute z-10 h-[calc(100%-100px)] w-[calc(100%-50px)] flex items-center justify-center text-transparent transition-all pointer-events-none ',
statusPost == 'loading' && 'text-liquid-white',
)}
>
<div>Загрузка...</div>
</div>
<div
className={cn(
'h-[calc(100%-45px-60px)] opacity-50 pointer-events-none transition-all ',
statusPost == 'successful' &&
'text-liquid-white pointer-events-auto opacity-100',
)}
>
<MarkdownEditor
defaultValue={
statusPost == 'successful'
? post?.content
: MarkDownPattern
}
onChange={(v) => {
setContent(v);
}}
/>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(
updatePost({
name: '',
content,
groupId,
postId,
}),
);
}}
text={status == 'idle' ? 'Сохранить' : 'Загрузка...'}
disabled={
status == 'loading' || statusPost != 'successful'
}
/>
<ReverseButton
onClick={() => {
dispatch(deletePost({ groupId, postId }));
}}
color="error"
text={
statusDelete == 'idle' ? 'Удалить' : 'Загрузка...'
}
disabled={
statusDelete == 'loading' ||
statusPost != 'successful'
}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalUpdate;

View File

@@ -0,0 +1,86 @@
import { FC } from 'react';
import { useAppSelector } from '../../../../redux/hooks';
import MarkdownPreview from '../../../articleeditor/MarckDownPreview';
import { Edit } from '../../../../assets/icons/input';
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 PostItemProps {
id: number;
groupId: number;
authorId: number;
authorUsername: string;
name: string;
content: string;
createdAt: string;
updatedAt: string;
isAdmin: boolean;
setModalUpdateActive: (v: boolean) => void;
setUpdatePostId: (v: number) => void;
}
export const PostItem: FC<PostItemProps> = ({
id,
groupId,
authorId,
authorUsername,
name,
content,
createdAt,
updatedAt,
isAdmin,
setModalUpdateActive,
setUpdatePostId,
}) => {
const members = useAppSelector(
(state) => state.groups.fetchGroupById.group?.members,
);
const member = members?.find((m) => m.userId === authorId);
return (
<div className="rounded-[10px] flex flex-col gap-[20px]">
<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>
{isAdmin && (
<div
className=" h-[40px] w-[40px] absolute top-0 right-0 flex items-center justify-center cursor-pointer
rounded-[10px] hover:bg-liquid-lighter transition-all duration-300 active:scale-90"
onClick={() => {
setUpdatePostId(id);
setModalUpdateActive(true);
}}
>
<img src={Edit} />
</div>
)}
</div>
<div>
<MarkdownPreview className="bg-transparent" content={content} />
</div>
</div>
);
};

View File

@@ -0,0 +1,111 @@
import { FC, useEffect, useState } from 'react';
import { useAppSelector, useAppDispatch } from '../../../../redux/hooks';
import { fetchGroupPosts } from '../../../../redux/slices/groupfeed';
import { SearchInput } from '../../../../components/input/SearchInput';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
import { fetchGroupById } from '../../../../redux/slices/groups';
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import ModalCreate from './ModalCreate';
import { PostItem } from './PostItem';
import ModalUpdate from './ModalUpdate';
interface PostsProps {
groupId: number;
}
export const Posts: FC<PostsProps> = ({ groupId }) => {
const dispatch = useAppDispatch();
const [modalCreateActive, setModalCreateActive] = useState<boolean>(false);
const [modalUpdateActive, setModalUpdateActive] = useState<boolean>(false);
const [updatePostId, setUpdatePostId] = useState<number>(0);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const { pages, status } = useAppSelector(
(state) => state.groupfeed.fetchPosts,
);
const { id: userId } = useAppSelector((state) => state.auth);
const { group } = useAppSelector((state) => state.groups.fetchGroupById);
// Загружаем только первую страницу
useEffect(() => {
dispatch(fetchGroupPosts({ groupId, page: 0, pageSize: 20 }));
dispatch(fetchGroupById(groupId));
}, [groupId]);
useEffect(() => {
dispatch(setMenuActiveGroupPage('home'));
}, []);
useEffect(() => {
if (!group) return;
const isUserAdmin =
group.members?.some(
(m) =>
Number(m.userId) === Number(userId) &&
m.role.includes('Administrator'),
) || false;
setIsAdmin(isUserAdmin);
}, [group, userId]);
const page0 = pages[0];
return (
<div className="h-full overflow-y-scroll thin-dark-scrollbar">
<div className="h-[40px] mb-[20px] relative">
<SearchInput
className="w-[216px]"
onChange={(v) => {}}
placeholder="Поиск сообщений"
/>
{isAdmin && (
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
<SecondaryButton
onClick={() => {
setModalCreateActive(true);
}}
text="Создать пост"
/>
</div>
)}
</div>
{status === 'loading' && <div>Загрузка...</div>}
{status === 'failed' && <div>Ошибка загрузки постов</div>}
{status == 'successful' &&
page0?.items &&
page0.items.length > 0 ? (
<div className="flex flex-col gap-[20px]">
{page0.items.map((post, i) => (
<PostItem
{...post}
key={i}
isAdmin={isAdmin}
setModalUpdateActive={setModalUpdateActive}
setUpdatePostId={setUpdatePostId}
/>
))}
</div>
) : status === 'successful' ? (
<div>Постов пока нет</div>
) : null}
<ModalCreate
active={modalCreateActive}
setActive={setModalCreateActive}
groupId={groupId}
/>
<ModalUpdate
active={modalUpdateActive}
setActive={setModalUpdateActive}
groupId={groupId}
postId={updatePostId}
/>
</div>
);
};

View File

@@ -0,0 +1,109 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActivePage } from '../../../redux/slices/store';
import { useQuery } from '../../../hooks/useQuery';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import {
joinGroupByToken,
setGroupsStatus,
} from '../../../redux/slices/groups';
const GroupInvite = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const query = useQuery();
const token = query.get('token') ?? undefined;
const expiresAt = query.get('expiresAt') ?? undefined;
const groupName = query.get('groupName') ?? undefined;
const groupId = Number(query.get('groupId') ?? undefined);
const username = useAppSelector((state) => state.auth.username);
const joinStatus = useAppSelector(
(state) => state.groups.joinGroupByToken.status,
);
const joinError = useAppSelector(
(state) => state.groups.joinGroupByToken.error,
);
useEffect(() => {
dispatch(setMenuActivePage('groups'));
}, []);
useEffect(() => {
if (joinStatus == 'successful') {
dispatch(
setGroupsStatus({ key: 'joinGroupByToken', status: 'idle' }),
);
navigate(`/group/${groupId}`);
}
}, [joinStatus]);
if (!token || !expiresAt || !groupName || !groupId) {
return (
<div className="h-full w-full box-border p-[20px] pt-[20p] flex items-center justify-center text-bold text-[36px]">
Приглашение признано недействительным.
</div>
);
}
const isExpired = new Date(expiresAt) < new Date();
if (isExpired) {
return (
<div className="h-full w-full box-border p-[20px] pt-[20px] flex items-center justify-center text-bold text-[36px]">
Период действия приглашения истек.
</div>
);
}
const handleJoin = async () => {
if (!token) return;
try {
await dispatch(joinGroupByToken(token)).unwrap();
} catch (err) {}
};
const handleCancel = () => {
navigate('/home/account');
};
return (
<div className="h-full w-full box-border flex">
<div className="p-[25px] text-liquid-white w-full">
<div className="font-bold text-[30px] mb-2">
Привет, {username}!
</div>
<div className="font-bold text-[25px]">
Вы действительно хотите присоединиться к группе:
</div>
<div className="font-bold text-[25px] mb-[20px]">
"{groupName}"
</div>
{joinError && (
<div className="text-red-500 mb-[10px]">
Ошибка присоединения: {joinError}
</div>
)}
<div className="flex flex-row w-full items-center justify-center mt-[30px] gap-[20px]">
<PrimaryButton
onClick={handleJoin}
text={
joinStatus === 'loading'
? 'Присоединяемся...'
: 'Присоединиться'
}
disabled={joinStatus === 'loading'}
/>
<SecondaryButton onClick={handleCancel} text="Отмена" />
</div>
</div>
</div>
);
};
export default GroupInvite;

View File

@@ -0,0 +1,51 @@
import {
FilterDropDown,
FilterItem,
} from '../../../components/drop-down-list/Filter';
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
import { SearchInput } from '../../../components/input/SearchInput';
const Filters = () => {
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 (
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
<SorterDropDown
items={[
{
value: '1',
text: 'Сложность',
},
{
value: '2',
text: 'Дата создания',
},
{
value: '3',
text: 'ID',
},
]}
onChange={(v) => {}}
/>
<FilterDropDown
items={items}
defaultState={[]}
onChange={(values) => {}}
/>
</div>
);
};
export default Filters;

View File

@@ -1,26 +0,0 @@
import { FC } from 'react';
import { cn } from '../../../lib/cn';
import { useParams, Navigate } from 'react-router-dom';
interface GroupsBlockProps {}
const Group: FC<GroupsBlockProps> = () => {
const { groupId } = useParams<{ groupId: string }>();
const groupIdNumber = Number(groupId);
if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) {
return <Navigate to="/home/groups" replace />;
}
return (
<div
className={cn(
'border-b-[1px] border-b-liquid-lighter rounded-[10px]',
)}
>
{groupIdNumber}
</div>
);
};
export default Group;

View File

@@ -7,7 +7,7 @@ import {
EyeOpen,
} from '../../../assets/icons/groups';
import { useNavigate } from 'react-router-dom';
import { GroupUpdate } from './Groups';
import { GroupInvite, GroupUpdate } from './Groups';
export interface GroupItemProps {
id: number;
@@ -17,6 +17,9 @@ export interface GroupItemProps {
description: string;
setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void;
setInviteActive: (value: any) => void;
setInviteGroup: (value: GroupInvite) => void;
type: 'manage' | 'member';
}
interface IconComponentProps {
@@ -45,6 +48,9 @@ const GroupItem: React.FC<GroupItemProps> = ({
description,
setUpdateGroup,
setUpdateActive,
setInviteActive,
setInviteGroup,
type,
}) => {
const navigate = useNavigate();
@@ -63,10 +69,16 @@ const GroupItem: React.FC<GroupItemProps> = ({
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
<div className="text-[18px] font-bold">{name}</div>
<div className=" flex gap-[10px]">
{(role == 'menager' || role == 'owner') && (
<IconComponent src={UserAdd} />
{type == 'manage' && (
<IconComponent
src={UserAdd}
onClick={() => {
setInviteActive(true);
setInviteGroup({ id, name });
}}
/>
)}
{(role == 'menager' || role == 'owner') && (
{type == 'manage' && (
<IconComponent
src={Edit}
onClick={() => {

View File

@@ -7,6 +7,8 @@ import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchMyGroups } from '../../../redux/slices/groups';
import ModalCreate from './ModalCreate';
import ModalUpdate from './ModalUpdate';
import Filters from './Filter';
import ModalInvite from './ModalInvite';
export interface GroupUpdate {
id: number;
@@ -14,19 +16,35 @@ export interface GroupUpdate {
description: string;
}
export interface GroupInvite {
id: number;
name: string;
}
const Groups = () => {
const [modalActive, setModalActive] = useState<boolean>(false);
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false);
const [modalActive, setModalActive] = useState(false);
const [modalUpdateActive, setModalUpdateActive] = useState(false);
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
id: 0,
name: '',
description: '',
});
const [modalInviteActive, setModalInviteActive] = useState(false);
const [inviteGroup, setInviteGroup] = useState<GroupInvite>({
id: 0,
name: '',
});
const dispatch = useAppDispatch();
// Берём группы из стора
const groups = useAppSelector((store) => store.groups.groups);
// Берём группы и статус из нового слайса
const groups = useAppSelector((store) => store.groups.fetchMyGroups.groups);
const groupsStatus = useAppSelector(
(store) => store.groups.fetchMyGroups.status,
);
const groupsError = useAppSelector(
(store) => store.groups.fetchMyGroups.error,
);
// Берём текущего пользователя
const currentUserName = useAppSelector((store) => store.auth.username);
@@ -51,8 +69,8 @@ const Groups = () => {
(m) => m.username === currentUserName,
);
if (!me) return;
if (me.role === 'Administrator') {
const roles = me.role.split(',').map((r) => r.trim());
if (roles.includes('Administrator')) {
managed.push(group);
} else {
current.push(group);
@@ -67,7 +85,7 @@ const Groups = () => {
}, [groups, currentUserName]);
return (
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
<div className="h-full box-border">
<div className="relative flex items-center mb-[20px]">
<div
@@ -78,47 +96,75 @@ const Groups = () => {
Группы
</div>
<SecondaryButton
onClick={() => {
setModalActive(true);
}}
onClick={() => setModalActive(true)}
text="Создать группу"
className="absolute right-0"
/>
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
<Filters />
<GroupsBlock
className="mb-[20px]"
title="Управляемые"
groups={managedGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
title="Текущие"
groups={currentGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
title="Скрытые"
groups={hiddenGroups} // пока пусто
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
{groupsStatus === 'loading' && (
<div className="text-liquid-white mt-4">
Загрузка групп...
</div>
)}
{groupsStatus === 'failed' && (
<div className="text-red-400 mt-4">
Ошибка: {groupsError || 'Не удалось загрузить группы'}
</div>
)}
{groupsStatus === 'successful' && (
<>
<GroupsBlock
className="mb-[20px]"
title="Управляемые"
groups={managedGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setModalInviteActive}
setInviteGroup={setInviteGroup}
type="manage"
/>
<GroupsBlock
className="mb-[20px]"
title="Текущие"
groups={currentGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setModalInviteActive}
setInviteGroup={setInviteGroup}
type="member"
/>
<GroupsBlock
className="mb-[20px]"
title="Скрытые"
groups={hiddenGroups} // пока пусто
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setModalInviteActive}
setInviteGroup={setInviteGroup}
type="member"
/>
</>
)}
</div>
<ModalCreate setActive={setModalActive} active={modalActive} />
<ModalUpdate
setActive={setModalUpdateActive}
active={modelUpdateActive}
active={modalUpdateActive}
groupId={updateGroup.id}
groupName={updateGroup.name}
groupDescription={updateGroup.description}
/>
<ModalInvite
setActive={setModalInviteActive}
active={modalInviteActive}
groupId={inviteGroup.id}
groupName={inviteGroup.name}
/>
</div>
);
};

View File

@@ -3,7 +3,7 @@ import GroupItem from './GroupItem';
import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups';
import { Group } from '../../../redux/slices/groups';
import { GroupUpdate } from './Groups';
import { GroupInvite, GroupUpdate } from './Groups';
interface GroupsBlockProps {
groups: Group[];
@@ -11,6 +11,9 @@ interface GroupsBlockProps {
className?: string;
setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void;
setInviteActive: (value: any) => void;
setInviteGroup: (value: GroupInvite) => void;
type: 'manage' | 'member';
}
const GroupsBlock: FC<GroupsBlockProps> = ({
@@ -19,6 +22,9 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
className,
setUpdateActive,
setUpdateGroup,
setInviteActive,
setInviteGroup,
type,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -63,8 +69,11 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setInviteActive}
setInviteGroup={setInviteGroup}
role={'owner'}
name={v.name}
type={type}
/>
))}
</div>

View File

@@ -14,7 +14,7 @@ interface ModalCreateProps {
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const status = useAppSelector((state) => state.groups.statuses.create);
const status = useAppSelector((state) => state.groups.createGroup.status);
const dispatch = useAppDispatch();
useEffect(() => {

View File

@@ -0,0 +1,102 @@
import { FC, useEffect, useMemo } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { fetchGroupJoinLink } from '../../../redux/slices/groups';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
interface ModalInviteProps {
active: boolean;
setActive: (value: boolean) => void;
groupId: number;
groupName: string;
}
const ModalInvite: FC<ModalInviteProps> = ({
active,
setActive,
groupId,
groupName,
}) => {
const dispatch = useAppDispatch();
const baseUrl = window.location.origin;
// Получаем токен и дату из Redux
const { joinLink, status } = useAppSelector(
(state) => state.groups.fetchGroupJoinLink,
);
// При открытии модалки запрашиваем join link
useEffect(() => {
if (active) {
dispatch(fetchGroupJoinLink(groupId));
}
}, [active, groupId, dispatch]);
// Генерация полной ссылки с query параметрами
const inviteLink = useMemo(() => {
if (!joinLink) return '';
const params = new URLSearchParams({
token: joinLink.token,
expiresAt: joinLink.expiresAt,
groupName,
groupId: `${groupId}`,
});
return `${baseUrl}/home/group-invite?${params.toString()}`;
}, [joinLink, groupName, baseUrl, groupId]);
// Копирование и закрытие модалки
const handleCopy = async () => {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink);
setActive(false);
} catch (err) {
console.error('Не удалось скопировать ссылку:', err);
}
};
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] mb-[20px]">
Приглашение в группу "{groupName}"
</div>
<div className="">
<div className="font-bold text-[18px] mb-[5px]">
Ссылка для приглашения
</div>
<div
className=" break-all break-words text-[#5d96ff] hover:underline cursor-pointer"
onClick={handleCopy}
>
{inviteLink}
</div>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[30px] gap-[20px]">
<PrimaryButton
onClick={handleCopy}
text={
status === 'loading' ? 'Загрузка...' : 'Скопировать'
}
disabled={status === 'loading' || !inviteLink}
/>
<SecondaryButton
onClick={() => setActive(false)}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalInvite;

View File

@@ -24,10 +24,10 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const statusUpdate = useAppSelector(
(state) => state.groups.statuses.update,
(state) => state.groups.updateGroup.status,
);
const statusDelete = useAppSelector(
(state) => state.groups.statuses.delete,
(state) => state.groups.deleteGroup.status,
);
const dispatch = useAppDispatch();

View File

@@ -1,4 +1,4 @@
import { Logo } from '../../../assets/logos';
import { Logo, LogoFASIE } from '../../../assets/logos';
import {
Account,
Clipboard,
@@ -42,7 +42,7 @@ const Menu = () => {
const activePage = useAppSelector((state) => state.store.menu.activePage);
return (
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
<div className="w-[250px] h-full fixed top-0 items-center box-border p-[20px] pt-[35px]">
<img src={Logo} className="w-[173px]" />
<div className="">
{menuItems.map((v, i) => (
@@ -56,6 +56,15 @@ const Menu = () => {
/>
))}
</div>
<div className="h-[300px] w-full absolute left-0 bottom-[10px] p-[20px] flex flex-col justify-end gap-[20px]">
<img src={LogoFASIE} />
<div className="text-[12px] font-normal leading-[15px] text-liquid-light">
{
'Проект «LiquidCode» создан при поддержке Федерального государственного бюджетного учреждения «Фонд содействия развитию малых форм предприятий в научно-технической сфере» в рамках программы «Студенческий стартап» федерального проекта «Платформа университетского технологического предпринимательства»'
}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import {
FilterDropDown,
FilterItem,
} from '../../../components/drop-down-list/Filter';
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
import { SearchInput } from '../../../components/input/SearchInput';
const Filters = () => {
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 (
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
<SorterDropDown
items={[
{
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>
);
};
export default Filters;

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchMissions } from '../../../redux/slices/missions';
import ModalCreate from './ModalCreate';
import Filters from './Filter';
export interface Mission {
id: number;
@@ -45,7 +46,7 @@ const Missions = () => {
/>
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
<Filters />
<div>
{missions.map((v, i) => (

View File

@@ -97,7 +97,7 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
<div className="mt-4">
<label className="block mb-2">Файл задачи</label>
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 bg-liquid-lighter hover:bg-liquid-dark transition-colors rounded-[10px] text-liquid-white font-medium shadow-md">
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 bg-liquid-lighter hover:bg-liquid-dark transition-colors rounded-[10px] text-liquid-white font-medium">
{file ? file.name : 'Выбрать файл'}
<input
type="file"

View File

@@ -0,0 +1,40 @@
import { FC } from 'react';
export const ArticlesRightPanel: FC = () => {
const items = [
{
name: 'Энтузиаст создал карточки с NFC-метками для знакомства ребёнка с музыкой',
},
{
name: 'Алгоритм Древа Силы, Космический Сортировщик',
},
{
name: 'Космический Сортировщик',
},
{
name: 'Зеркала Многомерности',
},
];
return (
<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>
{items.map((v, i) => {
return (
<>
{
<div className="font-bold text-liquid-light text-[16px]">
{v.name}
</div>
}
{i + 1 != items.length && (
<div className="h-[1px] w-full bg-liquid-lighter"></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

@@ -0,0 +1,68 @@
import { FC } from 'react';
import { cn } from '../../../lib/cn';
export const MissionsRightPanel: FC = () => {
const items = [
{
name: 'Кромсатели металла v4',
difficulty: 'Easy',
tags: ['strings', 'arrays', 'math'],
},
{
name: 'Алгоритм Древа Силы',
difficulty: 'Medium',
tags: ['trees', 'dfs', 'recursion'],
},
{
name: 'Космический Сортировщик',
difficulty: 'Hard',
tags: ['sorting', 'optimization', 'greedy'],
},
{
name: 'Зеркала Многомерности',
difficulty: 'Medium',
tags: ['matrix', 'geometry', 'simulation'],
},
];
return (
<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>
{items.map((v, i) => {
return (
<>
{
<div className="text-liquid-light text-[16px]">
<div className="font-bold ">{v.name}</div>
<div
className={cn(
'',
v.difficulty == 'Hard' &&
'text-liquid-red',
v.difficulty == 'Medium' &&
'text-liquid-orange',
v.difficulty == 'Easy' &&
'text-liquid-green',
)}
>
{v.difficulty}
</div>
<div className="flex gap-[10px] overflow-hidden">
{v.tags.slice(0, 2).map((v, i) => (
<div key={i}>{v}</div>
))}
{v.tags.length > 2 && '...'}
</div>
</div>
}
{i + 1 != items.length && (
<div className="h-[1px] w-full bg-liquid-lighter"></div>
)}
</>
);
})}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import SubmissionItem from './SubmissionItem';
import { useAppSelector } from '../../../redux/hooks';
import { FC, useEffect } from 'react';
import { FC } from 'react';
export interface Mission {
id: number;
@@ -16,45 +16,46 @@ export interface Mission {
interface MissionSubmissionsProps {
missionId: number;
contestId?: number;
}
const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId }) => {
const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId, contestId }) => {
const submissions = useAppSelector(
(state) => state.submin.submitsById[missionId],
(state) => state.submin.submitsById[missionId] || []
);
useEffect(() => {}, []);
const checkStatus = (status: string) => {
if (status == 'IncorrectAnswer') return 'wronganswer';
if (status == 'TimeLimitError') return 'timelimit';
if (status === 'IncorrectAnswer') return 'wronganswer';
if (status === 'TimeLimitError') return 'timelimit';
return undefined;
};
// Если contestId передан, фильтруем по нему, иначе показываем все
const filteredSubmissions = contestId
? submissions.filter((v) => v.contestId === contestId)
: submissions;
return (
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
{submissions &&
submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id}
language={v.solution.language}
time={v.solution.time}
verdict={
v.solution.testerMessage?.includes(
'Compilation failed',
)
? 'Compilation failed'
: v.solution.testerMessage
}
type={i % 2 ? 'second' : 'first'}
status={
v.solution.testerMessage == 'All tests passed'
? 'success'
: checkStatus(v.solution.testerErrorCode)
}
/>
))}
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
{filteredSubmissions.map((v, i) => (
<SubmissionItem
key={v.id}
id={v.id}
language={v.solution.language}
time={v.solution.time}
verdict={
v.solution.testerMessage?.includes('Compilation failed')
? 'Compilation failed'
: v.solution.testerMessage
}
type={i % 2 ? 'second' : 'first'}
status={
v.solution.testerMessage === 'All tests passed'
? 'success'
: checkStatus(v.solution.testerErrorCode)
}
/>
))}
</div>
);
};

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/axios.ts","./src/main.tsx","./src/vite-env.d.ts","./src/assets/icons/account/index.ts","./src/assets/icons/auth/index.ts","./src/assets/icons/groups/index.ts","./src/assets/icons/header/index.ts","./src/assets/icons/input/index.ts","./src/assets/icons/menu/index.ts","./src/assets/icons/missions/index.ts","./src/assets/logos/index.ts","./src/components/button/primarybutton.tsx","./src/components/button/reversebutton.tsx","./src/components/button/secondarybutton.tsx","./src/components/checkbox/checkbox.tsx","./src/components/drop-down-list/dropdownlist.tsx","./src/components/input/daterangeinput.tsx","./src/components/input/input.tsx","./src/components/modal/modal.tsx","./src/components/router/protectedroute.tsx","./src/components/switch/switch.tsx","./src/config/colors.ts","./src/hooks/useclickoutside.ts","./src/hooks/usequery.ts","./src/lib/cn.ts","./src/pages/article.tsx","./src/pages/articleeditor.tsx","./src/pages/home.tsx","./src/pages/mission.tsx","./src/redux/hooks.ts","./src/redux/store.ts","./src/redux/slices/account.ts","./src/redux/slices/articles.ts","./src/redux/slices/auth.ts","./src/redux/slices/contests.ts","./src/redux/slices/groups.ts","./src/redux/slices/missions.ts","./src/redux/slices/store.ts","./src/redux/slices/submit.ts","./src/views/article/header.tsx","./src/views/articleeditor/editor.tsx","./src/views/articleeditor/header.tsx","./src/views/articleeditor/marckdownpreview.tsx","./src/views/home/account/account.tsx","./src/views/home/account/accoutmenu.tsx","./src/views/home/account/articlesblock.tsx","./src/views/home/account/contestsblock.tsx","./src/views/home/account/missionsblock.tsx","./src/views/home/account/rightpanel.tsx","./src/views/home/articles/articleitem.tsx","./src/views/home/articles/articles.tsx","./src/views/home/auth/login.tsx","./src/views/home/auth/register.tsx","./src/views/home/contest/contest.tsx","./src/views/home/contest/missionitem.tsx","./src/views/home/contest/missions.tsx","./src/views/home/contest/submissions.tsx","./src/views/home/contests/contestitem.tsx","./src/views/home/contests/contests.tsx","./src/views/home/contests/contestsblock.tsx","./src/views/home/contests/modalcreate.tsx","./src/views/home/groups/group.tsx","./src/views/home/groups/groupitem.tsx","./src/views/home/groups/groups.tsx","./src/views/home/groups/groupsblock.tsx","./src/views/home/groups/modalcreate.tsx","./src/views/home/groups/modalupdate.tsx","./src/views/home/menu/menu.tsx","./src/views/home/menu/menuitem.tsx","./src/views/home/missions/missionitem.tsx","./src/views/home/missions/missions.tsx","./src/views/home/missions/modalcreate.tsx","./src/views/mission/codeeditor/codeeditor.tsx","./src/views/mission/statement/header.tsx","./src/views/mission/statement/latextcontainer.tsx","./src/views/mission/statement/missionsubmissions.tsx","./src/views/mission/statement/statement.tsx","./src/views/mission/statement/submissionitem.tsx","./src/views/mission/submission/submission.tsx"],"version":"5.6.2"}
{"root":["./src/app.tsx","./src/axios.ts","./src/main.tsx","./src/vite-env.d.ts","./src/assets/icons/account/index.ts","./src/assets/icons/auth/index.ts","./src/assets/icons/filters/index.ts","./src/assets/icons/groups/index.ts","./src/assets/icons/header/index.ts","./src/assets/icons/input/index.ts","./src/assets/icons/menu/index.ts","./src/assets/icons/missions/index.ts","./src/assets/logos/index.ts","./src/components/button/primarybutton.tsx","./src/components/button/reversebutton.tsx","./src/components/button/secondarybutton.tsx","./src/components/checkbox/checkbox.tsx","./src/components/drop-down-list/dropdownlist.tsx","./src/components/drop-down-list/filter.tsx","./src/components/drop-down-list/sorter.tsx","./src/components/input/daterangeinput.tsx","./src/components/input/input.tsx","./src/components/input/searchinput.tsx","./src/components/modal/modal.tsx","./src/components/router/protectedroute.tsx","./src/components/switch/switch.tsx","./src/config/colors.ts","./src/hooks/useclickoutside.ts","./src/hooks/usequery.ts","./src/lib/cn.ts","./src/pages/article.tsx","./src/pages/articleeditor.tsx","./src/pages/contesteditor.tsx","./src/pages/home.tsx","./src/pages/mission.tsx","./src/redux/hooks.ts","./src/redux/store.ts","./src/redux/slices/articles.ts","./src/redux/slices/auth.ts","./src/redux/slices/contests.ts","./src/redux/slices/groups.ts","./src/redux/slices/missions.ts","./src/redux/slices/store.ts","./src/redux/slices/submit.ts","./src/views/article/header.tsx","./src/views/articleeditor/editor.tsx","./src/views/articleeditor/header.tsx","./src/views/articleeditor/marckdownpreview.tsx","./src/views/home/account/account.tsx","./src/views/home/account/accoutmenu.tsx","./src/views/home/account/rightpanel.tsx","./src/views/home/account/articles/articlesblock.tsx","./src/views/home/account/contests/contests.tsx","./src/views/home/account/contests/contestsblock.tsx","./src/views/home/account/contests/mycontestitem.tsx","./src/views/home/account/contests/registercontestitem.tsx","./src/views/home/account/missions/missions.tsx","./src/views/home/account/missions/missionsblock.tsx","./src/views/home/account/missions/mymissionitem.tsx","./src/views/home/articles/articleitem.tsx","./src/views/home/articles/articles.tsx","./src/views/home/articles/filter.tsx","./src/views/home/auth/login.tsx","./src/views/home/auth/register.tsx","./src/views/home/contest/contest.tsx","./src/views/home/contest/missionitem.tsx","./src/views/home/contest/missions.tsx","./src/views/home/contest/submissionitem.tsx","./src/views/home/contest/submissions.tsx","./src/views/home/contests/contestitem.tsx","./src/views/home/contests/contests.tsx","./src/views/home/contests/contestsblock.tsx","./src/views/home/contests/filter.tsx","./src/views/home/contests/modalcreate.tsx","./src/views/home/groups/filter.tsx","./src/views/home/groups/group.tsx","./src/views/home/groups/groupitem.tsx","./src/views/home/groups/groups.tsx","./src/views/home/groups/groupsblock.tsx","./src/views/home/groups/modalcreate.tsx","./src/views/home/groups/modalupdate.tsx","./src/views/home/menu/menu.tsx","./src/views/home/menu/menuitem.tsx","./src/views/home/missions/filter.tsx","./src/views/home/missions/missionitem.tsx","./src/views/home/missions/missions.tsx","./src/views/home/missions/modalcreate.tsx","./src/views/mission/codeeditor/codeeditor.tsx","./src/views/mission/statement/header.tsx","./src/views/mission/statement/latextcontainer.tsx","./src/views/mission/statement/missionsubmissions.tsx","./src/views/mission/statement/statement.tsx","./src/views/mission/statement/submissionitem.tsx","./src/views/mission/submission/submission.tsx"],"version":"5.6.2"}