missions and filter

This commit is contained in:
Виталий Лавшонок
2025-11-08 06:58:56 +03:00
parent 69655dda82
commit b12a3acf1d
26 changed files with 694 additions and 158 deletions

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,6 @@
import FilterActive from './filters-active.svg';
import Filter from './filters.svg';
import Sort from './sort.svg';
import SortActive from './sort-active.svg';
export { Filter, FilterActive, Sort, SortActive };

View File

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

After

Width:  |  Height:  |  Size: 324 B

View File

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

After

Width:  |  Height:  |  Size: 324 B

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { cn } from '../../lib/cn';
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
import { useClickOutside } from '../../hooks/useClickOutside';
export interface FilterItem {
text: string;
value: string;
}
interface FilterProps {
disabled?: boolean;
className?: string;
onChange: (state: string[]) => void; // теперь массив выбранных значений
defaultState?: FilterItem[];
items: FilterItem[];
}
export const Filter: React.FC<FilterProps> = ({
className = '',
onChange,
defaultState = [],
items = [{ text: '', value: '' }],
}) => {
if (items.length === 0) items.push({ text: '', value: '' });
const [selectedValues, setSelectedValues] =
React.useState<FilterItem[]>(defaultState);
const [active, setActive] = React.useState<boolean>(false);
React.useEffect(() => {
onChange(selectedValues.map((v) => v.value));
}, [selectedValues]);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setActive(false));
const toggleItem = (item: FilterItem) => {
const exists = selectedValues.some((v) => v.value === item.value);
if (exists) {
setSelectedValues(
selectedValues.filter((v) => v.value !== item.value),
);
} else {
setSelectedValues([...selectedValues, item]);
}
};
const displayText =
selectedValues.length > 0
? selectedValues.map((v) => v.text).join(', ')
: 'Выберите...';
return (
<div className={cn('relative', className)} ref={ref}>
<div
className={cn(
'flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[220px]',
'text-[16px] font-bold cursor-pointer select-none',
'transition-all active:scale-95 duration-300 overflow-hidden whitespace-nowrap text-ellipsis',
)}
onClick={() => setActive(!active)}
title={displayText}
>
{displayText}
</div>
<img
src={chevroneDropDownList}
className={cn(
'absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none',
active && 'rotate-180',
)}
/>
<div
className={cn(
'absolute rounded-[10px] bg-liquid-lighter 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-[220px] thin-scrollbar pr-[8px]',
'grid grid-cols-2 gap-[4px]',
)}
>
{items.map((v, i) => {
const isSelected = selectedValues.some(
(sel) => sel.value === v.value,
);
return (
<div
key={i}
className={cn(
'cursor-pointer h-[36px] relative transition-all duration-300 flex items-center pl-[8px] pr-[24px] rounded-[6px]',
'text-[14px] font-medium select-none',
'hover:bg-liquid-background',
isSelected &&
'bg-liquid-background font-semibold',
)}
onClick={() => toggleItem(v)}
>
{v.text}
{isSelected && (
<img
src={checkMark}
className="absolute right-[6px] w-[16px] h-[16px]"
/>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { cn } from '../../lib/cn';
import { checkMark } from '../../assets/icons/input';
import { useClickOutside } from '../../hooks/useClickOutside';
import { Sort, SortActive } 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 Sorter: React.FC<SorterProps> = ({
// disabled = false,
className = '',
onChange,
defaultState,
items = [{ text: '', value: '' }],
}) => {
if (items.length == 0) items.push({ text: '', value: '' });
const [value, setValue] = React.useState<SorterItem>(
defaultState != undefined ? defaultState : items[0],
);
const [active, setActive] = React.useState<boolean>(false);
React.useEffect(() => onChange(value.value), [value]);
const ref = React.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',
'transitin-all active:scale-95 duration-300 overflow-hidden',
active && ' grid-cols-[1fr]',
)}
onClick={() => {
setActive(!active);
}}
>
<div
className={cn(
'text-liquid-brightmain pl-[40px] pr-[16px]',
active && '',
)}
>
{' '}
{value.text}
</div>
</div>
<div className="h-[24px] w-[24px] bg-red-500"></div>
<img
src={Sort}
className={cn(
' absolute right-[16px] h-[24px] w-[24px] top-[8px] left-[8px] rotate-0 transition-all duration-300 pointer-events-none',
)}
/>
<img
src={SortActive}
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 && ' 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);
}}
>
{v.text}
{v.text == value.text && (
<img
src={checkMark}
className=" absolute right-[8px]"
/>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -4,7 +4,6 @@ import { PrimaryButton } from '../components/button/PrimaryButton';
import { Input } from '../components/input/Input';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import {
createContest,
CreateContestBody,
deleteContest,
fetchContestById,
@@ -17,7 +16,6 @@ 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;
@@ -27,7 +25,6 @@ interface Mission {
* Страница создания / редактирования контеста
*/
const ContestEditor = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -37,20 +34,15 @@ const ContestEditor = () => {
const refactor = !!contestId;
if (!refactor) {
return <Navigate to="/home/account/acontest" />
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: '',
@@ -67,9 +59,12 @@ const ContestEditor = () => {
const [missions, setMissions] = useState<Mission[]>([]);
const statusDelete = useAppSelector((state) => state.contests.deleteContest.status)
const statusUpdate = useAppSelector((state) => state.contests.updateContest.status);
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,
@@ -92,8 +87,6 @@ const ContestEditor = () => {
dispatch(deleteContest(contestId));
};
const addMission = () => {
const id = Number(missionIdInput.trim());
if (!id || contest.missionIds?.includes(id)) return;
@@ -121,19 +114,22 @@ const ContestEditor = () => {
};
useEffect(() => {
if (statusDelete == "successful"){
dispatch(setContestStatus({key: "deleteContest", status: "idle"}))
navigate('/home/account/contests')
if (statusDelete == 'successful') {
dispatch(
setContestStatus({ key: 'deleteContest', status: 'idle' }),
);
navigate('/home/account/contests');
}
}, [statusDelete])
}, [statusDelete]);
useEffect(() => {
if (statusUpdate == "successful"){
dispatch(setContestStatus({key: "updateContest", status: "idle"}))
navigate('/home/account/contests')
if (statusUpdate == 'successful') {
dispatch(
setContestStatus({ key: 'updateContest', status: 'idle' }),
);
navigate('/home/account/contests');
}
}, [statusUpdate])
}, [statusUpdate]);
useEffect(() => {
if (refactor) {
@@ -146,8 +142,10 @@ const ContestEditor = () => {
setContest({
...contestById,
// groupIds: contestById.groups.map(group => group.groupId),
missionIds: contestById.missions?.map(mission => mission.id),
articleIds: contestById.articles?.map(article => article.articleId),
missionIds: contestById.missions?.map((mission) => mission.id),
articleIds: contestById.articles?.map(
(article) => article.articleId,
),
visibility: 'Public',
scheduleType: 'AlwaysOpen',
});
@@ -300,7 +298,6 @@ const ContestEditor = () => {
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleUpdateContest}
text="Сохранить"
@@ -312,7 +309,6 @@ const ContestEditor = () => {
text="Удалить"
disabled={statusDelete === 'loading'}
/>
</div>
</div>
</div>

View File

@@ -51,8 +51,10 @@ const Home = () => {
<p>{jwt}</p>
<PrimaryButton
onClick={() => {
if (jwt)
if (jwt) {
navigator.clipboard.writeText(jwt);
alert(jwt);
}
}}
text="скопировать токен"
className="pt-[20px]"

View File

@@ -20,6 +20,8 @@ export interface Mission {
tags: string[];
createdAt: string;
updatedAt: string;
timeLimit: number;
memoryLimit: number;
statements?: Statement[];
}
@@ -31,6 +33,7 @@ interface MissionsState {
fetchList: Status;
fetchById: Status;
upload: Status;
fetchMy: Status;
};
error: string | null;
}
@@ -45,6 +48,7 @@ const initialState: MissionsState = {
fetchList: 'idle',
fetchById: 'idle',
upload: 'idle',
fetchMy: 'idle',
},
error: null,
};
@@ -90,6 +94,22 @@ export const fetchMissionById = createAsyncThunk(
},
);
// ✅ GET /missions/my
export const fetchMyMissions = createAsyncThunk(
'missions/fetchMyMissions',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/missions/my');
return response.data as Mission[]; // массив миссий пользователя
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при получении моих миссий',
);
}
},
);
// POST /missions/upload
export const uploadMission = createAsyncThunk(
'missions/uploadMission',
@@ -189,6 +209,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';

View File

@@ -1,7 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AccountMenu from './AccoutMenu';
import RightPanel from './RightPanel';
import MissionsBlock from './missions/MissionsBlock';
import Missions from './missions/Missions';
import Contests from './contests/Contests';
import ArticlesBlock from './articles/ArticlesBlock';
import { useAppDispatch } from '../../../redux/hooks';
@@ -24,10 +24,7 @@ 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 />}

View File

@@ -14,9 +14,6 @@ const Contests = () => {
const myContestsState = useAppSelector(
(state) => state.contests.fetchMyContests,
);
const regContestsState = useAppSelector(
(state) => state.contests.fetchRegisteredContests,
);
// При загрузке страницы — выставляем вкладку и подгружаем контесты
useEffect(() => {

View File

@@ -1,7 +1,5 @@
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';
import { Edit } from '../../../../assets/icons/input';
@@ -57,10 +55,6 @@ const ContestItem: React.FC<ContestItemProps> = ({
}) => {
const navigate = useNavigate();
const now = new Date();
const waitTime = new Date(startAt).getTime() - now.getTime();
return (
<div
className={cn(

View File

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

View File

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

View File

@@ -1,63 +1,68 @@
import { FC, useEffect } from "react";
import { useAppDispatch } from "../../../../redux/hooks";
import { setMenuActiveProfilePage } from "../../../../redux/slices/store";
import { cn } from "../../../../lib/cn";
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 ItemProps {
count: number;
totalCount: number;
interface MissionsBlockProps {
missions: Mission[];
title: string;
color?: "default" | "red" | "green" | "orange";
className?: string;
}
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 MissionsBlock = () => {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setMenuActiveProfilePage("missions"));
}, []);
const MissionsBlock: FC<MissionsBlockProps> = ({
missions,
title,
className,
}) => {
const [active, setActive] = useState<boolean>(true);
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
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="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
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>
<div>Мои задачи</div>
</div>
</div>
);

View File

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

View File

@@ -1,10 +1,9 @@
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { setMenuActivePage } from '../../../redux/slices/store';
import { Navigate, Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { fetchContestById } from '../../../redux/slices/contests';
import ContestMissions from './Missions';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import Submissions from './Submissions';
export interface Article {
@@ -14,7 +13,6 @@ export interface Article {
}
const Contest = () => {
const navigate = useNavigate();
const { contestId } = useParams<{ contestId: string }>();
const contestIdNumber =
contestId && /^\d+$/.test(contestId) ? parseInt(contestId, 10) : null;
@@ -22,8 +20,9 @@ const Contest = () => {
return <Navigate to="/home/contests" replace />;
}
const dispatch = useAppDispatch();
const contest = useAppSelector((state) => state.contests.fetchContestById.contest);
const contest = useAppSelector(
(state) => state.contests.fetchContestById.contest,
);
useEffect(() => {
dispatch(setMenuActivePage('contest'));
@@ -34,9 +33,8 @@ const Contest = () => {
}, [contestIdNumber]);
return (
<div className='w-full h-full'>
<div className="w-full h-full">
<Routes>
<Route
path="submissions"
element={<Submissions contest={contest} />}
@@ -46,7 +44,6 @@ const Contest = () => {
element={<ContestMissions contest={contest} />}
/>
</Routes>
</div>
);
};

View File

@@ -73,6 +73,7 @@ const ContestItem: React.FC<ContestItemProps> = ({
: ' bg-liquid-background',
)}
onClick={() => {
console.log(456);
navigate(`/contest/${id}`);
}}
>
@@ -99,8 +100,8 @@ const ContestItem: React.FC<ContestItemProps> = ({
<>
{' '}
<PrimaryButton
onClick={(e) => {
e.stopPropagation();
onClick={() => {
console.log(123);
}}
text="Регистрация"
/>
@@ -108,12 +109,7 @@ const ContestItem: React.FC<ContestItemProps> = ({
) : (
<>
{' '}
<ReverseButton
onClick={(e) => {
e.stopPropagation();
}}
text="Вы записаны"
/>
<ReverseButton onClick={() => {}} text="Вы записаны" />
</>
)}
</div>

View File

@@ -0,0 +1,48 @@
import { Filter, FilterItem } from '../../../components/drop-down-list/Filter';
import { Sorter } from '../../../components/drop-down-list/Sorter';
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">
<div></div>
<Sorter
items={[
{
value: '1',
text: 'Сложность',
},
{
value: '2',
text: 'Дата создания',
},
{
value: '3',
text: 'ID',
},
]}
onChange={(v) => console.log(v)}
/>
{/* <Filter
items={items}
defaultState={[items[0], items[3]]} // начальные выбранные элементы
onChange={(values) => console.log(values)} // обработчик изменения
className="w-[240px]"
/> */}
</div>
);
};
export default Filters;

View File

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

View File

@@ -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/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/contesteditor.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/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/missionsblock.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/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/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"}