add filters
This commit is contained in:
@@ -6,6 +6,7 @@ import chevroneDropDownList from './chevron-drop-down.svg';
|
|||||||
import checkMark from './check-mark.svg';
|
import checkMark from './check-mark.svg';
|
||||||
import Edit from './edit.svg';
|
import Edit from './edit.svg';
|
||||||
import Send from './send.svg';
|
import Send from './send.svg';
|
||||||
|
import Trash from './trash.svg';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -16,4 +17,5 @@ export {
|
|||||||
chevroneDropDownList,
|
chevroneDropDownList,
|
||||||
checkMark,
|
checkMark,
|
||||||
Send,
|
Send,
|
||||||
|
Trash,
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/assets/icons/input/trash.svg
Normal file
3
src/assets/icons/input/trash.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="M4 6.17647H20M9 3H15M10 16.7647V10.4118M14 16.7647V10.4118M15.5 21H8.5C7.39543 21 6.5 20.0519 6.5 18.8824L6.0434 7.27937C6.01973 6.67783 6.47392 6.17647 7.04253 6.17647H16.9575C17.5261 6.17647 17.9803 6.67783 17.9566 7.27937L17.5 18.8824C17.5 20.0519 16.6046 21 15.5 21Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 467 B |
161
src/components/filters/TagFilter.tsx
Normal file
161
src/components/filters/TagFilter.tsx
Normal file
@@ -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<TagFilterProps> = ({
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [active, setActive] = React.useState(false);
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState<string>('');
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// ==========================
|
||||||
|
// Теги
|
||||||
|
// ==========================
|
||||||
|
const addTag = () => {
|
||||||
|
if (tags.length > 30) {
|
||||||
|
setTagInput('');
|
||||||
|
toastError('Нельзя добавить больше 30 тегов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newTag = tagInput.trim();
|
||||||
|
if (newTag && !tags.includes(newTag)) {
|
||||||
|
setTags([...tags, newTag]);
|
||||||
|
setTagInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
setTags(tags.filter((tag) => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTags = () => {
|
||||||
|
setTags([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useClickOutside(ref, () => {
|
||||||
|
setActive(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onChange(tags);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)} ref={ref}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'items-center h-[40px] rounded-full bg-liquid-lighter w-[40px] flex',
|
||||||
|
'text-[18px] font-bold cursor-pointer select-none',
|
||||||
|
'overflow-hidden',
|
||||||
|
(active || tags.length > 0) &&
|
||||||
|
'w-fit border-liquid-brightmain border-[1px] border-solid',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) setActive(!active);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-liquid-brightmain pl-[42px] pr-[16px] w-fit">
|
||||||
|
{tags.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter icons */}
|
||||||
|
<img
|
||||||
|
src={iconFilter}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={iconFilterActive}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-[8px] top-[8px] h-[24px] w-[24px] rotate-0 transition-all duration-300 pointer-events-none opacity-0',
|
||||||
|
(active || tags.length > 0) && 'opacity-100',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded-[10px] bg-liquid-background w-[590px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||||
|
'grid overflow-hidden border-liquid-lighter border-[3px] border-solid',
|
||||||
|
active
|
||||||
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
|
: 'grid-rows-[0fr] opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden p-[8px]">
|
||||||
|
<div className="overflow-y-scroll min-h-[130px] thin-scrollbar grid gap-[20px]">
|
||||||
|
{/* Теги */}
|
||||||
|
<div className="">
|
||||||
|
<div className="grid grid-cols-[1fr,140px,130px] items-end gap-2">
|
||||||
|
<Input
|
||||||
|
name="articleTag"
|
||||||
|
autocomplete="articleTag"
|
||||||
|
className="max-w-[600px] "
|
||||||
|
type="text"
|
||||||
|
label="Теги"
|
||||||
|
onChange={setTagInput}
|
||||||
|
defaultState={tagInput}
|
||||||
|
placeholder="arrays"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') addTag();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={addTag}
|
||||||
|
text="Добавить"
|
||||||
|
className="h-[40px] w-[140px]"
|
||||||
|
/>
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={resetTags}
|
||||||
|
text="Сбросить"
|
||||||
|
className="h-[40px] w-[130px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-[10px] mt-2 ">
|
||||||
|
{tags.length == 0 ? (
|
||||||
|
<div className="text-liquid-brightmain flex items-center justify-center w-full h-[50px]">
|
||||||
|
Вы еще не добавили ни одного тега
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="text-liquid-red font-bold ml-[5px]"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ interface inputProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
onChange: (state: string) => void;
|
onChange: (state: string) => void;
|
||||||
defaultState?: string;
|
defaultState?: string;
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
@@ -25,6 +26,7 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
label = '',
|
label = '',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
className = '',
|
className = '',
|
||||||
|
inputClassName = '',
|
||||||
onChange,
|
onChange,
|
||||||
defaultState = '',
|
defaultState = '',
|
||||||
name = '',
|
name = '',
|
||||||
@@ -52,6 +54,7 @@ export const Input: React.FC<inputProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
||||||
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
||||||
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ReverseButton } from '../components/button/ReverseButton';
|
|||||||
import {
|
import {
|
||||||
DropDownList,
|
DropDownList,
|
||||||
DropDownListItem,
|
DropDownListItem,
|
||||||
} from '../components/filters/DropDownList';
|
} from '../components/input/DropDownList';
|
||||||
import { NumberInput } from '../components/input/NumberInput';
|
import { NumberInput } from '../components/input/NumberInput';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import DateInput from '../components/input/DateInput';
|
import DateInput from '../components/input/DateInput';
|
||||||
|
|||||||
@@ -110,9 +110,14 @@ export const fetchArticles = createAsyncThunk(
|
|||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (tags && tags.length > 0) params.tags = tags;
|
if (tags && tags.length > 0) params.tags = tags;
|
||||||
|
|
||||||
const response = await axios.get<ArticlesResponse>('/articles', {
|
const response = await axios.get<ArticlesResponse>('/articles', {
|
||||||
params,
|
params,
|
||||||
|
paramsSerializer: {
|
||||||
|
indexes: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(
|
||||||
|
|||||||
@@ -71,7 +71,12 @@ export const fetchMissions = createAsyncThunk(
|
|||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (tags.length) params.tags = tags;
|
if (tags.length) params.tags = tags;
|
||||||
const response = await axios.get('/missions', { params });
|
const response = await axios.get('/missions', {
|
||||||
|
params,
|
||||||
|
paramsSerializer: {
|
||||||
|
indexes: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
return response.data; // { missions, hasNextPage }
|
return response.data; // { missions, hasNextPage }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ interface StorState {
|
|||||||
group: {
|
group: {
|
||||||
groupFilter: string;
|
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: {
|
group: {
|
||||||
groupFilter: '',
|
groupFilter: '',
|
||||||
},
|
},
|
||||||
|
articles: {
|
||||||
|
articleTagFilter: [],
|
||||||
|
filterName: '',
|
||||||
|
},
|
||||||
|
contests: {
|
||||||
|
contestsTagFilter: [],
|
||||||
|
filterName: '',
|
||||||
|
},
|
||||||
|
missions: {
|
||||||
|
missionsTagFilter: [],
|
||||||
|
filterName: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slice
|
// Slice
|
||||||
@@ -29,32 +53,63 @@ const storeSlice = createSlice({
|
|||||||
name: 'store',
|
name: 'store',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
|
setMenuActivePage: (state, action: PayloadAction<string>) => {
|
||||||
state.menu.activePage = activePage.payload;
|
state.menu.activePage = action.payload;
|
||||||
},
|
},
|
||||||
setMenuActiveProfilePage: (
|
setMenuActiveProfilePage: (state, action: PayloadAction<string>) => {
|
||||||
state,
|
state.menu.activeProfilePage = action.payload;
|
||||||
activeProfilePage: PayloadAction<string>,
|
|
||||||
) => {
|
|
||||||
state.menu.activeProfilePage = activeProfilePage.payload;
|
|
||||||
},
|
},
|
||||||
setMenuActiveGroupPage: (
|
setMenuActiveGroupPage: (state, action: PayloadAction<string>) => {
|
||||||
state,
|
state.menu.activeGroupPage = action.payload;
|
||||||
activeGroupPage: PayloadAction<string>,
|
|
||||||
) => {
|
|
||||||
state.menu.activeGroupPage = activeGroupPage.payload;
|
|
||||||
},
|
},
|
||||||
setGroupFilter: (state, groupFilter: PayloadAction<string>) => {
|
setGroupFilter: (state, action: PayloadAction<string>) => {
|
||||||
state.group.groupFilter = groupFilter.payload;
|
state.group.groupFilter = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- ARTICLES ----------
|
||||||
|
setArticlesTagFilter: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.articles.articleTagFilter = action.payload;
|
||||||
|
},
|
||||||
|
setArticlesNameFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.articles.filterName = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- CONTESTS ----------
|
||||||
|
setContestsTagFilter: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.contests.contestsTagFilter = action.payload;
|
||||||
|
},
|
||||||
|
setContestsNameFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.contests.filterName = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- MISSIONS ----------
|
||||||
|
setMissionsTagFilter: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.missions.missionsTagFilter = action.payload;
|
||||||
|
},
|
||||||
|
setMissionsNameFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.missions.filterName = action.payload;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
// menu
|
||||||
setMenuActivePage,
|
setMenuActivePage,
|
||||||
setMenuActiveProfilePage,
|
setMenuActiveProfilePage,
|
||||||
setMenuActiveGroupPage,
|
setMenuActiveGroupPage,
|
||||||
setGroupFilter,
|
setGroupFilter,
|
||||||
|
|
||||||
|
// articles
|
||||||
|
setArticlesTagFilter,
|
||||||
|
setArticlesNameFilter,
|
||||||
|
|
||||||
|
// contests
|
||||||
|
setContestsTagFilter,
|
||||||
|
setContestsNameFilter,
|
||||||
|
|
||||||
|
// missions
|
||||||
|
setMissionsTagFilter,
|
||||||
|
setMissionsNameFilter,
|
||||||
} = storeSlice.actions;
|
} = storeSlice.actions;
|
||||||
|
|
||||||
export const storeReducer = storeSlice.reducer;
|
export const storeReducer = storeSlice.reducer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Edit } from '../../../../assets/icons/input';
|
import { Trash } from '../../../../assets/icons/input';
|
||||||
import { useAppSelector } from '../../../../redux/hooks';
|
import { useAppSelector } from '../../../../redux/hooks';
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
@@ -83,7 +83,7 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-[24px] w-[24px]">
|
<div className="h-[24px] w-[24px]">
|
||||||
<img
|
<img
|
||||||
src={Edit}
|
src={Trash}
|
||||||
className={cn(
|
className={cn(
|
||||||
'hover:bg-liquid-light rounded-[8px] transition-all duration-300',
|
'hover:bg-liquid-light rounded-[8px] transition-all duration-300',
|
||||||
deleteStatus == 'loading' &&
|
deleteStatus == 'loading' &&
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { useAppSelector } from '../../../redux/hooks';
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
export interface ArticleItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,6 +10,65 @@ export interface ArticleItemProps {
|
|||||||
|
|
||||||
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const filterTags = useAppSelector(
|
||||||
|
(state) => state.store.articles.articleTagFilter,
|
||||||
|
);
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.articles.filterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightZ = (name: string, filter: string) => {
|
||||||
|
if (!filter) return name;
|
||||||
|
|
||||||
|
const s = filter.toLowerCase();
|
||||||
|
const t = name.toLowerCase();
|
||||||
|
const n = t.length;
|
||||||
|
const m = s.length;
|
||||||
|
|
||||||
|
const mark = Array(n).fill(false);
|
||||||
|
|
||||||
|
// Проходимся с конца и ставим отметки
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
if (i + m <= n && t.slice(i, i + m) === s) {
|
||||||
|
for (let j = i; j < i + m; j++) {
|
||||||
|
if (mark[j]) break;
|
||||||
|
mark[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Формируем единые жёлтые блоки ===
|
||||||
|
const result: any[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
if (!mark[i]) {
|
||||||
|
// обычный символ
|
||||||
|
result.push(name[i]);
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// начинаем жёлтый блок
|
||||||
|
let j = i;
|
||||||
|
while (j < n && mark[j]) j++;
|
||||||
|
|
||||||
|
const chunk = name.slice(i, j);
|
||||||
|
result.push(
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="bg-yellow-400 text-black rounded px-1"
|
||||||
|
>
|
||||||
|
{chunk}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -26,7 +86,7 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
#{id}
|
#{id}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
<div className="text-[18px] font-bold flex items-center bg-red-400r">
|
||||||
{name}
|
{highlightZ(name, nameFilter)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
||||||
@@ -36,6 +96,8 @@ const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
||||||
v == 'Sertificated' && 'text-liquid-green',
|
v == 'Sertificated' && 'text-liquid-green',
|
||||||
|
filterTags.includes(v) &&
|
||||||
|
'border-liquid-brightmain border-[1px] border-solid text-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{v}
|
{v}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useEffect } from 'react';
|
|||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ArticleItem from './ArticleItem';
|
import ArticleItem from './ArticleItem';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import {
|
||||||
|
setArticlesNameFilter,
|
||||||
|
setArticlesTagFilter,
|
||||||
|
setMenuActivePage,
|
||||||
|
} from '../../../redux/slices/store';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { fetchArticles } from '../../../redux/slices/articles';
|
import { fetchArticles } from '../../../redux/slices/articles';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
@@ -15,39 +19,22 @@ const Articles = () => {
|
|||||||
const articles = useAppSelector(
|
const articles = useAppSelector(
|
||||||
(state) => state.articles.fetchArticles.articles,
|
(state) => state.articles.fetchArticles.articles,
|
||||||
);
|
);
|
||||||
const status = useAppSelector(
|
const tagsFilter = useAppSelector(
|
||||||
(state) => state.articles.fetchArticles.status,
|
(state) => state.store.articles.articleTagFilter,
|
||||||
|
);
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.articles.filterName,
|
||||||
);
|
);
|
||||||
const error = useAppSelector((state) => state.articles.fetchArticles.error);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('articles'));
|
dispatch(setMenuActivePage('articles'));
|
||||||
dispatch(fetchArticles({}));
|
dispatch(fetchArticles({ tags: tagsFilter }));
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
// ========================
|
const filterTagsHandler = (value: string[]) => {
|
||||||
// Состояния загрузки / ошибки
|
dispatch(setArticlesTagFilter(value));
|
||||||
// ========================
|
dispatch(fetchArticles({ tags: value }));
|
||||||
if (status === 'loading') {
|
};
|
||||||
return (
|
|
||||||
<div className="h-full w-full flex items-center justify-center text-liquid-light text-[18px]">
|
|
||||||
Загрузка статей...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'failed') {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full flex flex-col items-center justify-center text-liquid-red text-[18px]">
|
|
||||||
Ошибка при загрузке статей
|
|
||||||
{error && (
|
|
||||||
<div className="text-liquid-light text-[14px] mt-2">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Основной контент
|
// Основной контент
|
||||||
@@ -68,7 +55,14 @@ const Articles = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Фильтры */}
|
{/* Фильтры */}
|
||||||
<Filters />
|
<Filters
|
||||||
|
onChangeTags={(value: string[]) => {
|
||||||
|
filterTagsHandler(value);
|
||||||
|
}}
|
||||||
|
onChangeName={(value: string) => {
|
||||||
|
dispatch(setArticlesNameFilter(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Список статей */}
|
{/* Список статей */}
|
||||||
<div className="mt-[20px]">
|
<div className="mt-[20px]">
|
||||||
@@ -77,14 +71,15 @@ const Articles = () => {
|
|||||||
Пока нет статей
|
Пока нет статей
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
articles.map((v) => <ArticleItem key={v.id} {...v} />)
|
articles
|
||||||
|
.filter((v) =>
|
||||||
|
v.name
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(nameFilter.toLocaleLowerCase()),
|
||||||
|
)
|
||||||
|
.map((v) => <ArticleItem key={v.id} {...v} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Пагинация (пока заглушка) */}
|
|
||||||
<div className="mt-[20px] text-liquid-light text-[14px]">
|
|
||||||
pages
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +1,24 @@
|
|||||||
import {
|
import { FC } from 'react';
|
||||||
FilterDropDown,
|
import { TagFilter } from '../../../components/filters/TagFilter';
|
||||||
FilterItem,
|
|
||||||
} from '../../../components/filters/Filter';
|
|
||||||
import { SorterDropDown } from '../../../components/filters/Sorter';
|
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
const Filters = () => {
|
interface ArticleFiltersProps {
|
||||||
const items: FilterItem[] = [
|
onChangeTags: (value: string[]) => void;
|
||||||
{ text: 'React', value: 'react' },
|
onChangeName: (value: string) => void;
|
||||||
{ text: 'Vue', value: 'vue' },
|
}
|
||||||
{ text: 'Angular', value: 'angular' },
|
|
||||||
{ text: 'Svelte', value: 'svelte' },
|
|
||||||
{ text: 'Next.js', value: 'next' },
|
|
||||||
{ text: 'Nuxt', value: 'nuxt' },
|
|
||||||
{ text: 'Solid', value: 'solid' },
|
|
||||||
{ text: 'Qwik', value: 'qwik' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const Filters: FC<ArticleFiltersProps> = ({ onChangeTags, onChangeName }) => {
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
<SearchInput
|
||||||
|
onChange={(value: string) => {
|
||||||
<SorterDropDown
|
onChangeName(value);
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: '1',
|
|
||||||
text: 'Сложность',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '2',
|
|
||||||
text: 'Дата создания',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '3',
|
|
||||||
text: 'ID',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={(v) => {
|
|
||||||
v;
|
|
||||||
}}
|
}}
|
||||||
|
placeholder="Поиск статьи"
|
||||||
/>
|
/>
|
||||||
|
<TagFilter
|
||||||
<FilterDropDown
|
onChange={(value: string[]) => {
|
||||||
items={items}
|
onChangeTags(value);
|
||||||
defaultState={[]}
|
|
||||||
onChange={(values) => {
|
|
||||||
values;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ContestsBlock from './ContestsBlock';
|
import ContestsBlock from './ContestsBlock';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import {
|
||||||
|
setContestsNameFilter,
|
||||||
|
setMenuActivePage,
|
||||||
|
} from '../../../redux/slices/store';
|
||||||
import {
|
import {
|
||||||
fetchContests,
|
fetchContests,
|
||||||
fetchMyContests,
|
fetchMyContests,
|
||||||
@@ -21,6 +24,10 @@ const Contests = () => {
|
|||||||
(state) => state.contests.fetchContests,
|
(state) => state.contests.fetchContests,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.contests.filterName,
|
||||||
|
);
|
||||||
|
|
||||||
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('contests'));
|
dispatch(setMenuActivePage('contests'));
|
||||||
@@ -49,7 +56,11 @@ const Contests = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filters />
|
<Filters
|
||||||
|
onChangeName={(v: string) => {
|
||||||
|
dispatch(setContestsNameFilter(v));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{status == 'loading' && (
|
{status == 'loading' && (
|
||||||
<div className="text-liquid-white p-4">
|
<div className="text-liquid-white p-4">
|
||||||
Загрузка контестов...
|
Загрузка контестов...
|
||||||
@@ -60,18 +71,30 @@ const Contests = () => {
|
|||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Текущие"
|
title="Текущие"
|
||||||
contests={contests.filter(
|
contests={contests
|
||||||
(c) => c.scheduleType != 'AlwaysOpen',
|
.filter((v) =>
|
||||||
)}
|
v.name
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(
|
||||||
|
nameFilter.toLocaleLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((c) => c.scheduleType != 'AlwaysOpen')}
|
||||||
type="upcoming"
|
type="upcoming"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Постоянные"
|
title="Постоянные"
|
||||||
contests={contests.filter(
|
contests={contests
|
||||||
(c) => c.scheduleType == 'AlwaysOpen',
|
.filter((v) =>
|
||||||
)}
|
v.name
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(
|
||||||
|
nameFilter.toLocaleLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((c) => c.scheduleType == 'AlwaysOpen')}
|
||||||
type="past"
|
type="past"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,49 +1,18 @@
|
|||||||
import { FilterDropDown, FilterItem } from '../../../components/filters/Filter';
|
import { FC } from 'react';
|
||||||
import { SorterDropDown } from '../../../components/filters/Sorter';
|
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
const Filters = () => {
|
interface ContestFiltersProps {
|
||||||
const items: FilterItem[] = [
|
onChangeName: (value: string) => void;
|
||||||
{ text: 'React', value: 'react' },
|
}
|
||||||
{ text: 'Vue', value: 'vue' },
|
|
||||||
{ text: 'Angular', value: 'angular' },
|
|
||||||
{ text: 'Svelte', value: 'svelte' },
|
|
||||||
{ text: 'Next.js', value: 'next' },
|
|
||||||
{ text: 'Nuxt', value: 'nuxt' },
|
|
||||||
{ text: 'Solid', value: 'solid' },
|
|
||||||
{ text: 'Qwik', value: 'qwik' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const Filters: FC<ContestFiltersProps> = ({ onChangeName }) => {
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
<SearchInput
|
||||||
|
onChange={(value: string) => {
|
||||||
<SorterDropDown
|
onChangeName(value);
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: '1',
|
|
||||||
text: 'Сложность',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '2',
|
|
||||||
text: 'Дата создания',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '3',
|
|
||||||
text: 'ID',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={(v) => {
|
|
||||||
v;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterDropDown
|
|
||||||
items={items}
|
|
||||||
defaultState={[]}
|
|
||||||
onChange={(values) => {
|
|
||||||
values;
|
|
||||||
}}
|
}}
|
||||||
|
placeholder="Поиск контеста"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { NumberInput } from '../../../components/input/NumberInput';
|
|||||||
import {
|
import {
|
||||||
DropDownList,
|
DropDownList,
|
||||||
DropDownListItem,
|
DropDownListItem,
|
||||||
} from '../../../components/filters/DropDownList';
|
} from '../../../components/input/DropDownList';
|
||||||
import DateInput from '../../../components/input/DateInput';
|
import DateInput from '../../../components/input/DateInput';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,61 @@ const PastContestItem: React.FC<PastContestItemProps> = ({
|
|||||||
(state) => state.contests.fetchParticipating,
|
(state) => state.contests.fetchParticipating,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.contests.filterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightZ = (name: string, filter: string) => {
|
||||||
|
if (!filter) return name;
|
||||||
|
|
||||||
|
const s = filter.toLowerCase();
|
||||||
|
const t = name.toLowerCase();
|
||||||
|
const n = t.length;
|
||||||
|
const m = s.length;
|
||||||
|
|
||||||
|
const mark = Array(n).fill(false);
|
||||||
|
|
||||||
|
// Проходимся с конца и ставим отметки
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
if (i + m <= n && t.slice(i, i + m) === s) {
|
||||||
|
for (let j = i; j < i + m; j++) {
|
||||||
|
if (mark[j]) break;
|
||||||
|
mark[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Формируем единые жёлтые блоки ===
|
||||||
|
const result: any[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
if (!mark[i]) {
|
||||||
|
// обычный символ
|
||||||
|
result.push(name[i]);
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// начинаем жёлтый блок
|
||||||
|
let j = i;
|
||||||
|
while (j < n && mark[j]) j++;
|
||||||
|
|
||||||
|
const chunk = name.slice(i, j);
|
||||||
|
result.push(
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="bg-yellow-400 text-black rounded px-1"
|
||||||
|
>
|
||||||
|
{chunk}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRole(
|
setRole(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -119,7 +174,9 @@ const PastContestItem: React.FC<PastContestItemProps> = ({
|
|||||||
navigate(`/contest/${contestId}?${params}`);
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
<div className="text-left font-bold text-[18px]">
|
||||||
|
{highlightZ(name, nameFilter)}
|
||||||
|
</div>
|
||||||
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
||||||
{username}
|
{username}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,6 +98,61 @@ const UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
|
|||||||
(state) => state.contests.fetchParticipating,
|
(state) => state.contests.fetchParticipating,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.contests.filterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightZ = (name: string, filter: string) => {
|
||||||
|
if (!filter) return name;
|
||||||
|
|
||||||
|
const s = filter.toLowerCase();
|
||||||
|
const t = name.toLowerCase();
|
||||||
|
const n = t.length;
|
||||||
|
const m = s.length;
|
||||||
|
|
||||||
|
const mark = Array(n).fill(false);
|
||||||
|
|
||||||
|
// Проходимся с конца и ставим отметки
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
if (i + m <= n && t.slice(i, i + m) === s) {
|
||||||
|
for (let j = i; j < i + m; j++) {
|
||||||
|
if (mark[j]) break;
|
||||||
|
mark[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Формируем единые жёлтые блоки ===
|
||||||
|
const result: any[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
if (!mark[i]) {
|
||||||
|
// обычный символ
|
||||||
|
result.push(name[i]);
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// начинаем жёлтый блок
|
||||||
|
let j = i;
|
||||||
|
while (j < n && mark[j]) j++;
|
||||||
|
|
||||||
|
const chunk = name.slice(i, j);
|
||||||
|
result.push(
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="bg-yellow-400 text-black rounded px-1"
|
||||||
|
>
|
||||||
|
{chunk}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const query = useQuery();
|
const query = useQuery();
|
||||||
const username = query.get('username') ?? myname ?? '';
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
@@ -146,7 +201,9 @@ const UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
|
|||||||
navigate(`/contest/${contestId}?${params}`);
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-left font-bold text-[18px]">{name}</div>
|
<div className="text-left font-bold text-[18px]">
|
||||||
|
{highlightZ(name, nameFilter)}
|
||||||
|
</div>
|
||||||
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
||||||
{username}
|
{username}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,24 @@
|
|||||||
import {
|
import { FC } from 'react';
|
||||||
FilterDropDown,
|
import { TagFilter } from '../../../components/filters/TagFilter';
|
||||||
FilterItem,
|
|
||||||
} from '../../../components/filters/Filter';
|
|
||||||
import { SorterDropDown } from '../../../components/filters/Sorter';
|
|
||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
|
||||||
const Filters = () => {
|
interface MissionFiltersProps {
|
||||||
const items: FilterItem[] = [
|
onChangeTags: (value: string[]) => void;
|
||||||
{ text: 'React', value: 'react' },
|
onChangeName: (value: string) => void;
|
||||||
{ text: 'Vue', value: 'vue' },
|
}
|
||||||
{ text: 'Angular', value: 'angular' },
|
|
||||||
{ text: 'Svelte', value: 'svelte' },
|
|
||||||
{ text: 'Next.js', value: 'next' },
|
|
||||||
{ text: 'Nuxt', value: 'nuxt' },
|
|
||||||
{ text: 'Solid', value: 'solid' },
|
|
||||||
{ text: 'Qwik', value: 'qwik' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const Filters: FC<MissionFiltersProps> = ({ onChangeTags, onChangeName }) => {
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput onChange={() => {}} placeholder="Поиск задачи" />
|
<SearchInput
|
||||||
|
onChange={(value: string) => {
|
||||||
<SorterDropDown
|
onChangeName(value);
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: '1',
|
|
||||||
text: 'Сложность',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '2',
|
|
||||||
text: 'Дата создания',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '3',
|
|
||||||
text: 'ID',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={(v) => {
|
|
||||||
v;
|
|
||||||
}}
|
}}
|
||||||
|
placeholder="Поиск задачи"
|
||||||
/>
|
/>
|
||||||
|
<TagFilter
|
||||||
<FilterDropDown
|
onChange={(value: string[]) => {
|
||||||
items={items}
|
onChangeTags(value);
|
||||||
defaultState={[]}
|
|
||||||
onChange={(values) => {
|
|
||||||
values;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppSelector } from '../../../redux/hooks';
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -38,6 +39,61 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const nameFilter = useAppSelector(
|
||||||
|
(state) => state.store.missions.filterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightZ = (name: string, filter: string) => {
|
||||||
|
if (!filter) return name;
|
||||||
|
|
||||||
|
const s = filter.toLowerCase();
|
||||||
|
const t = name.toLowerCase();
|
||||||
|
const n = t.length;
|
||||||
|
const m = s.length;
|
||||||
|
|
||||||
|
const mark = Array(n).fill(false);
|
||||||
|
|
||||||
|
// Проходимся с конца и ставим отметки
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
if (i + m <= n && t.slice(i, i + m) === s) {
|
||||||
|
for (let j = i; j < i + m; j++) {
|
||||||
|
if (mark[j]) break;
|
||||||
|
mark[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Формируем единые жёлтые блоки ===
|
||||||
|
const result: any[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
if (!mark[i]) {
|
||||||
|
// обычный символ
|
||||||
|
result.push(name[i]);
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// начинаем жёлтый блок
|
||||||
|
let j = i;
|
||||||
|
while (j < n && mark[j]) j++;
|
||||||
|
|
||||||
|
const chunk = name.slice(i, j);
|
||||||
|
result.push(
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="bg-yellow-400 text-black rounded px-1"
|
||||||
|
>
|
||||||
|
{chunk}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -55,7 +111,9 @@ const MissionItem: React.FC<MissionItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[18px] font-bold">#{id}</div>
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
<div className="text-[18px] font-bold">{name}</div>
|
<div className="text-[18px] font-bold">
|
||||||
|
{highlightZ(name, nameFilter)}
|
||||||
|
</div>
|
||||||
<div className="text-[12px] text-right">
|
<div className="text-[12px] text-right">
|
||||||
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
|
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
|
||||||
{formatBytesToMB(memoryLimit)}
|
{formatBytesToMB(memoryLimit)}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import MissionItem from './MissionItem';
|
|||||||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import {
|
||||||
|
setMenuActivePage,
|
||||||
|
setMissionsNameFilter,
|
||||||
|
setMissionsTagFilter,
|
||||||
|
} from '../../../redux/slices/store';
|
||||||
import { fetchMissions } from '../../../redux/slices/missions';
|
import { fetchMissions } from '../../../redux/slices/missions';
|
||||||
import ModalCreate from './ModalCreate';
|
import ModalCreate from './ModalCreate';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
@@ -25,10 +29,21 @@ const Missions = () => {
|
|||||||
|
|
||||||
const missions = useAppSelector((state) => state.missions.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(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('missions'));
|
dispatch(setMenuActivePage('missions'));
|
||||||
dispatch(fetchMissions({}));
|
dispatch(fetchMissions({ tags: tagsFilter }));
|
||||||
}, []);
|
}, []);
|
||||||
|
const filterTagsHandler = (value: string[]) => {
|
||||||
|
dispatch(setMissionsTagFilter(value));
|
||||||
|
dispatch(fetchMissions({ tags: value }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||||
@@ -46,28 +61,39 @@ const Missions = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filters />
|
<Filters
|
||||||
|
onChangeTags={(value: string[]) => {
|
||||||
|
filterTagsHandler(value);
|
||||||
|
}}
|
||||||
|
onChangeName={(value: string) => {
|
||||||
|
dispatch(setMissionsNameFilter(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{missions.map((v, i) => (
|
{missions
|
||||||
<MissionItem
|
.filter((v) =>
|
||||||
key={i}
|
v.name
|
||||||
id={v.id}
|
.toLowerCase()
|
||||||
authorId={v.authorId}
|
.includes(nameFilter.toLocaleLowerCase()),
|
||||||
name={v.name}
|
)
|
||||||
difficulty={'Easy'}
|
.map((v, i) => (
|
||||||
tags={v.tags}
|
<MissionItem
|
||||||
timeLimit={1000}
|
key={i}
|
||||||
memoryLimit={256 * 1024 * 1024}
|
id={v.id}
|
||||||
createdAt={v.createdAt}
|
authorId={v.authorId}
|
||||||
updatedAt={v.updatedAt}
|
name={v.name}
|
||||||
type={i % 2 == 0 ? 'first' : 'second'}
|
difficulty={'Easy'}
|
||||||
status={'empty'}
|
tags={v.tags}
|
||||||
/>
|
timeLimit={1000}
|
||||||
))}
|
memoryLimit={256 * 1024 * 1024}
|
||||||
|
createdAt={v.createdAt}
|
||||||
|
updatedAt={v.updatedAt}
|
||||||
|
type={i % 2 == 0 ? 'first' : 'second'}
|
||||||
|
status={'empty'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>pages</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalCreate setActive={setModalActive} active={modalActive} />
|
<ModalCreate setActive={setModalActive} active={modalActive} />
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
|||||||
import {
|
import {
|
||||||
DropDownList,
|
DropDownList,
|
||||||
DropDownListItem,
|
DropDownListItem,
|
||||||
} from '../../../../components/filters/DropDownList';
|
} from '../../../../components/input/DropDownList';
|
||||||
import { ReverseButton } from '../../../../components/button/ReverseButton';
|
import { ReverseButton } from '../../../../components/button/ReverseButton';
|
||||||
|
|
||||||
interface ModalUpdateProps {
|
interface ModalUpdateProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { upload } from '../../../assets/icons/input';
|
import { upload } from '../../../assets/icons/input';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { DropDownList } from '../../../components/filters/DropDownList';
|
import { DropDownList } from '../../../components/input/DropDownList';
|
||||||
|
|
||||||
const languageMap: Record<string, string> = {
|
const languageMap: Record<string, string> = {
|
||||||
c: 'cpp',
|
c: 'cpp',
|
||||||
|
|||||||
Reference in New Issue
Block a user