From 02de330034f5743d9fdb7410eeb73eedbed6a20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:04:20 +0300 Subject: [PATCH] add filters --- src/assets/icons/input/index.ts | 2 + src/assets/icons/input/trash.svg | 3 + src/components/filters/TagFilter.tsx | 161 ++++++++++++++++++ .../{filters => input}/DropDownList.tsx | 0 src/components/input/Input.tsx | 3 + src/pages/ContestEditor.tsx | 2 +- src/redux/slices/articles.ts | 5 + src/redux/slices/missions.ts | 7 +- src/redux/slices/store.ts | 83 +++++++-- .../home/account/missions/MyMissionItem.tsx | 4 +- src/views/home/articles/ArticleItem.tsx | 64 ++++++- src/views/home/articles/Articles.tsx | 67 ++++---- src/views/home/articles/Filter.tsx | 55 ++---- src/views/home/contests/Contests.tsx | 39 ++++- src/views/home/contests/Filter.tsx | 49 +----- src/views/home/contests/ModalCreate.tsx | 2 +- src/views/home/contests/PastContestItem.tsx | 59 ++++++- .../home/contests/UpcomingContestItem.tsx | 59 ++++++- src/views/home/missions/Filter.tsx | 55 ++---- src/views/home/missions/MissionItem.tsx | 60 ++++++- src/views/home/missions/Missions.tsx | 68 +++++--- .../home/rightpanel/group/ModalUpdate.tsx | 2 +- src/views/mission/codeeditor/CodeEditor.tsx | 2 +- 23 files changed, 639 insertions(+), 212 deletions(-) create mode 100644 src/assets/icons/input/trash.svg create mode 100644 src/components/filters/TagFilter.tsx rename src/components/{filters => input}/DropDownList.tsx (100%) diff --git a/src/assets/icons/input/index.ts b/src/assets/icons/input/index.ts index 57e404c..123fa7a 100644 --- a/src/assets/icons/input/index.ts +++ b/src/assets/icons/input/index.ts @@ -6,6 +6,7 @@ import chevroneDropDownList from './chevron-drop-down.svg'; import checkMark from './check-mark.svg'; import Edit from './edit.svg'; import Send from './send.svg'; +import Trash from './trash.svg'; export { Edit, @@ -16,4 +17,5 @@ export { chevroneDropDownList, checkMark, Send, + Trash, }; diff --git a/src/assets/icons/input/trash.svg b/src/assets/icons/input/trash.svg new file mode 100644 index 0000000..e79819b --- /dev/null +++ b/src/assets/icons/input/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/filters/TagFilter.tsx b/src/components/filters/TagFilter.tsx new file mode 100644 index 0000000..d67a88c --- /dev/null +++ b/src/components/filters/TagFilter.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { cn } from '../../lib/cn'; +import { useClickOutside } from '../../hooks/useClickOutside'; +import { iconFilter, iconFilterActive } from '../../assets/icons/filters'; +import { Input } from '../input/Input'; +import { PrimaryButton } from '../button/PrimaryButton'; +import { toastError } from '../../lib/toastNotification'; +import { SecondaryButton } from '../button/SecondaryButton'; + +interface TagFilterProps { + disabled?: boolean; + className?: string; + onChange: (items: string[]) => void; +} + +export const TagFilter: React.FC = ({ + disabled = false, + className = '', + onChange, +}) => { + const [active, setActive] = React.useState(false); + + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState([]); + + // ========================== + // Теги + // ========================== + const addTag = () => { + if (tags.length > 30) { + setTagInput(''); + toastError('Нельзя добавить больше 30 тегов'); + return; + } + const newTag = tagInput.trim(); + if (newTag && !tags.includes(newTag)) { + setTags([...tags, newTag]); + setTagInput(''); + } + }; + + const removeTag = (tagToRemove: string) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + const resetTags = () => { + setTags([]); + }; + + const ref = React.useRef(null); + + useClickOutside(ref, () => { + setActive(false); + }); + + React.useEffect(() => { + onChange(tags); + }, [tags]); + + return ( +
+
0) && + 'w-fit border-liquid-brightmain border-[1px] border-solid', + )} + onClick={() => { + if (!disabled) setActive(!active); + }} + > +
+ {tags.length} +
+
+ + {/* Filter icons */} + + 0) && 'opacity-100', + )} + /> + + {/* Dropdown */} +
+
+
+ {/* Теги */} +
+
+ { + if (e.key === 'Enter') addTag(); + }} + /> + + +
+
+ {tags.length == 0 ? ( +
+ Вы еще не добавили ни одного тега +
+ ) : ( + tags.map((tag) => ( +
+ {tag} + +
+ )) + )} +
+
+
+
+
+
+ ); +}; diff --git a/src/components/filters/DropDownList.tsx b/src/components/input/DropDownList.tsx similarity index 100% rename from src/components/filters/DropDownList.tsx rename to src/components/input/DropDownList.tsx diff --git a/src/components/input/Input.tsx b/src/components/input/Input.tsx index 5bbfe7f..624c79a 100644 --- a/src/components/input/Input.tsx +++ b/src/components/input/Input.tsx @@ -11,6 +11,7 @@ interface inputProps { label?: string; placeholder?: string; className?: string; + inputClassName?: string; onChange: (state: string) => void; defaultState?: string; autocomplete?: string; @@ -25,6 +26,7 @@ export const Input: React.FC = ({ label = '', placeholder = '', className = '', + inputClassName = '', onChange, defaultState = '', name = '', @@ -52,6 +54,7 @@ export const Input: React.FC = ({ className={cn( 'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light', type == 'password' ? 'h-[40px]' : 'h-[36px]', + inputClassName, )} value={value} name={name} diff --git a/src/pages/ContestEditor.tsx b/src/pages/ContestEditor.tsx index e18a91b..527eba1 100644 --- a/src/pages/ContestEditor.tsx +++ b/src/pages/ContestEditor.tsx @@ -17,7 +17,7 @@ import { ReverseButton } from '../components/button/ReverseButton'; import { DropDownList, DropDownListItem, -} from '../components/filters/DropDownList'; +} from '../components/input/DropDownList'; import { NumberInput } from '../components/input/NumberInput'; import { cn } from '../lib/cn'; import DateInput from '../components/input/DateInput'; diff --git a/src/redux/slices/articles.ts b/src/redux/slices/articles.ts index e2346dc..00023bc 100644 --- a/src/redux/slices/articles.ts +++ b/src/redux/slices/articles.ts @@ -110,9 +110,14 @@ export const fetchArticles = createAsyncThunk( try { const params: any = { page, pageSize }; if (tags && tags.length > 0) params.tags = tags; + const response = await axios.get('/articles', { params, + paramsSerializer: { + indexes: null, + }, }); + return response.data; } catch (err: any) { return rejectWithValue( diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index 1d749cb..1d6919f 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -71,7 +71,12 @@ export const fetchMissions = createAsyncThunk( try { const params: any = { page, pageSize }; if (tags.length) params.tags = tags; - const response = await axios.get('/missions', { params }); + const response = await axios.get('/missions', { + params, + paramsSerializer: { + indexes: null, + }, + }); return response.data; // { missions, hasNextPage } } catch (err: any) { return rejectWithValue( diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index ba2c604..c6ff185 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -10,6 +10,18 @@ interface StorState { group: { groupFilter: string; }; + articles: { + articleTagFilter: string[]; + filterName: string; + }; + contests: { + contestsTagFilter: string[]; + filterName: string; + }; + missions: { + missionsTagFilter: string[]; + filterName: string; + }; } // Инициализация состояния @@ -22,6 +34,18 @@ const initialState: StorState = { group: { groupFilter: '', }, + articles: { + articleTagFilter: [], + filterName: '', + }, + contests: { + contestsTagFilter: [], + filterName: '', + }, + missions: { + missionsTagFilter: [], + filterName: '', + }, }; // Slice @@ -29,32 +53,63 @@ const storeSlice = createSlice({ name: 'store', initialState, reducers: { - setMenuActivePage: (state, activePage: PayloadAction) => { - state.menu.activePage = activePage.payload; + setMenuActivePage: (state, action: PayloadAction) => { + state.menu.activePage = action.payload; }, - setMenuActiveProfilePage: ( - state, - activeProfilePage: PayloadAction, - ) => { - state.menu.activeProfilePage = activeProfilePage.payload; + setMenuActiveProfilePage: (state, action: PayloadAction) => { + state.menu.activeProfilePage = action.payload; }, - setMenuActiveGroupPage: ( - state, - activeGroupPage: PayloadAction, - ) => { - state.menu.activeGroupPage = activeGroupPage.payload; + setMenuActiveGroupPage: (state, action: PayloadAction) => { + state.menu.activeGroupPage = action.payload; }, - setGroupFilter: (state, groupFilter: PayloadAction) => { - state.group.groupFilter = groupFilter.payload; + setGroupFilter: (state, action: PayloadAction) => { + state.group.groupFilter = action.payload; + }, + + // ---------- ARTICLES ---------- + setArticlesTagFilter: (state, action: PayloadAction) => { + state.articles.articleTagFilter = action.payload; + }, + setArticlesNameFilter: (state, action: PayloadAction) => { + state.articles.filterName = action.payload; + }, + + // ---------- CONTESTS ---------- + setContestsTagFilter: (state, action: PayloadAction) => { + state.contests.contestsTagFilter = action.payload; + }, + setContestsNameFilter: (state, action: PayloadAction) => { + state.contests.filterName = action.payload; + }, + + // ---------- MISSIONS ---------- + setMissionsTagFilter: (state, action: PayloadAction) => { + state.missions.missionsTagFilter = action.payload; + }, + setMissionsNameFilter: (state, action: PayloadAction) => { + state.missions.filterName = action.payload; }, }, }); export const { + // menu setMenuActivePage, setMenuActiveProfilePage, setMenuActiveGroupPage, setGroupFilter, + + // articles + setArticlesTagFilter, + setArticlesNameFilter, + + // contests + setContestsTagFilter, + setContestsNameFilter, + + // missions + setMissionsTagFilter, + setMissionsNameFilter, } = storeSlice.actions; export const storeReducer = storeSlice.reducer; diff --git a/src/views/home/account/missions/MyMissionItem.tsx b/src/views/home/account/missions/MyMissionItem.tsx index a882bb0..c52b050 100644 --- a/src/views/home/account/missions/MyMissionItem.tsx +++ b/src/views/home/account/missions/MyMissionItem.tsx @@ -1,6 +1,6 @@ import { cn } from '../../../../lib/cn'; import { useNavigate } from 'react-router-dom'; -import { Edit } from '../../../../assets/icons/input'; +import { Trash } from '../../../../assets/icons/input'; import { useAppSelector } from '../../../../redux/hooks'; export interface MissionItemProps { @@ -83,7 +83,7 @@ const MissionItem: React.FC = ({
= ({ id, name, tags }) => { const navigate = useNavigate(); + + const filterTags = useAppSelector( + (state) => state.store.articles.articleTagFilter, + ); + const nameFilter = useAppSelector( + (state) => state.store.articles.filterName, + ); + + const highlightZ = (name: string, filter: string) => { + if (!filter) return name; + + const s = filter.toLowerCase(); + const t = name.toLowerCase(); + const n = t.length; + const m = s.length; + + const mark = Array(n).fill(false); + + // Проходимся с конца и ставим отметки + for (let i = n - 1; i >= 0; i--) { + if (i + m <= n && t.slice(i, i + m) === s) { + for (let j = i; j < i + m; j++) { + if (mark[j]) break; + mark[j] = true; + } + } + } + + // === Формируем единые жёлтые блоки === + const result: any[] = []; + let i = 0; + + while (i < n) { + if (!mark[i]) { + // обычный символ + result.push(name[i]); + i++; + } else { + // начинаем жёлтый блок + let j = i; + while (j < n && mark[j]) j++; + + const chunk = name.slice(i, j); + result.push( + + {chunk} + , + ); + + i = j; + } + } + + return result; + }; + return (
= ({ id, name, tags }) => { #{id}
- {name} + {highlightZ(name, nameFilter)}
@@ -36,6 +96,8 @@ const ArticleItem: React.FC = ({ id, name, tags }) => { className={cn( 'rounded-full px-[16px] py-[8px] bg-liquid-lighter', v == 'Sertificated' && 'text-liquid-green', + filterTags.includes(v) && + 'border-liquid-brightmain border-[1px] border-solid text-liquid-brightmain', )} > {v} diff --git a/src/views/home/articles/Articles.tsx b/src/views/home/articles/Articles.tsx index 5c3caad..f2463cf 100644 --- a/src/views/home/articles/Articles.tsx +++ b/src/views/home/articles/Articles.tsx @@ -2,7 +2,11 @@ import { useEffect } from 'react'; import { SecondaryButton } from '../../../components/button/SecondaryButton'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import ArticleItem from './ArticleItem'; -import { setMenuActivePage } from '../../../redux/slices/store'; +import { + setArticlesNameFilter, + setArticlesTagFilter, + setMenuActivePage, +} from '../../../redux/slices/store'; import { useNavigate } from 'react-router-dom'; import { fetchArticles } from '../../../redux/slices/articles'; import Filters from './Filter'; @@ -15,39 +19,22 @@ const Articles = () => { const articles = useAppSelector( (state) => state.articles.fetchArticles.articles, ); - const status = useAppSelector( - (state) => state.articles.fetchArticles.status, + const tagsFilter = useAppSelector( + (state) => state.store.articles.articleTagFilter, + ); + const nameFilter = useAppSelector( + (state) => state.store.articles.filterName, ); - const error = useAppSelector((state) => state.articles.fetchArticles.error); useEffect(() => { dispatch(setMenuActivePage('articles')); - dispatch(fetchArticles({})); - }, [dispatch]); + dispatch(fetchArticles({ tags: tagsFilter })); + }, []); - // ======================== - // Состояния загрузки / ошибки - // ======================== - if (status === 'loading') { - return ( -
- Загрузка статей... -
- ); - } - - if (status === 'failed') { - return ( -
- Ошибка при загрузке статей - {error && ( -
- {error} -
- )} -
- ); - } + const filterTagsHandler = (value: string[]) => { + dispatch(setArticlesTagFilter(value)); + dispatch(fetchArticles({ tags: value })); + }; // ======================== // Основной контент @@ -68,7 +55,14 @@ const Articles = () => {
{/* Фильтры */} - + { + filterTagsHandler(value); + }} + onChangeName={(value: string) => { + dispatch(setArticlesNameFilter(value)); + }} + /> {/* Список статей */}
@@ -77,14 +71,15 @@ const Articles = () => { Пока нет статей
) : ( - articles.map((v) => ) + articles + .filter((v) => + v.name + .toLocaleLowerCase() + .includes(nameFilter.toLocaleLowerCase()), + ) + .map((v) => ) )} - - {/* Пагинация (пока заглушка) */} -
- pages -
); diff --git a/src/views/home/articles/Filter.tsx b/src/views/home/articles/Filter.tsx index efc1181..ad9eed9 100644 --- a/src/views/home/articles/Filter.tsx +++ b/src/views/home/articles/Filter.tsx @@ -1,51 +1,24 @@ -import { - FilterDropDown, - FilterItem, -} from '../../../components/filters/Filter'; -import { SorterDropDown } from '../../../components/filters/Sorter'; +import { FC } from 'react'; +import { TagFilter } from '../../../components/filters/TagFilter'; 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' }, - ]; +interface ArticleFiltersProps { + onChangeTags: (value: string[]) => void; + onChangeName: (value: string) => void; +} +const Filters: FC = ({ onChangeTags, onChangeName }) => { return (
- {}} placeholder="Поиск задачи" /> - - { - v; + { + onChangeName(value); }} + placeholder="Поиск статьи" /> - - { - values; + { + onChangeTags(value); }} />
diff --git a/src/views/home/contests/Contests.tsx b/src/views/home/contests/Contests.tsx index 0bf1055..d6b4892 100644 --- a/src/views/home/contests/Contests.tsx +++ b/src/views/home/contests/Contests.tsx @@ -3,7 +3,10 @@ import { SecondaryButton } from '../../../components/button/SecondaryButton'; import { cn } from '../../../lib/cn'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import ContestsBlock from './ContestsBlock'; -import { setMenuActivePage } from '../../../redux/slices/store'; +import { + setContestsNameFilter, + setMenuActivePage, +} from '../../../redux/slices/store'; import { fetchContests, fetchMyContests, @@ -21,6 +24,10 @@ const Contests = () => { (state) => state.contests.fetchContests, ); + const nameFilter = useAppSelector( + (state) => state.store.contests.filterName, + ); + // При загрузке страницы — выставляем активную вкладку и подгружаем контесты useEffect(() => { dispatch(setMenuActivePage('contests')); @@ -49,7 +56,11 @@ const Contests = () => { /> - + { + dispatch(setContestsNameFilter(v)); + }} + /> {status == 'loading' && (
Загрузка контестов... @@ -60,18 +71,30 @@ const Contests = () => { c.scheduleType != 'AlwaysOpen', - )} + contests={contests + .filter((v) => + v.name + .toLocaleLowerCase() + .includes( + nameFilter.toLocaleLowerCase(), + ), + ) + .filter((c) => c.scheduleType != 'AlwaysOpen')} type="upcoming" /> c.scheduleType == 'AlwaysOpen', - )} + contests={contests + .filter((v) => + v.name + .toLocaleLowerCase() + .includes( + nameFilter.toLocaleLowerCase(), + ), + ) + .filter((c) => c.scheduleType == 'AlwaysOpen')} type="past" /> diff --git a/src/views/home/contests/Filter.tsx b/src/views/home/contests/Filter.tsx index 0ff9845..4ef689b 100644 --- a/src/views/home/contests/Filter.tsx +++ b/src/views/home/contests/Filter.tsx @@ -1,49 +1,18 @@ -import { FilterDropDown, FilterItem } from '../../../components/filters/Filter'; -import { SorterDropDown } from '../../../components/filters/Sorter'; +import { FC } from 'react'; 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' }, - ]; +interface ContestFiltersProps { + onChangeName: (value: string) => void; +} +const Filters: FC = ({ onChangeName }) => { return (
- {}} placeholder="Поиск задачи" /> - - { - v; - }} - /> - - { - values; + { + onChangeName(value); }} + placeholder="Поиск контеста" />
); diff --git a/src/views/home/contests/ModalCreate.tsx b/src/views/home/contests/ModalCreate.tsx index b05cdaf..aa2d212 100644 --- a/src/views/home/contests/ModalCreate.tsx +++ b/src/views/home/contests/ModalCreate.tsx @@ -14,7 +14,7 @@ import { NumberInput } from '../../../components/input/NumberInput'; import { DropDownList, DropDownListItem, -} from '../../../components/filters/DropDownList'; +} from '../../../components/input/DropDownList'; import DateInput from '../../../components/input/DateInput'; import { cn } from '../../../lib/cn'; diff --git a/src/views/home/contests/PastContestItem.tsx b/src/views/home/contests/PastContestItem.tsx index 10ea4e3..c310171 100644 --- a/src/views/home/contests/PastContestItem.tsx +++ b/src/views/home/contests/PastContestItem.tsx @@ -83,6 +83,61 @@ const PastContestItem: React.FC = ({ (state) => state.contests.fetchParticipating, ); + const nameFilter = useAppSelector( + (state) => state.store.contests.filterName, + ); + + const highlightZ = (name: string, filter: string) => { + if (!filter) return name; + + const s = filter.toLowerCase(); + const t = name.toLowerCase(); + const n = t.length; + const m = s.length; + + const mark = Array(n).fill(false); + + // Проходимся с конца и ставим отметки + for (let i = n - 1; i >= 0; i--) { + if (i + m <= n && t.slice(i, i + m) === s) { + for (let j = i; j < i + m; j++) { + if (mark[j]) break; + mark[j] = true; + } + } + } + + // === Формируем единые жёлтые блоки === + const result: any[] = []; + let i = 0; + + while (i < n) { + if (!mark[i]) { + // обычный символ + result.push(name[i]); + i++; + } else { + // начинаем жёлтый блок + let j = i; + while (j < n && mark[j]) j++; + + const chunk = name.slice(i, j); + result.push( + + {chunk} + , + ); + + i = j; + } + } + + return result; + }; + useEffect(() => { setRole( (() => { @@ -119,7 +174,9 @@ const PastContestItem: React.FC = ({ navigate(`/contest/${contestId}?${params}`); }} > -
{name}
+
+ {highlightZ(name, nameFilter)} +
{username}
diff --git a/src/views/home/contests/UpcomingContestItem.tsx b/src/views/home/contests/UpcomingContestItem.tsx index f06e297..972ad99 100644 --- a/src/views/home/contests/UpcomingContestItem.tsx +++ b/src/views/home/contests/UpcomingContestItem.tsx @@ -98,6 +98,61 @@ const UpcoingContestItem: React.FC = ({ (state) => state.contests.fetchParticipating, ); + const nameFilter = useAppSelector( + (state) => state.store.contests.filterName, + ); + + const highlightZ = (name: string, filter: string) => { + if (!filter) return name; + + const s = filter.toLowerCase(); + const t = name.toLowerCase(); + const n = t.length; + const m = s.length; + + const mark = Array(n).fill(false); + + // Проходимся с конца и ставим отметки + for (let i = n - 1; i >= 0; i--) { + if (i + m <= n && t.slice(i, i + m) === s) { + for (let j = i; j < i + m; j++) { + if (mark[j]) break; + mark[j] = true; + } + } + } + + // === Формируем единые жёлтые блоки === + const result: any[] = []; + let i = 0; + + while (i < n) { + if (!mark[i]) { + // обычный символ + result.push(name[i]); + i++; + } else { + // начинаем жёлтый блок + let j = i; + while (j < n && mark[j]) j++; + + const chunk = name.slice(i, j); + result.push( + + {chunk} + , + ); + + i = j; + } + } + + return result; + }; + const query = useQuery(); const username = query.get('username') ?? myname ?? ''; @@ -146,7 +201,9 @@ const UpcoingContestItem: React.FC = ({ navigate(`/contest/${contestId}?${params}`); }} > -
{name}
+
+ {highlightZ(name, nameFilter)} +
{username}
diff --git a/src/views/home/missions/Filter.tsx b/src/views/home/missions/Filter.tsx index efc1181..665b478 100644 --- a/src/views/home/missions/Filter.tsx +++ b/src/views/home/missions/Filter.tsx @@ -1,51 +1,24 @@ -import { - FilterDropDown, - FilterItem, -} from '../../../components/filters/Filter'; -import { SorterDropDown } from '../../../components/filters/Sorter'; +import { FC } from 'react'; +import { TagFilter } from '../../../components/filters/TagFilter'; 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' }, - ]; +interface MissionFiltersProps { + onChangeTags: (value: string[]) => void; + onChangeName: (value: string) => void; +} +const Filters: FC = ({ onChangeTags, onChangeName }) => { return (
- {}} placeholder="Поиск задачи" /> - - { - v; + { + onChangeName(value); }} + placeholder="Поиск задачи" /> - - { - values; + { + onChangeTags(value); }} />
diff --git a/src/views/home/missions/MissionItem.tsx b/src/views/home/missions/MissionItem.tsx index 3d4b6cc..b3e30df 100644 --- a/src/views/home/missions/MissionItem.tsx +++ b/src/views/home/missions/MissionItem.tsx @@ -1,6 +1,7 @@ import { cn } from '../../../lib/cn'; import { IconError, IconSuccess } from '../../../assets/icons/missions'; import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../redux/hooks'; export interface MissionItemProps { id: number; @@ -38,6 +39,61 @@ const MissionItem: React.FC = ({ }) => { const navigate = useNavigate(); + const nameFilter = useAppSelector( + (state) => state.store.missions.filterName, + ); + + const highlightZ = (name: string, filter: string) => { + if (!filter) return name; + + const s = filter.toLowerCase(); + const t = name.toLowerCase(); + const n = t.length; + const m = s.length; + + const mark = Array(n).fill(false); + + // Проходимся с конца и ставим отметки + for (let i = n - 1; i >= 0; i--) { + if (i + m <= n && t.slice(i, i + m) === s) { + for (let j = i; j < i + m; j++) { + if (mark[j]) break; + mark[j] = true; + } + } + } + + // === Формируем единые жёлтые блоки === + const result: any[] = []; + let i = 0; + + while (i < n) { + if (!mark[i]) { + // обычный символ + result.push(name[i]); + i++; + } else { + // начинаем жёлтый блок + let j = i; + while (j < n && mark[j]) j++; + + const chunk = name.slice(i, j); + result.push( + + {chunk} + , + ); + + i = j; + } + } + + return result; + }; + return (
= ({ }} >
#{id}
-
{name}
+
+ {highlightZ(name, nameFilter)} +
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '} {formatBytesToMB(memoryLimit)} diff --git a/src/views/home/missions/Missions.tsx b/src/views/home/missions/Missions.tsx index 511d036..fb2f34d 100644 --- a/src/views/home/missions/Missions.tsx +++ b/src/views/home/missions/Missions.tsx @@ -2,7 +2,11 @@ import MissionItem from './MissionItem'; import { SecondaryButton } from '../../../components/button/SecondaryButton'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { useEffect, useState } from 'react'; -import { setMenuActivePage } from '../../../redux/slices/store'; +import { + setMenuActivePage, + setMissionsNameFilter, + setMissionsTagFilter, +} from '../../../redux/slices/store'; import { fetchMissions } from '../../../redux/slices/missions'; import ModalCreate from './ModalCreate'; import Filters from './Filter'; @@ -25,10 +29,21 @@ const Missions = () => { const missions = useAppSelector((state) => state.missions.missions); + const nameFilter = useAppSelector( + (state) => state.store.missions.filterName, + ); + const tagsFilter = useAppSelector( + (state) => state.store.articles.articleTagFilter, + ); + useEffect(() => { dispatch(setMenuActivePage('missions')); - dispatch(fetchMissions({})); + dispatch(fetchMissions({ tags: tagsFilter })); }, []); + const filterTagsHandler = (value: string[]) => { + dispatch(setMissionsTagFilter(value)); + dispatch(fetchMissions({ tags: value })); + }; return (
@@ -46,28 +61,39 @@ const Missions = () => { />
- + { + filterTagsHandler(value); + }} + onChangeName={(value: string) => { + dispatch(setMissionsNameFilter(value)); + }} + />
- {missions.map((v, i) => ( - - ))} + {missions + .filter((v) => + v.name + .toLowerCase() + .includes(nameFilter.toLocaleLowerCase()), + ) + .map((v, i) => ( + + ))}
- -
pages
diff --git a/src/views/home/rightpanel/group/ModalUpdate.tsx b/src/views/home/rightpanel/group/ModalUpdate.tsx index 4830d23..bb3e1cb 100644 --- a/src/views/home/rightpanel/group/ModalUpdate.tsx +++ b/src/views/home/rightpanel/group/ModalUpdate.tsx @@ -14,7 +14,7 @@ import ConfirmModal from '../../../../components/modal/ConfirmModal'; import { DropDownList, DropDownListItem, -} from '../../../../components/filters/DropDownList'; +} from '../../../../components/input/DropDownList'; import { ReverseButton } from '../../../../components/button/ReverseButton'; interface ModalUpdateProps { diff --git a/src/views/mission/codeeditor/CodeEditor.tsx b/src/views/mission/codeeditor/CodeEditor.tsx index 768cbe6..0f501f4 100644 --- a/src/views/mission/codeeditor/CodeEditor.tsx +++ b/src/views/mission/codeeditor/CodeEditor.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import Editor from '@monaco-editor/react'; import { upload } from '../../../assets/icons/input'; import { cn } from '../../../lib/cn'; -import { DropDownList } from '../../../components/filters/DropDownList'; +import { DropDownList } from '../../../components/input/DropDownList'; const languageMap: Record = { c: 'cpp',