Merge remote-tracking branch 'origin/dev'
Some checks failed
Build and Push Docker Image / build (push) Failing after 48s
1434
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
14
src/App.tsx
@@ -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 path="/home/*" element={<Home />} />
|
||||
<Route path="/mission/:missionId" element={<Mission />} />
|
||||
<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/:articleId" element={<Article />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
|
||||
3
src/assets/icons/filters/filters-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V6.17C1.99986 6.58294 2.08497 6.99147 2.25 7.37V7.43C2.39128 7.75097 2.59139 8.04266 2.84 8.29L9 14.41V21C8.99966 21.1699 9.04264 21.3372 9.12487 21.4859C9.20711 21.6346 9.32589 21.7599 9.47 21.85C9.62914 21.9486 9.81277 22.0006 10 22C10.1565 21.9991 10.3107 21.9614 10.45 21.89L14.45 19.89C14.6149 19.8069 14.7536 19.6798 14.8507 19.5227C14.9478 19.3656 14.9994 19.1847 15 19V14.41L21.12 8.29C21.3686 8.04266 21.5687 7.75097 21.71 7.43V7.37C21.8888 6.99443 21.9876 6.58578 22 6.17V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM13.29 13.29C13.1973 13.3834 13.124 13.4943 13.0742 13.6161C13.0245 13.7379 12.9992 13.8684 13 14V18.38L11 19.38V14C11.0008 13.8684 10.9755 13.7379 10.9258 13.6161C10.876 13.4943 10.8027 13.3834 10.71 13.29L5.41 8H18.59L13.29 13.29ZM20 6H4V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V6Z" fill="#00DBD9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
src/assets/icons/filters/filters.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V6.17C1.99986 6.58294 2.08497 6.99147 2.25 7.37V7.43C2.39128 7.75097 2.59139 8.04266 2.84 8.29L9 14.41V21C8.99966 21.1699 9.04264 21.3372 9.12487 21.4859C9.20711 21.6346 9.32589 21.7599 9.47 21.85C9.62914 21.9486 9.81277 22.0006 10 22C10.1565 21.9991 10.3107 21.9614 10.45 21.89L14.45 19.89C14.6149 19.8069 14.7536 19.6798 14.8507 19.5227C14.9478 19.3656 14.9994 19.1847 15 19V14.41L21.12 8.29C21.3686 8.04266 21.5687 7.75097 21.71 7.43V7.37C21.8888 6.99443 21.9876 6.58578 22 6.17V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM13.29 13.29C13.1973 13.3834 13.124 13.4943 13.0742 13.6161C13.0245 13.7379 12.9992 13.8684 13 14V18.38L11 19.38V14C11.0008 13.8684 10.9755 13.7379 10.9258 13.6161C10.876 13.4943 10.8027 13.3834 10.71 13.29L5.41 8H18.59L13.29 13.29ZM20 6H4V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V6Z" fill="#576466"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
7
src/assets/icons/filters/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import iconFilterActive from './filters-active.svg';
|
||||
import iconFilter from './filters.svg';
|
||||
import iconSort from './sort.svg';
|
||||
import iconSortActive from './sort-active.svg';
|
||||
import iconSearch from './search.svg';
|
||||
|
||||
export { iconFilter, iconFilterActive, iconSort, iconSortActive, iconSearch };
|
||||
3
src/assets/icons/filters/search.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.927 17.04L20.4001 20.4M19.2801 11.44C19.2801 15.7699 15.77 19.28 11.4401 19.28C7.11019 19.28 3.6001 15.7699 3.6001 11.44C3.6001 7.11009 7.11019 3.60001 11.4401 3.60001C15.77 3.60001 19.2801 7.11009 19.2801 11.44Z" stroke="#576466" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
3
src/assets/icons/filters/sort-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.4415 6.62732H22M13.4415 11.4421H19.5547M13.4415 16.2569H17.1094M5.80564 6V18M5.80564 18L2 14.3317M5.80564 18L9.7566 14.3317" stroke="#00DBD9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
3
src/assets/icons/filters/sort.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.4415 6.62732H22M13.4415 11.4421H19.5547M13.4415 16.2569H17.1094M5.80564 6V18M5.80564 18L2 14.3317M5.80564 18L9.7566 14.3317" stroke="#576466" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
3
src/assets/icons/group/cup.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 4H18V3C18 2.73478 17.8946 2.48043 17.7071 2.29289C17.5196 2.10536 17.2652 2 17 2H7C6.73478 2 6.48043 2.10536 6.29289 2.29289C6.10536 2.48043 6 2.73478 6 3V4H3C2.73478 4 2.48043 4.10536 2.29289 4.29289C2.10536 4.48043 2 4.73478 2 5V8C2 9.06087 2.42143 10.0783 3.17157 10.8284C3.92172 11.5786 4.93913 12 6 12H7.54C8.44453 13.0091 9.66406 13.6824 11 13.91V16H10C9.20435 16 8.44129 16.3161 7.87868 16.8787C7.31607 17.4413 7 18.2044 7 19V21C7 21.2652 7.10536 21.5196 7.29289 21.7071C7.48043 21.8946 7.73478 22 8 22H16C16.2652 22 16.5196 21.8946 16.7071 21.7071C16.8946 21.5196 17 21.2652 17 21V19C17 18.2044 16.6839 17.4413 16.1213 16.8787C15.5587 16.3161 14.7956 16 14 16H13V13.91C14.3359 13.6824 15.5555 13.0091 16.46 12H18C19.0609 12 20.0783 11.5786 20.8284 10.8284C21.5786 10.0783 22 9.06087 22 8V5C22 4.73478 21.8946 4.48043 21.7071 4.29289C21.5196 4.10536 21.2652 4 21 4ZM6 10C5.46957 10 4.96086 9.78929 4.58579 9.41421C4.21071 9.03914 4 8.53043 4 8V6H6V8C6.0022 8.68171 6.12056 9.35806 6.35 10H6ZM14 18C14.2652 18 14.5196 18.1054 14.7071 18.2929C14.8946 18.4804 15 18.7348 15 19V20H9V19C9 18.7348 9.10536 18.4804 9.29289 18.2929C9.48043 18.1054 9.73478 18 10 18H14ZM16 8C16 9.06087 15.5786 10.0783 14.8284 10.8284C14.0783 11.5786 13.0609 12 12 12C10.9391 12 9.92172 11.5786 9.17157 10.8284C8.42143 10.0783 8 9.06087 8 8V4H16V8ZM20 8C20 8.53043 19.7893 9.03914 19.4142 9.41421C19.0391 9.78929 18.5304 10 18 10H17.65C17.8794 9.35806 17.9978 8.68171 18 8V6H20V8Z" fill="#EDF6F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
3
src/assets/icons/group/home.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 17.0625H16.5M11.3046 3.21117L3.50457 8.48603C3.18802 8.7001 3 9.04666 3 9.41605V19.2882C3 20.2336 3.80589 21 4.8 21H19.2C20.1941 21 21 20.2336 21 19.2882V9.41605C21 9.04665 20.812 8.7001 20.4954 8.48603L12.6954 3.21117C12.2791 2.92961 11.7209 2.92961 11.3046 3.21117Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
5
src/assets/icons/group/index.ts
Normal 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 };
|
||||
3
src/assets/icons/group/message-chat.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 10.5V6C19 4.89543 18.1046 4 17 4H5C3.89543 4 3 4.89543 3 6V13.8261C3 14.9307 3.89543 15.8261 5 15.8261H6.56522V20L10.7391 15.8261H11M16.163 18.3913L18.7717 21V18.3913H19C20.1046 18.3913 21 17.4959 21 16.3913V13C21 11.8954 20.1046 11 19 11H13C11.8954 11 11 11.8954 11 13V16.3913C11 17.4959 11.8954 18.3913 13 18.3913H16.163Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 524 B |
BIN
src/assets/logos/LogoFASIE.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
@@ -1,3 +1,4 @@
|
||||
import Logo from './Logo.svg';
|
||||
import LogoFASIE from './LogoFASIE.png';
|
||||
|
||||
export { Logo };
|
||||
export { Logo, LogoFASIE };
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
125
src/components/drop-down-list/Filter.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { checkMark } from '../../assets/icons/input';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { iconFilter, iconFilterActive } from '../../assets/icons/filters';
|
||||
|
||||
export interface FilterItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (items: FilterItem[]) => void;
|
||||
defaultState?: FilterItem[];
|
||||
items: FilterItem[];
|
||||
}
|
||||
|
||||
export const FilterDropDown: React.FC<FilterProps> = ({
|
||||
disabled = false,
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState = [],
|
||||
items = [],
|
||||
}) => {
|
||||
const [value, setValue] = React.useState<FilterItem[]>(defaultState);
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
onChange(value);
|
||||
}, [value]);
|
||||
|
||||
const toggleItem = (item: FilterItem) => {
|
||||
const exists = value.some((val) => val.value === item.value);
|
||||
if (exists) {
|
||||
setValue(value.filter((val) => val.value !== item.value));
|
||||
} else {
|
||||
setValue([...value, item]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'items-center h-[40px] rounded-full bg-liquid-lighter w-[40px] flex',
|
||||
'text-[18px] font-bold cursor-pointer select-none',
|
||||
'overflow-hidden',
|
||||
(active || value.length > 0) &&
|
||||
'w-fit border-liquid-brightmain border-[1px] border-solid',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) setActive(!active);
|
||||
}}
|
||||
>
|
||||
<div className="text-liquid-brightmain pl-[42px] pr-[16px] w-fit">
|
||||
{value.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter icons */}
|
||||
<img
|
||||
src={iconFilter}
|
||||
className={cn(
|
||||
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={iconFilterActive}
|
||||
className={cn(
|
||||
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
|
||||
(active || value.length > 0) && 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-[10px] bg-liquid-lighter w-[460px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden',
|
||||
active
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden p-[8px]">
|
||||
<div className="overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] grid grid-cols-2 gap-[20px]">
|
||||
{items.map((v) => {
|
||||
const selected = value.some(
|
||||
(val) => val.value === v.value,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={v.value}
|
||||
className={cn(
|
||||
'cursor-pointer h-[36px] relative transition-all duration-300',
|
||||
'text-[16px] font-medium select-none flex items-center pl-[8px]',
|
||||
'hover:bg-liquid-background rounded-[10px]',
|
||||
selected && 'bg-liquid-background/50',
|
||||
)}
|
||||
onClick={() => toggleItem(v)}
|
||||
>
|
||||
{v.text}
|
||||
{selected && (
|
||||
<img
|
||||
src={checkMark}
|
||||
className="absolute right-[8px] h-[20px] w-[20px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
129
src/components/drop-down-list/Sorter.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { checkMark } from '../../assets/icons/input';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { iconSort, iconSortActive } from '../../assets/icons/filters';
|
||||
|
||||
export interface SorterItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SorterProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: SorterItem;
|
||||
items: SorterItem[];
|
||||
}
|
||||
|
||||
export const SorterDropDown: FC<SorterProps> = ({
|
||||
// disabled = false,
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState,
|
||||
items = [{ text: '', value: '' }],
|
||||
}) => {
|
||||
if (items.length == 0) items.push({ text: '', value: '' });
|
||||
|
||||
const [value, setValue] = useState<SorterItem>(
|
||||
defaultState != undefined ? defaultState : items[0],
|
||||
);
|
||||
const [active, setActive] = useState<boolean>(false);
|
||||
const [activate, setActivate] = useState<Boolean>(false);
|
||||
|
||||
useEffect(() => onChange(value.value), [value]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => {
|
||||
setActive(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid items-center h-[40px] rounded-full bg-liquid-lighter grid-cols-[40px]',
|
||||
'text-[18px] font-bold cursor-pointer select-none',
|
||||
'overflow-hidden',
|
||||
(active || activate) &&
|
||||
' grid-cols-[1fr] border-liquid-brightmain border-[1px] border-solid',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'text-liquid-brightmain pl-[42px] pr-[16px]',
|
||||
active && '',
|
||||
)}
|
||||
>
|
||||
{' '}
|
||||
{value.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={iconSort}
|
||||
className={cn(
|
||||
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={iconSortActive}
|
||||
className={cn(
|
||||
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
|
||||
(active || activate) && ' opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
' absolute rounded-[10px] bg-liquid-lighter w-[220px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||
'grid overflow-hidden',
|
||||
active
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className=" overflow-hidden p-[8px]">
|
||||
<div
|
||||
className={cn(
|
||||
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
||||
)}
|
||||
>
|
||||
{items.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'cursor-pointer h-[36px] relative transition-all duration-300',
|
||||
i + 1 != items.length &&
|
||||
'border-b-liquid-light border-b-[1px]',
|
||||
'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
|
||||
'hover:bg-liquid-background',
|
||||
'first:rounded-t-[6px] last:rounded-b-[6px]',
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue(v);
|
||||
setActive(false);
|
||||
setActivate(true);
|
||||
}}
|
||||
>
|
||||
{v.text}
|
||||
|
||||
{v.text == value.text && (
|
||||
<img
|
||||
src={checkMark}
|
||||
className=" absolute right-[8px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
62
src/components/input/SearchInput.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { iconSearch } from '../../assets/icons/filters';
|
||||
|
||||
interface searchInputProps {
|
||||
name?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange: (state: string) => void;
|
||||
defaultState?: string;
|
||||
autocomplete?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC<searchInputProps> = ({
|
||||
placeholder = '',
|
||||
className = '',
|
||||
onChange,
|
||||
defaultState = '',
|
||||
name = '',
|
||||
autocomplete = '',
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [value, setValue] = React.useState<string>(defaultState);
|
||||
|
||||
React.useEffect(() => onChange(value), [value]);
|
||||
React.useEffect(() => setValue(defaultState), [defaultState]);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'relative bg-liquid-lighter w-[200px] h-[40px] flex rounded-full px-[16px] pl-[50px] cursor-text',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={cn(
|
||||
'placeholder:text-liquid-light h-[28px] w-[200px] bg-transparent outline-none text-liquid-white my-[6px]',
|
||||
)}
|
||||
value={value}
|
||||
name={name}
|
||||
autoComplete={autocomplete}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={iconSearch}
|
||||
className=" absolute top-[8px] left-[16px] w-[24px] h-[24px]"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -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 />;
|
||||
|
||||
34
src/lib/toastNotification.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const toastSuccess = (mes: string, autoClose: number = 3000) => {
|
||||
toast.success(mes, {
|
||||
position: 'top-right',
|
||||
autoClose: autoClose,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const toastWarning = (mes: string, autoClose: number = 3000) => {
|
||||
toast.warning(mes, {
|
||||
position: 'top-right',
|
||||
autoClose: autoClose,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const toastError = (mes: string, autoClose: number = 3000) => {
|
||||
toast.error(mes, {
|
||||
position: 'top-right',
|
||||
autoClose: autoClose,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
});
|
||||
};
|
||||
@@ -6,11 +6,13 @@ import './styles/palette/theme-light.css';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { 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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
fetchArticles: {
|
||||
articles: [],
|
||||
currentArticle: undefined,
|
||||
hasNextPage: false,
|
||||
statuses: {
|
||||
create: 'idle',
|
||||
update: 'idle',
|
||||
delete: 'idle',
|
||||
fetchAll: 'idle',
|
||||
fetchById: 'idle',
|
||||
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}`, {
|
||||
const response = await axios.put<Article>(
|
||||
`/articles/${articleId}`,
|
||||
{
|
||||
name,
|
||||
content,
|
||||
tags,
|
||||
});
|
||||
return response.data as Article;
|
||||
},
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
fetchContests: {
|
||||
contests: Contest[];
|
||||
selectedContest: Contest | null;
|
||||
hasNextPage: boolean;
|
||||
statuses: {
|
||||
fetchList: Status;
|
||||
fetchById: Status;
|
||||
create: Status;
|
||||
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 = {
|
||||
fetchContests: {
|
||||
contests: [],
|
||||
selectedContest: null,
|
||||
hasNextPage: false,
|
||||
statuses: {
|
||||
fetchList: 'idle',
|
||||
fetchById: 'idle',
|
||||
create: 'idle',
|
||||
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;
|
||||
|
||||
347
src/redux/slices/groupfeed.ts
Normal 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;
|
||||
@@ -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 {
|
||||
fetchMyGroups: {
|
||||
groups: Group[];
|
||||
currentGroup: Group | null;
|
||||
statuses: {
|
||||
create: Status;
|
||||
update: Status;
|
||||
delete: Status;
|
||||
fetchMy: Status;
|
||||
fetchById: Status;
|
||||
addMember: Status;
|
||||
removeMember: Status;
|
||||
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 = {
|
||||
fetchMyGroups: {
|
||||
groups: [],
|
||||
currentGroup: null,
|
||||
statuses: {
|
||||
create: 'idle',
|
||||
update: 'idle',
|
||||
delete: 'idle',
|
||||
fetchMy: 'idle',
|
||||
fetchById: 'idle',
|
||||
addMember: 'idle',
|
||||
removeMember: 'idle',
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -0,0 +1,32 @@
|
||||
.Toastify__progress-bar--success {
|
||||
background: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success .Toastify__toast-icon svg path {
|
||||
fill: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__progress-bar--error {
|
||||
background: #f13e5f !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--error .Toastify__toast-icon svg path {
|
||||
fill: #f13e5f !important;
|
||||
}
|
||||
|
||||
.Toastify__progress-bar--success {
|
||||
background: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success .Toastify__toast-icon svg path {
|
||||
fill: #10be59 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast {
|
||||
background: #292929 !important;
|
||||
color: var(--color-liquid-white);
|
||||
}
|
||||
|
||||
.Toastify__toast > button > svg {
|
||||
fill: var(--color-liquid-white);
|
||||
}
|
||||
@@ -4,18 +4,7 @@ import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
import MarkdownPreview from './MarckDownPreview';
|
||||
|
||||
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>,
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppDispatch } from '../../../redux/hooks';
|
||||
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
|
||||
|
||||
const ContestsBlock = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActiveProfilePage('contests'));
|
||||
}, []);
|
||||
return (
|
||||
<div className="h-full w-full relative flex items-center justify-center text-[60px] font-bold">
|
||||
Пока пусто :(
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestsBlock;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppDispatch } from '../../../redux/hooks';
|
||||
import { setMenuActiveProfilePage } from '../../../redux/slices/store';
|
||||
|
||||
const MissionsBlock = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActiveProfilePage('missions'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative flex items-center justify-center text-[60px] font-bold">
|
||||
Пока пусто :(
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissionsBlock;
|
||||
@@ -1,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,19 +12,19 @@ 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',
|
||||
'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="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
|
||||
@@ -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}
|
||||
@@ -52,6 +52,7 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||
<img
|
||||
className="absolute right-[10px] top-[10px] h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
||||
src={Edit}
|
||||
alt="Редактировать"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
@@ -69,13 +70,24 @@ 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
|
||||
@@ -84,24 +96,26 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300',
|
||||
active && 'border-b-liquid-lighter',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
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',
|
||||
@@ -110,8 +124,25 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
||||
>
|
||||
<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>
|
||||
61
src/views/home/account/contests/Contests.tsx
Normal 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;
|
||||
93
src/views/home/account/contests/ContestsBlock.tsx
Normal 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;
|
||||
98
src/views/home/account/contests/MyContestItem.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { Account } from '../../../../assets/icons/auth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Edit } from '../../../../assets/icons/input';
|
||||
|
||||
export interface ContestItemProps {
|
||||
id: number;
|
||||
name: string;
|
||||
startAt: string;
|
||||
duration: number;
|
||||
members: number;
|
||||
type: 'first' | 'second';
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatWaitTime(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
const remainder = days % 10;
|
||||
let suffix = 'дней';
|
||||
if (remainder === 1 && days !== 11) suffix = 'день';
|
||||
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
|
||||
suffix = 'дня';
|
||||
return `${days} ${suffix}`;
|
||||
} else if (hours > 0) {
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
|
||||
} else {
|
||||
return `${minutes} мин`;
|
||||
}
|
||||
}
|
||||
|
||||
const ContestItem: React.FC<ContestItemProps> = ({
|
||||
id,
|
||||
name,
|
||||
startAt,
|
||||
duration,
|
||||
members,
|
||||
type,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,1fr,110px,110px,110px,24px] items-center font-bold',
|
||||
type == 'first'
|
||||
? ' bg-liquid-lighter'
|
||||
: ' bg-liquid-background',
|
||||
)}
|
||||
onClick={() => {
|
||||
navigate(`/contest/${id}`);
|
||||
}}
|
||||
>
|
||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
||||
<div className="text-center text-liquid-brightmain font-normal ">
|
||||
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
|
||||
valavshonok
|
||||
</div>
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(startAt)}
|
||||
</div>
|
||||
<div className="text-center">{formatWaitTime(duration)}</div>
|
||||
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
|
||||
<div>{members}</div>
|
||||
<img src={Account} className="h-[24px] w-[24px]" />
|
||||
</div>
|
||||
|
||||
<img
|
||||
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
||||
src={Edit}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/contest/create?back=/home/account/contests&contestId=${id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestItem;
|
||||
114
src/views/home/account/contests/RegisterContestItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { cn } from '../../../../lib/cn';
|
||||
import { Account } from '../../../../assets/icons/auth';
|
||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
||||
import { ReverseButton } from '../../../../components/button/ReverseButton';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export interface ContestItemProps {
|
||||
id: number;
|
||||
name: string;
|
||||
startAt: string;
|
||||
duration: number;
|
||||
members: number;
|
||||
statusRegister: 'reg' | 'nonreg';
|
||||
type: 'first' | 'second';
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatWaitTime(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
const remainder = days % 10;
|
||||
let suffix = 'дней';
|
||||
if (remainder === 1 && days !== 11) suffix = 'день';
|
||||
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
|
||||
suffix = 'дня';
|
||||
return `${days} ${suffix}`;
|
||||
} else if (hours > 0) {
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
|
||||
} else {
|
||||
return `${minutes} мин`;
|
||||
}
|
||||
}
|
||||
|
||||
const ContestItem: React.FC<ContestItemProps> = ({
|
||||
id,
|
||||
name,
|
||||
startAt,
|
||||
duration,
|
||||
members,
|
||||
statusRegister,
|
||||
type,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const waitTime = new Date(startAt).getTime() - now.getTime();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer',
|
||||
waitTime <= 0 ? 'grid grid-cols-6' : 'grid grid-cols-7',
|
||||
'items-center font-bold text-liquid-white',
|
||||
type == 'first'
|
||||
? ' bg-liquid-lighter'
|
||||
: ' bg-liquid-background',
|
||||
)}
|
||||
onClick={() => {
|
||||
navigate(`/contest/${id}`);
|
||||
}}
|
||||
>
|
||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
||||
<div className="text-center text-liquid-brightmain font-normal ">
|
||||
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
|
||||
valavshonok
|
||||
</div>
|
||||
<div className="text-center text-nowrap whitespace-pre-line">
|
||||
{formatDate(startAt)}
|
||||
</div>
|
||||
<div className="text-center">{formatWaitTime(duration)}</div>
|
||||
{waitTime > 0 && (
|
||||
<div className="text-center whitespace-pre-line ">
|
||||
{'До начала\n' + formatWaitTime(waitTime)}
|
||||
</div>
|
||||
)}
|
||||
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
|
||||
<div>{members}</div>
|
||||
<img src={Account} className="h-[24px] w-[24px]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{statusRegister == 'reg' ? (
|
||||
<>
|
||||
{' '}
|
||||
<PrimaryButton onClick={() => {}} text="Регистрация" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
<ReverseButton onClick={() => {}} text="Вы записаны" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestItem;
|
||||
109
src/views/home/account/missions/Missions.tsx
Normal 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;
|
||||
71
src/views/home/account/missions/MissionsBlock.tsx
Normal 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;
|
||||
89
src/views/home/account/missions/MyMissionItem.tsx
Normal 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;
|
||||
@@ -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({}));
|
||||
}, []);
|
||||
|
||||
if (status == 'loading') return <div>Загрузка...</div>;
|
||||
}, [dispatch]);
|
||||
|
||||
// ========================
|
||||
// Состояния загрузки / ошибки
|
||||
// ========================
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||
<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]">
|
||||
<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>
|
||||
);
|
||||
|
||||
51
src/views/home/articles/Filter.tsx
Normal 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;
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
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;
|
||||
@@ -9,31 +17,104 @@ export interface Article {
|
||||
}
|
||||
|
||||
interface ContestMissionsProps {
|
||||
contest: Contest | null;
|
||||
contest?: Contest;
|
||||
}
|
||||
|
||||
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { submissions, status } = useAppSelector(
|
||||
(state) => state.contests.fetchMySubmissions
|
||||
);
|
||||
|
||||
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,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 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) => (
|
||||
{(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}
|
||||
type={i % 2 ? 'second' : 'first'}
|
||||
status={status}
|
||||
type={i % 2 ? "second" : "first"}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
94
src/views/home/contest/SubmissionItem.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { cn } from '../../../lib/cn';
|
||||
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
||||
// import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface SubmissionItemProps {
|
||||
id: number;
|
||||
datetime: string;
|
||||
missionId: number;
|
||||
language: string;
|
||||
verdict: string;
|
||||
duration: number;
|
||||
memory: number;
|
||||
type: 'first' | 'second';
|
||||
status?: 'success' | 'wronganswer' | 'timelimit';
|
||||
}
|
||||
|
||||
export function formatMilliseconds(ms: number): string {
|
||||
const rounded = Math.round(ms) / 1000;
|
||||
const formatted = rounded.toString().replace(/\.?0+$/, '');
|
||||
return `${formatted} c`;
|
||||
}
|
||||
|
||||
export function formatBytesToMB(bytes: number): string {
|
||||
const megabytes = Math.floor(bytes / (1024 * 1024));
|
||||
return `${megabytes} МБ`;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
const SubmissionItem: React.FC<SubmissionItemProps> = ({
|
||||
id,
|
||||
datetime,
|
||||
missionId,
|
||||
language,
|
||||
verdict,
|
||||
duration,
|
||||
memory,
|
||||
type,
|
||||
status
|
||||
}) => {
|
||||
// const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
' w-full relative rounded-[10px] text-liquid-white text-center text-bold text-[16px] py-[8px]',
|
||||
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
|
||||
'grid grid-cols-7 grid-flow-col gap-[20px] px-[20px] box-border items-center',
|
||||
status == 'wronganswer' &&
|
||||
'border-l-[11px] border-l-liquid-red pl-[9px]',
|
||||
status == 'timelimit' &&
|
||||
'border-l-[11px] border-l-liquid-orange pl-[9px]',
|
||||
status == 'success' &&
|
||||
'border-l-[11px] border-l-liquid-green pl-[9px]',
|
||||
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||
)}
|
||||
onClick={() => {}}
|
||||
>
|
||||
<div className="text-[18px] font-bold">#{id}</div>
|
||||
<div className="text-[18px] font-bold text-center">
|
||||
{formatDate(datetime)}
|
||||
</div>
|
||||
<div>{missionId} </div>
|
||||
<div className="text-[18px] font-bold text-center">{language}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[18px] font-bold text-center',
|
||||
status == 'wronganswer' && 'text-liquid-red',
|
||||
status == 'timelimit' && 'text-liquid-orange',
|
||||
status == 'success' && 'text-liquid-green',
|
||||
)}
|
||||
>
|
||||
{verdict}
|
||||
</div>
|
||||
<div>{formatMilliseconds(duration)}</div>
|
||||
<div>
|
||||
{formatBytesToMB(memory)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmissionItem;
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,13 +49,24 @@ 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).getTime();
|
||||
const endTime = new Date(
|
||||
contest.endsAt ?? new Date().toDateString(),
|
||||
).getTime();
|
||||
return endTime >= now.getTime();
|
||||
})}
|
||||
/>
|
||||
@@ -69,10 +75,14 @@ const Contests = () => {
|
||||
className="mb-[20px]"
|
||||
title="Прошедшие"
|
||||
contests={contests.filter((contest) => {
|
||||
const endTime = new Date(contest.endsAt).getTime();
|
||||
const endTime = new Date(
|
||||
contest.endsAt ?? new Date().toDateString(),
|
||||
).getTime();
|
||||
return endTime < now.getTime();
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalCreateContest
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
))}
|
||||
|
||||
51
src/views/home/contests/Filter.tsx
Normal 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;
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
52
src/views/home/group/Group.tsx
Normal 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;
|
||||
96
src/views/home/group/GroupMenu.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { MessageChat, Home, Cup } from '../../../assets/icons/group';
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||
import {
|
||||
setMenuActivePage,
|
||||
setMenuActiveProfilePage,
|
||||
} from '../../../redux/slices/store';
|
||||
|
||||
interface MenuItemProps {
|
||||
icon: string;
|
||||
text: string;
|
||||
href: string;
|
||||
page: string;
|
||||
profilePage: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
icon,
|
||||
text = '',
|
||||
href = '',
|
||||
active = false,
|
||||
page = '',
|
||||
profilePage = '',
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={`
|
||||
flex items-center gap-3 p-[16px] rounded-[10px] h-[40px] text-[18px] font-bold
|
||||
transition-all duration-300 text-liquid-white
|
||||
active:scale-95 hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-light hover:ring-inset
|
||||
${active && 'bg-liquid-lighter '}
|
||||
`}
|
||||
onClick={() => {
|
||||
dispatch(setMenuActivePage(page));
|
||||
dispatch(setMenuActiveProfilePage(profilePage));
|
||||
}}
|
||||
>
|
||||
<img src={icon} />
|
||||
<span>{text}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupMenuProps {
|
||||
groupId: number;
|
||||
}
|
||||
|
||||
const GroupMenu: FC<GroupMenuProps> = ({ groupId }) => {
|
||||
const menuItems = [
|
||||
{
|
||||
text: 'Главная',
|
||||
href: `/group/${groupId}/home`,
|
||||
icon: Home,
|
||||
page: 'group',
|
||||
profilePage: 'home',
|
||||
},
|
||||
{
|
||||
text: 'Чат',
|
||||
href: `/group/${groupId}/chat`,
|
||||
icon: MessageChat,
|
||||
page: 'group',
|
||||
profilePage: 'chat',
|
||||
},
|
||||
{
|
||||
text: 'Контесты',
|
||||
href: `/group/${groupId}/contests`,
|
||||
icon: Cup,
|
||||
page: 'group',
|
||||
profilePage: 'contests',
|
||||
},
|
||||
];
|
||||
|
||||
const activeGroupPage = useAppSelector(
|
||||
(state) => state.store.menu.activeGroupPage,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full relative flex gap-[10px]">
|
||||
{menuItems.map((v, i) => (
|
||||
<MenuItem
|
||||
{...v}
|
||||
key={i}
|
||||
active={activeGroupPage == v.profilePage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMenu;
|
||||
12
src/views/home/group/chat/Chat.tsx
Normal 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 <></>;
|
||||
};
|
||||
12
src/views/home/group/contests/Contests.tsx
Normal 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 <></>;
|
||||
};
|
||||
72
src/views/home/group/posts/ModalCreate.tsx
Normal 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;
|
||||
140
src/views/home/group/posts/ModalUpdate.tsx
Normal 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;
|
||||
86
src/views/home/group/posts/PostItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
src/views/home/group/posts/Posts.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
src/views/home/groupinviter/GroupInvite.tsx
Normal 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;
|
||||
51
src/views/home/groups/Filter.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,22 +96,36 @@ 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 />
|
||||
|
||||
{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]"
|
||||
@@ -101,6 +133,9 @@ const Groups = () => {
|
||||
groups={currentGroups}
|
||||
setUpdateActive={setModalUpdateActive}
|
||||
setUpdateGroup={setUpdateGroup}
|
||||
setInviteActive={setModalInviteActive}
|
||||
setInviteGroup={setInviteGroup}
|
||||
type="member"
|
||||
/>
|
||||
<GroupsBlock
|
||||
className="mb-[20px]"
|
||||
@@ -108,17 +143,28 @@ const Groups = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
102
src/views/home/groups/ModalInvite.tsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
51
src/views/home/missions/Filter.tsx
Normal 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;
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"
|
||||
|
||||
40
src/views/home/rightpanel/Articles.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/views/home/rightpanel/Group.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
export const GroupRightPanel: FC = () => {
|
||||
const items = [
|
||||
{
|
||||
name: 'Игнат Герасименко',
|
||||
role: 'Администратор',
|
||||
},
|
||||
{
|
||||
name: 'Алиса Макаренко',
|
||||
role: 'Модератор',
|
||||
},
|
||||
{
|
||||
name: 'Федор Картман',
|
||||
role: 'Модератор',
|
||||
},
|
||||
{
|
||||
name: 'Карина Механаджанович',
|
||||
role: 'Участник',
|
||||
},
|
||||
{
|
||||
name: 'Михаил Ангрский',
|
||||
role: 'Участник',
|
||||
},
|
||||
{
|
||||
name: 'newuser',
|
||||
role: 'Участник (Вы)',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="h-screen w-full overflow-y-scroll thin-dark-scrollbar p-[20px] gap-[5px] flex flex-col">
|
||||
<div className="text-liquid-white font-bold text-[18px]">
|
||||
Пользователи
|
||||
</div>
|
||||
|
||||
{items.map((v, i) => {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<div className="text-liquid-light text-[16px] grid grid-cols-[40px,1fr] gap-[10px] items-center cursor-pointer hover:bg-liquid-lighter transition-all duration-300 rounded-[10px] p-[5px]">
|
||||
<div className="h-[40px] w-[40px] rounded-[10px] bg-[#D9D9D9]"></div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-liquid-white font-bold text-[16px] leading-5">
|
||||
{v.name}
|
||||
</div>
|
||||
<div className="text-liquid-light font-normal text-[16px] leading-5">
|
||||
{v.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{i + 1 != items.length && (
|
||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
src/views/home/rightpanel/Missions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,40 +16,41 @@ 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) => (
|
||||
{filteredSubmissions.map((v, i) => (
|
||||
<SubmissionItem
|
||||
key={i}
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
language={v.solution.language}
|
||||
time={v.solution.time}
|
||||
verdict={
|
||||
v.solution.testerMessage?.includes(
|
||||
'Compilation failed',
|
||||
)
|
||||
v.solution.testerMessage?.includes('Compilation failed')
|
||||
? 'Compilation failed'
|
||||
: v.solution.testerMessage
|
||||
}
|
||||
type={i % 2 ? 'second' : 'first'}
|
||||
status={
|
||||
v.solution.testerMessage == 'All tests passed'
|
||||
v.solution.testerMessage === 'All tests passed'
|
||||
? 'success'
|
||||
: checkStatus(v.solution.testerErrorCode)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||