diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index cf9ca08..4074135 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -273,7 +273,7 @@ export const fetchParticipatingContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10 } = params; + const { page = 0, pageSize = 100 } = params; const response = await axios.get( '/contests/participating', { params: { page, pageSize } }, @@ -315,7 +315,7 @@ export const fetchContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10, groupId } = params; + const { page = 0, pageSize = 100, groupId } = params; const response = await axios.get('/contests', { params: { page, pageSize, groupId }, }); @@ -417,7 +417,7 @@ export const fetchRegisteredContests = createAsyncThunk( { rejectWithValue }, ) => { try { - const { page = 0, pageSize = 10 } = params; + const { page = 0, pageSize = 100 } = params; const response = await axios.get( '/contests/registered', { params: { page, pageSize } }, diff --git a/src/redux/slices/groupfeed.ts b/src/redux/slices/groupfeed.ts index 05cfdbb..f322fc2 100644 --- a/src/redux/slices/groupfeed.ts +++ b/src/redux/slices/groupfeed.ts @@ -94,7 +94,7 @@ export const fetchGroupPosts = createAsyncThunk( { groupId, page = 0, - pageSize = 20, + pageSize = 100, }: { groupId: number; page?: number; pageSize?: number }, { rejectWithValue }, ) => { diff --git a/src/redux/slices/profile.ts b/src/redux/slices/profile.ts new file mode 100644 index 0000000..466cded --- /dev/null +++ b/src/redux/slices/profile.ts @@ -0,0 +1,395 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; + +// ===================== +// Типы +// ===================== + +type Status = 'idle' | 'loading' | 'successful' | 'failed'; + +// Основной профиль +export interface ProfileIdentity { + userId: number; + username: string; + email: string; + createdAt: string; +} + +export interface ProfileSolutions { + totalSolved: number; + solvedLast7Days: number; +} + +export interface ProfileContestsInfo { + totalParticipations: number; + participationsLast7Days: number; +} + +export interface ProfileCreationStats { + missions: { total: number; last7Days: number }; + contests: { total: number; last7Days: number }; + articles: { total: number; last7Days: number }; +} + +export interface ProfileResponse { + identity: ProfileIdentity; + solutions: ProfileSolutions; + contests: ProfileContestsInfo; + creation: ProfileCreationStats; +} + +// Missions +export interface MissionsBucket { + key: string; + label: string; + solved: number; + total: number; +} + +export interface MissionItem { + missionId: number; + missionName: string; + difficultyLabel: string; + difficultyValue: number; + createdAt: string; + timeLimitMilliseconds: number; + memoryLimitBytes: number; +} + +export interface MissionsResponse { + summary: { + total: MissionsBucket; + buckets: MissionsBucket[]; + }; + recent: { + items: MissionItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; + authored: { + items: MissionItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; +} + +// Articles +export interface ProfileArticleItem { + articleId: number; + title: string; + createdAt: string; + updatedAt: string; +} +export interface ProfileArticlesResponse { + articles: { + items: ProfileArticleItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; + }; +} + +// Contests +export interface ContestItem { + contestId: number; + name: string; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: 'None' | 'Participant' | 'Organizer'; +} + +export interface ContestsList { + items: ContestItem[]; + page: number; + pageSize: number; + hasNextPage: boolean; +} + +export interface ProfileContestsResponse { + upcoming: ContestsList; + past: ContestsList; + mine: ContestsList; +} + +// ===================== +// Состояние +// ===================== + +interface ProfileState { + profile: { + data?: ProfileResponse; + status: Status; + error?: string; + }; + + missions: { + data?: MissionsResponse; + status: Status; + error?: string; + }; + + articles: { + data?: ProfileArticlesResponse; + status: Status; + error?: string; + }; + + contests: { + data?: ProfileContestsResponse; + status: Status; + error?: string; + }; +} + +const initialState: ProfileState = { + profile: { + data: undefined, + status: 'idle', + error: undefined, + }, + missions: { + data: undefined, + status: 'idle', + error: undefined, + }, + articles: { + data: undefined, + status: 'idle', + error: undefined, + }, + contests: { + data: undefined, + status: 'idle', + error: undefined, + }, +}; + +// ===================== +// Async Thunks +// ===================== + +// Основной профиль +export const fetchProfile = createAsyncThunk( + 'profile/fetch', + async (username: string, { rejectWithValue }) => { + try { + const res = await axios.get( + `/profile/${username}`, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки профиля', + ); + } + }, +); + +// Missions +export const fetchProfileMissions = createAsyncThunk( + 'profile/fetchMissions', + async ( + { + username, + recentPage = 0, + recentPageSize = 15, + authoredPage = 0, + authoredPageSize = 25, + }: { + username: string; + recentPage?: number; + recentPageSize?: number; + authoredPage?: number; + authoredPageSize?: number; + }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/missions`, + { + params: { + recentPage, + recentPageSize, + authoredPage, + authoredPageSize, + }, + }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки задач', + ); + } + }, +); + +// Articles +export const fetchProfileArticles = createAsyncThunk( + 'profile/fetchArticles', + async ( + { + username, + page = 0, + pageSize = 25, + }: { username: string; page?: number; pageSize?: number }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/articles`, + { params: { page, pageSize } }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки статей', + ); + } + }, +); + +// Contests +export const fetchProfileContests = createAsyncThunk( + 'profile/fetchContests', + async ( + { + username, + upcomingPage = 0, + upcomingPageSize = 10, + pastPage = 0, + pastPageSize = 10, + minePage = 0, + minePageSize = 10, + }: { + username: string; + upcomingPage?: number; + upcomingPageSize?: number; + pastPage?: number; + pastPageSize?: number; + minePage?: number; + minePageSize?: number; + }, + { rejectWithValue }, + ) => { + try { + const res = await axios.get( + `/profile/${username}/contests`, + { + params: { + upcomingPage, + upcomingPageSize, + pastPage, + pastPageSize, + minePage, + minePageSize, + }, + }, + ); + return res.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки контестов', + ); + } + }, +); + +// ===================== +// Slice +// ===================== + +const profileSlice = createSlice({ + name: 'profile', + initialState, + reducers: { + setProfileStatus: ( + state, + action: PayloadAction<{ + key: keyof ProfileState; + status: Status; + }>, + ) => { + state[action.payload.key].status = action.payload.status; + }, + }, + + extraReducers: (builder) => { + // PROFILE + builder.addCase(fetchProfile.pending, (state) => { + state.profile.status = 'loading'; + state.profile.error = undefined; + }); + builder.addCase( + fetchProfile.fulfilled, + (state, action: PayloadAction) => { + state.profile.status = 'successful'; + state.profile.data = action.payload; + }, + ); + builder.addCase(fetchProfile.rejected, (state, action: any) => { + state.profile.status = 'failed'; + state.profile.error = action.payload; + }); + + // MISSIONS + builder.addCase(fetchProfileMissions.pending, (state) => { + state.missions.status = 'loading'; + state.missions.error = undefined; + }); + builder.addCase( + fetchProfileMissions.fulfilled, + (state, action: PayloadAction) => { + state.missions.status = 'successful'; + state.missions.data = action.payload; + }, + ); + builder.addCase(fetchProfileMissions.rejected, (state, action: any) => { + state.missions.status = 'failed'; + state.missions.error = action.payload; + }); + + // ARTICLES + builder.addCase(fetchProfileArticles.pending, (state) => { + state.articles.status = 'loading'; + state.articles.error = undefined; + }); + builder.addCase( + fetchProfileArticles.fulfilled, + (state, action: PayloadAction) => { + state.articles.status = 'successful'; + state.articles.data = action.payload; + }, + ); + builder.addCase(fetchProfileArticles.rejected, (state, action: any) => { + state.articles.status = 'failed'; + state.articles.error = action.payload; + }); + + // CONTESTS + builder.addCase(fetchProfileContests.pending, (state) => { + state.contests.status = 'loading'; + state.contests.error = undefined; + }); + builder.addCase( + fetchProfileContests.fulfilled, + (state, action: PayloadAction) => { + state.contests.status = 'successful'; + state.contests.data = action.payload; + }, + ); + builder.addCase(fetchProfileContests.rejected, (state, action: any) => { + state.contests.status = 'failed'; + state.contests.error = action.payload; + }); + }, +}); + +export const { setProfileStatus } = profileSlice.actions; +export const profileReducer = profileSlice.reducer; diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index cc713a0..ba2c604 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -7,6 +7,9 @@ interface StorState { activeProfilePage: string; activeGroupPage: string; }; + group: { + groupFilter: string; + }; } // Инициализация состояния @@ -16,6 +19,9 @@ const initialState: StorState = { activeProfilePage: '', activeGroupPage: '', }, + group: { + groupFilter: '', + }, }; // Slice @@ -38,6 +44,9 @@ const storeSlice = createSlice({ ) => { state.menu.activeGroupPage = activeGroupPage.payload; }, + setGroupFilter: (state, groupFilter: PayloadAction) => { + state.group.groupFilter = groupFilter.payload; + }, }, }); @@ -45,6 +54,7 @@ export const { setMenuActivePage, setMenuActiveProfilePage, setMenuActiveGroupPage, + setGroupFilter, } = storeSlice.actions; export const storeReducer = storeSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 4dfa071..79f8154 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -8,6 +8,7 @@ import { groupsReducer } from './slices/groups'; import { articlesReducer } from './slices/articles'; import { groupFeedReducer } from './slices/groupfeed'; import { groupChatReducer } from './slices/groupChat'; +import { profileReducer } from './slices/profile'; // использование // import { useAppDispatch, useAppSelector } from '../redux/hooks'; @@ -29,6 +30,7 @@ export const store = configureStore({ articles: articlesReducer, groupfeed: groupFeedReducer, groupchat: groupChatReducer, + profile: profileReducer, }, }); diff --git a/src/views/home/account/Account.tsx b/src/views/home/account/Account.tsx index 527dc5c..3c932c8 100644 --- a/src/views/home/account/Account.tsx +++ b/src/views/home/account/Account.tsx @@ -4,19 +4,47 @@ import RightPanel from './RightPanel'; import Missions from './missions/Missions'; import Contests from './contests/Contests'; import ArticlesBlock from './articles/ArticlesBlock'; -import { useAppDispatch } from '../../../redux/hooks'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { useEffect } from 'react'; import { setMenuActivePage } from '../../../redux/slices/store'; +import { useQuery } from '../../../hooks/useQuery'; +import { + fetchProfile, + fetchProfileArticles, + fetchProfileContests, + fetchProfileMissions, +} from '../../../redux/slices/profile'; const Account = () => { const dispatch = useAppDispatch(); + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; useEffect(() => { - dispatch(setMenuActivePage('account')); - }, []); + if (username == myname) dispatch(setMenuActivePage('account')); + dispatch( + fetchProfileMissions({ + username: username, + recentPageSize: 1, + authoredPageSize: 100, + }), + ); + dispatch(fetchProfileArticles({ username: username, pageSize: 100 })); + dispatch( + fetchProfileContests({ + username: username, + pastPageSize: 100, + minePageSize: 100, + upcomingPageSize: 100, + }), + ); + dispatch(fetchProfile(username)); + }, [username]); return ( -
+
diff --git a/src/views/home/account/RightPanel.tsx b/src/views/home/account/RightPanel.tsx index b1b63d8..952bc23 100644 --- a/src/views/home/account/RightPanel.tsx +++ b/src/views/home/account/RightPanel.tsx @@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { logout } from '../../../redux/slices/auth'; import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account'; import { FC } from 'react'; +import { useQuery } from '../../../hooks/useQuery'; interface StatisticItemProps { icon: string; @@ -34,32 +35,55 @@ const StatisticItem: FC = ({ ); }; +export const formatDate = (isoDate?: string): string => { + if (!isoDate) return ''; + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${day}.${month}.${year}`; +}; + const RightPanel = () => { const dispatch = useAppDispatch(); - const name = useAppSelector((state) => state.auth.username); - const email = useAppSelector((state) => state.auth.email); + + const { data: profileData } = useAppSelector( + (state) => state.profile.profile, + ); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + return (
- {name} + {profileData?.identity.username}
- {email} -
-
- Топ 50% + {profileData?.identity.email}
- {}} - text="Редактировать" - className="w-full" - /> +
+ {`Зарегистрирован ${formatDate( + profileData?.identity.createdAt, + )}`} +
+ + {username == myname && ( + {}} + text="Редактировать" + className="w-full" + /> + )}
@@ -70,14 +94,14 @@ const RightPanel = () => {
@@ -87,30 +111,32 @@ const RightPanel = () => { - { - dispatch(logout()); - }} - text="Выход" - color="error" - /> + {username == myname && ( + { + dispatch(logout()); + }} + text="Выход" + color="error" + /> + )}
); }; diff --git a/src/views/home/account/articles/ArticlesBlock.tsx b/src/views/home/account/articles/ArticlesBlock.tsx index eb91f9b..6325347 100644 --- a/src/views/home/account/articles/ArticlesBlock.tsx +++ b/src/views/home/account/articles/ArticlesBlock.tsx @@ -3,16 +3,25 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; import { cn } from '../../../../lib/cn'; import { ChevroneDown, Edit } from '../../../../assets/icons/groups'; -import { fetchMyArticles } from '../../../../redux/slices/articles'; import { useNavigate } from 'react-router-dom'; export interface ArticleItemProps { id: number; name: string; - tags: string[]; + createdAt: string; } -const ArticleItem: FC = ({ id, name, tags }) => { +export const formatDate = (isoDate?: string): string => { + if (!isoDate) return ''; + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${day}.${month}.${year}`; +}; + +const ArticleItem: FC = ({ id, name, createdAt }) => { const navigate = useNavigate(); return ( @@ -35,18 +44,8 @@ const ArticleItem: FC = ({ id, name, tags }) => {
-
- {tags.map((v, i) => ( -
- {v} -
- ))} +
+ {`Опубликована ${formatDate(createdAt)}`}
= ({ className = '' }) => { const dispatch = useAppDispatch(); const [active, setActive] = useState(true); - // ✅ Берём только "мои статьи" - const articles = useAppSelector( - (state) => state.articles.fetchMyArticles.articles, - ); - const status = useAppSelector( - (state) => state.articles.fetchMyArticles.status, - ); - const error = useAppSelector( - (state) => state.articles.fetchMyArticles.error, + const { data: articleData } = useAppSelector( + (state) => state.profile.articles, ); useEffect(() => { dispatch(setMenuActiveProfilePage('articles')); - dispatch(fetchMyArticles()); }, [dispatch]); return ( @@ -130,19 +121,21 @@ const ArticlesBlock: FC = ({ className = '' }) => {
)} {status === 'failed' && ( -
- Ошибка:{' '} - {error || 'Не удалось загрузить статьи'} -
+
Ошибка:
)} {status === 'successful' && - articles.length === 0 && ( + articleData?.articles.items.length === 0 && (
У вас пока нет статей
)} - {articles.map((v) => ( - + {articleData?.articles.items.map((v, i) => ( + ))}
diff --git a/src/views/home/account/contests/Contests.tsx b/src/views/home/account/contests/Contests.tsx index 0c62c1a..eb38061 100644 --- a/src/views/home/account/contests/Contests.tsx +++ b/src/views/home/account/contests/Contests.tsx @@ -1,25 +1,18 @@ import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; -import { - fetchMyContests, - fetchRegisteredContests, -} from '../../../../redux/slices/contests'; import ContestsBlock from './ContestsBlock'; const Contests = () => { const dispatch = useAppDispatch(); - // Redux-состояния - const myContestsState = useAppSelector( - (state) => state.contests.fetchMyContests, + const { data: constestData } = useAppSelector( + (state) => state.profile.contests, ); // При загрузке страницы — выставляем вкладку и подгружаем контесты useEffect(() => { dispatch(setMenuActiveProfilePage('contests')); - dispatch(fetchMyContests()); - dispatch(fetchRegisteredContests({})); }, []); return ( @@ -29,30 +22,38 @@ const Contests = () => { v.role != 'Organizer') + .filter((v) => v.scheduleType != 'AlwaysOpen')} + /> +
+ +
+ v.role != 'Organizer', + ) ?? []), + ...(constestData?.upcoming.items + .filter((v) => v.role != 'Organizer') + .filter((v) => v.scheduleType == 'AlwaysOpen') ?? + []), + ]} />
{/* Контесты, которые я создал */}
- {myContestsState.status === 'loading' ? ( -
- Загрузка ваших контестов... -
- ) : myContestsState.error ? ( -
- Ошибка: {myContestsState.error} -
- ) : ( - - )} +
); diff --git a/src/views/home/account/contests/ContestsBlock.tsx b/src/views/home/account/contests/ContestsBlock.tsx index f732ffe..1862461 100644 --- a/src/views/home/account/contests/ContestsBlock.tsx +++ b/src/views/home/account/contests/ContestsBlock.tsx @@ -1,22 +1,23 @@ import { useState, FC } from 'react'; import { cn } from '../../../../lib/cn'; import { ChevroneDown } from '../../../../assets/icons/groups'; -import MyContestItem from './MyContestItem'; -import RegisterContestItem from './RegisterContestItem'; -import { Contest } from '../../../../redux/slices/contests'; +import { ContestItem } from '../../../../redux/slices/profile'; +import PastContestItem from './PastContestItem'; +import UpcoingContestItem from './UpcomingContestItem'; +import EditContestItem from './EditContestItem'; interface ContestsBlockProps { - contests: Contest[]; + contests?: ContestItem[]; title: string; className?: string; - type?: 'my' | 'reg'; + type?: 'edit' | 'upcoming' | 'past'; } const ContestsBlock: FC = ({ contests, title, className, - type = 'my', + type = 'edit', }) => { const [active, setActive] = useState(title != 'Скрытые'); @@ -36,11 +37,11 @@ const ContestsBlock: FC = ({ setActive(!active); }} > - {title} + {title} @@ -53,35 +54,38 @@ const ContestsBlock: FC = ({ >
- {contests.map((v, i) => { - return type == 'my' ? ( - - ) : ( - - ); + {contests?.map((v, i) => { + if (type == 'past') { + return ( + + ); + } + + if (type == 'upcoming') { + return ( + + ); + } + + if (type == 'edit') { + return ( + + ); + } + + return <>; })}
diff --git a/src/views/home/account/contests/EditContestItem.tsx b/src/views/home/account/contests/EditContestItem.tsx new file mode 100644 index 0000000..1f337e4 --- /dev/null +++ b/src/views/home/account/contests/EditContestItem.tsx @@ -0,0 +1,146 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; +import { Edit } from '../../../../assets/icons/input'; + +export interface EditContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +const EditContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const started = new Date(startsAt) <= new Date(); + + return ( +
{ + if (!started) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ +
+ {new Date() < new Date(startsAt) ? ( + <>{'Не начался'} + ) : ( + <> + {scheduleType == 'AlwaysOpen' + ? 'Открыт' + : new Date() < new Date(endsAt) + ? 'Идет' + : 'Завершен'} + + )} +
+ {username == myname && ( + { + e.stopPropagation(); + navigate( + `/contest/create?back=/home/account/contests&contestId=${contestId}`, + ); + }} + /> + )} +
+ ); +}; + +export default EditContestItem; diff --git a/src/views/home/account/contests/MyContestItem.tsx b/src/views/home/account/contests/MyContestItem.tsx deleted file mode 100644 index eef1bf4..0000000 --- a/src/views/home/account/contests/MyContestItem.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { cn } from '../../../../lib/cn'; -import { Account } from '../../../../assets/icons/auth'; -import { useNavigate } from 'react-router-dom'; -import { Edit } from '../../../../assets/icons/input'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - type: 'first' | 'second'; -} - -function formatDate(dateString: string): string { - const date = new Date(dateString); - - const day = date.getDate().toString().padStart(2, '0'); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const year = date.getFullYear(); - - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - - return `${day}/${month}/${year}\n${hours}:${minutes}`; -} - -function formatWaitTime(ms: number): string { - const minutes = Math.floor(ms / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - const remainder = days % 10; - let suffix = 'дней'; - if (remainder === 1 && days !== 11) suffix = 'день'; - else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) - suffix = 'дня'; - return `${days} ${suffix}`; - } else if (hours > 0) { - const mins = minutes % 60; - return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; - } else { - return `${minutes} мин`; - } -} - -const ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - type, -}) => { - const navigate = useNavigate(); - - return ( -
{ - navigate(`/contest/${id}`); - }} - > -
{name}
-
- {/* {authors.map((v, i) =>

{v}

)} */} - valavshonok -
-
- {formatDate(startAt)} -
-
{formatWaitTime(duration)}
-
-
{members}
- -
- - { - e.stopPropagation(); - navigate( - `/contest/create?back=/home/account/contests&contestId=${id}`, - ); - }} - /> -
- ); -}; - -export default ContestItem; diff --git a/src/views/home/account/contests/PastContestItem.tsx b/src/views/home/account/contests/PastContestItem.tsx new file mode 100644 index 0000000..a05d5dc --- /dev/null +++ b/src/views/home/account/contests/PastContestItem.tsx @@ -0,0 +1,112 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; + +export interface PastContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +const PastContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + return ( +
{ + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ +
+ {scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'} +
+
+ ); +}; + +export default PastContestItem; diff --git a/src/views/home/account/contests/RegisterContestItem.tsx b/src/views/home/account/contests/RegisterContestItem.tsx deleted file mode 100644 index f8cbf6c..0000000 --- a/src/views/home/account/contests/RegisterContestItem.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { cn } from '../../../../lib/cn'; -import { Account } from '../../../../assets/icons/auth'; -import { PrimaryButton } from '../../../../components/button/PrimaryButton'; -import { ReverseButton } from '../../../../components/button/ReverseButton'; -import { useNavigate } from 'react-router-dom'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - statusRegister: 'reg' | 'nonreg'; - type: 'first' | 'second'; -} - -function formatDate(dateString: string): string { - const date = new Date(dateString); - - const day = date.getDate().toString().padStart(2, '0'); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const year = date.getFullYear(); - - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - - return `${day}/${month}/${year}\n${hours}:${minutes}`; -} - -function formatWaitTime(ms: number): string { - const minutes = Math.floor(ms / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - const remainder = days % 10; - let suffix = 'дней'; - if (remainder === 1 && days !== 11) suffix = 'день'; - else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) - suffix = 'дня'; - return `${days} ${suffix}`; - } else if (hours > 0) { - const mins = minutes % 60; - return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; - } else { - return `${minutes} мин`; - } -} - -const ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - statusRegister, - type, -}) => { - const navigate = useNavigate(); - - const now = new Date(); - - const waitTime = new Date(startAt).getTime() - now.getTime(); - - return ( -
{ - navigate(`/contest/${id}`); - }} - > -
{name}
-
- {/* {authors.map((v, i) =>

{v}

)} */} - valavshonok -
-
- {formatDate(startAt)} -
-
{formatWaitTime(duration)}
- {waitTime > 0 && ( -
- {'До начала\n' + formatWaitTime(waitTime)} -
- )} -
-
{members}
- -
-
- {statusRegister == 'reg' ? ( - <> - {' '} - {}} text="Регистрация" /> - - ) : ( - <> - {' '} - {}} text="Вы записаны" /> - - )} -
-
- ); -}; - -export default ContestItem; diff --git a/src/views/home/account/contests/UpcomingContestItem.tsx b/src/views/home/account/contests/UpcomingContestItem.tsx new file mode 100644 index 0000000..0bb8adf --- /dev/null +++ b/src/views/home/account/contests/UpcomingContestItem.tsx @@ -0,0 +1,160 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; + +export interface UpcoingContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + visibility: string; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + role: string; + type: 'first' | 'second'; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +function formatWaitTime(ms: number): string { + const minutes = Math.floor(ms / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +const UpcoingContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + + const myname = useAppSelector((state) => state.auth.username); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const started = new Date(startsAt) <= new Date(); + const finished = new Date(endsAt) <= new Date(); + const waitTime = !started + ? new Date(startsAt).getTime() - new Date().getTime() + : new Date(endsAt).getTime() - new Date().getTime(); + + return ( +
{ + if (!started) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/account/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ + {!started ? ( +
+ {'До начала\n' + formatWaitTime(waitTime)} +
+ ) : ( + !finished && ( +
+ {'До конца\n' + formatWaitTime(waitTime)} +
+ ) + )} + +
+ {new Date() < new Date(startsAt) ? ( + <>{'Не начался'} + ) : ( + <>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'} + )} +
+
+ ); +}; + +export default UpcoingContestItem; diff --git a/src/views/home/account/missions/Missions.tsx b/src/views/home/account/missions/Missions.tsx index 5dc74b4..d052e81 100644 --- a/src/views/home/account/missions/Missions.tsx +++ b/src/views/home/account/missions/Missions.tsx @@ -5,7 +5,6 @@ import { cn } from '../../../../lib/cn'; import MissionsBlock from './MissionsBlock'; import { deleteMission, - fetchMyMissions, setMissionsStatus, } from '../../../../redux/slices/missions'; import ConfirmModal from '../../../../components/modal/ConfirmModal'; @@ -43,14 +42,16 @@ const Item: FC = ({ const Missions = () => { const dispatch = useAppDispatch(); - const missions = useAppSelector((state) => state.missions.missions); - const status = useAppSelector((state) => state.missions.statuses.fetchMy); + const [modalDeleteTask, setModalDeleteTask] = useState(false); const [taskdeleteId, setTaskDeleteId] = useState(0); + const { data: missionData } = useAppSelector( + (state) => state.profile.missions, + ); + useEffect(() => { dispatch(setMenuActiveProfilePage('missions')); - dispatch(fetchMyMissions()); }, []); useEffect(() => { @@ -66,42 +67,39 @@ const Missions = () => {
- +
- - - + {missionData?.summary?.buckets?.map((bucket) => ( + + ))}
-
- Компетенции -
- -
- - - -
void; @@ -58,11 +58,11 @@ const MissionsBlock: FC = ({ {missions.map((v, i) => ( = ({ contest }) => { const navigate = useNavigate(); const dispatch = useAppDispatch(); + + const query = useQuery(); + const url = query.get('back') ?? '/home/contests'; + const { status } = useAppSelector( (state) => state.contests.fetchMySubmissions, ); @@ -113,7 +118,7 @@ const ContestMissions: FC = ({ contest }) => { src={arrowLeft} className="cursor-pointer" onClick={() => { - navigate(`/home/contests`); + navigate(url); }} /> diff --git a/src/views/home/contests/ContestItem.tsx b/src/views/home/contests/ContestItem.tsx deleted file mode 100644 index d4e1a8c..0000000 --- a/src/views/home/contests/ContestItem.tsx +++ /dev/null @@ -1,165 +0,0 @@ -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 { toastWarning } from '../../../lib/toastNotification'; -import { useEffect, useState } from 'react'; -import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; -import { addOrUpdateContestMember } from '../../../redux/slices/contests'; - -export type Role = 'None' | 'Participant' | 'Organizer'; - -export interface ContestItemProps { - id: number; - name: string; - startAt: string; - duration: number; - members: number; - type: 'first' | 'second'; -} - -function formatDate(dateString: string): string { - const date = new Date(dateString); - - const day = date.getDate().toString().padStart(2, '0'); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const year = date.getFullYear(); - - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - - return `${day}/${month}/${year}\n${hours}:${minutes}`; -} - -function formatWaitTime(ms: number): string { - const minutes = Math.floor(ms / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - const remainder = days % 10; - let suffix = 'дней'; - if (remainder === 1 && days !== 11) suffix = 'день'; - else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) - suffix = 'дня'; - return `${days} ${suffix}`; - } else if (hours > 0) { - const mins = minutes % 60; - return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; - } else { - return `${minutes} мин`; - } -} - -const ContestItem: React.FC = ({ - id, - name, - startAt, - duration, - members, - type, -}) => { - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - - const now = new Date(); - - const waitTime = new Date(startAt).getTime() - now.getTime(); - - const [myRole, setMyRole] = useState('None'); - - const userId = useAppSelector((state) => state.auth.id); - const { contests: contestsRegistered } = useAppSelector( - (state) => state.contests.fetchParticipating, - ); - const { contests: contestsMy } = useAppSelector( - (state) => state.contests.fetchMyContests, - ); - - useEffect(() => { - if (!contestsRegistered || contestsRegistered.length === 0) { - setMyRole('None'); - return; - } - - const reg = contestsRegistered.find((c) => c.id === id); - const my = contestsMy.find((c) => c.id === id); - - if (my) setMyRole('Organizer'); - else if (reg) setMyRole('Participant'); - else setMyRole('None'); - }, [contestsRegistered]); - - return ( -
{ - if (myRole == 'None') { - toastWarning('Зарегистрируйтесь на контест'); - return; - } - navigate(`/contest/${id}`); - }} - > -
{name}
-
- {/* {authors.map((v, i) =>

{v}

)} */} - valavshonok -
-
- {formatDate(startAt)} -
-
{formatWaitTime(duration)}
- {waitTime > 0 && ( -
- {'До начала\n' + formatWaitTime(waitTime)} -
- )} -
-
{members}
- -
-
- {myRole == 'None' ? ( - <> - {' '} - { - dispatch( - addOrUpdateContestMember({ - contestId: id, - member: { - userId: Number(userId), - role: 'Participant', - }, - }), - ); - }} - text="Регистрация" - /> - - ) : ( - <> - {' '} - { - navigate(`/contest/${id}`); - }} - text="Войти" - /> - - )} -
-
- ); -}; - -export default ContestItem; diff --git a/src/views/home/contests/Contests.tsx b/src/views/home/contests/Contests.tsx index ab54299..0bf1055 100644 --- a/src/views/home/contests/Contests.tsx +++ b/src/views/home/contests/Contests.tsx @@ -4,31 +4,28 @@ import { cn } from '../../../lib/cn'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import ContestsBlock from './ContestsBlock'; import { setMenuActivePage } from '../../../redux/slices/store'; -import { fetchContests, fetchMyContests, fetchParticipatingContests } from '../../../redux/slices/contests'; +import { + fetchContests, + fetchMyContests, + fetchParticipatingContests, +} from '../../../redux/slices/contests'; import ModalCreateContest from './ModalCreate'; import Filters from './Filter'; const Contests = () => { const dispatch = useAppDispatch(); - const now = new Date(); const [modalActive, setModalActive] = useState(false); - // Берём данные из Redux - const contests = useAppSelector( - (state) => state.contests.fetchContests.contests, + const { contests, status } = useAppSelector( + (state) => state.contests.fetchContests, ); - const status = useAppSelector( - (state) => state.contests.fetchContests.status, - ); - const error = useAppSelector((state) => state.contests.fetchContests.error); - // При загрузке страницы — выставляем активную вкладку и подгружаем контесты useEffect(() => { dispatch(setMenuActivePage('contests')); dispatch(fetchContests({})); - dispatch(fetchParticipatingContests({pageSize:100})); + dispatch(fetchParticipatingContests({ pageSize: 100 })); dispatch(fetchMyContests()); }, []); @@ -58,31 +55,24 @@ const Contests = () => { Загрузка контестов...
)} - {status == 'failed' && ( -
Ошибка: {error}
- )} {status == 'successful' && ( <> { - const endTime = new Date( - contest.endsAt ?? new Date().toDateString(), - ).getTime(); - return endTime >= now.getTime(); - })} + contests={contests.filter( + (c) => c.scheduleType != 'AlwaysOpen', + )} + type="upcoming" /> { - const endTime = new Date( - contest.endsAt ?? new Date().toDateString(), - ).getTime(); - return endTime < now.getTime(); - })} + title="Постоянные" + contests={contests.filter( + (c) => c.scheduleType == 'AlwaysOpen', + )} + type="past" /> )} diff --git a/src/views/home/contests/ContestsBlock.tsx b/src/views/home/contests/ContestsBlock.tsx index 7b642b5..8cb0a43 100644 --- a/src/views/home/contests/ContestsBlock.tsx +++ b/src/views/home/contests/ContestsBlock.tsx @@ -1,19 +1,22 @@ import { useState, FC } from 'react'; import { cn } from '../../../lib/cn'; import { ChevroneDown } from '../../../assets/icons/groups'; -import ContestItem from './ContestItem'; import { Contest } from '../../../redux/slices/contests'; +import PastContestItem from './PastContestItem'; +import UpcoingContestItem from './UpcomingContestItem'; interface ContestsBlockProps { contests: Contest[]; title: string; className?: string; + type: 'upcoming' | 'past'; } const ContestsBlock: FC = ({ contests, title, className, + type, }) => { const [active, setActive] = useState(title != 'Скрытые'); @@ -33,11 +36,11 @@ const ContestsBlock: FC = ({ setActive(!active); }} > - {title} + {title} @@ -50,24 +53,51 @@ const ContestsBlock: FC = ({ >
- {contests.map((v, i) => ( - - ))} + {contests.map((v, i) => { + if (type == 'past') { + return ( + + ); + } + + if (type == 'upcoming') { + return ( + + ); + } + + return <>; + })}
diff --git a/src/views/home/contests/PastContestItem.tsx b/src/views/home/contests/PastContestItem.tsx new file mode 100644 index 0000000..10ea4e3 --- /dev/null +++ b/src/views/home/contests/PastContestItem.tsx @@ -0,0 +1,189 @@ +import { cn } from '../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { useQuery } from '../../../hooks/useQuery'; +import { ReverseButton } from '../../../components/button/ReverseButton'; +import { PrimaryButton } from '../../../components/button/PrimaryButton'; +import { toastWarning } from '../../../lib/toastNotification'; +import { useEffect, useState } from 'react'; +import { + addOrUpdateContestMember, + fetchParticipatingContests, +} from '../../../redux/slices/contests'; + +export interface PastContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + type: 'first' | 'second'; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +type Role = 'None' | 'Participant' | 'Organizer'; + +const PastContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [role, setRole] = useState('None'); + + const myname = useAppSelector((state) => state.auth.username); + + const userId = useAppSelector((state) => state.auth.id); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const { contests: myContests } = useAppSelector( + (state) => state.contests.fetchMyContests, + ); + const { contests: participantContests } = useAppSelector( + (state) => state.contests.fetchParticipating, + ); + + useEffect(() => { + setRole( + (() => { + if (myContests?.some((c) => c.id === contestId)) { + return 'Organizer'; + } + if (participantContests?.some((c) => c.id === contestId)) { + return 'Participant'; + } + return 'None'; + })(), + ); + }, [myContests, participantContests]); + + return ( +
{ + if (role == 'None') { + toastWarning('Нужно зарегистрироваться на контест'); + return; + } + const params = new URLSearchParams({ + back: '/home/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ +
+ {scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'} +
+ {userId && ( +
+ {role == 'Organizer' || role == 'Participant' ? ( + { + const params = new URLSearchParams({ + back: '/home/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + text="Войти" + /> + ) : ( + { + dispatch( + addOrUpdateContestMember({ + contestId: contestId, + member: { + userId: Number(userId), + role: 'Participant', + }, + }), + ) + .unwrap() + .then(() => + dispatch( + fetchParticipatingContests({}), + ), + ); + }} + text="Регистрация" + /> + )} +
+ )} +
+ ); +}; + +export default PastContestItem; diff --git a/src/views/home/contests/UpcomingContestItem.tsx b/src/views/home/contests/UpcomingContestItem.tsx new file mode 100644 index 0000000..f06e297 --- /dev/null +++ b/src/views/home/contests/UpcomingContestItem.tsx @@ -0,0 +1,233 @@ +import { cn } from '../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { useQuery } from '../../../hooks/useQuery'; +import { toastWarning } from '../../../lib/toastNotification'; +import { useEffect, useState } from 'react'; +import { ReverseButton } from '../../../components/button/ReverseButton'; +import { PrimaryButton } from '../../../components/button/PrimaryButton'; +import { + addOrUpdateContestMember, + fetchParticipatingContests, +} from '../../../redux/slices/contests'; + +type Role = 'None' | 'Participant' | 'Organizer'; + +export interface UpcoingContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + type: 'first' | 'second'; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +function formatWaitTime(ms: number): string { + const minutes = Math.floor(ms / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +const UpcoingContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, +}) => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [role, setRole] = useState('None'); + + const myname = useAppSelector((state) => state.auth.username); + + const { contests: myContests } = useAppSelector( + (state) => state.contests.fetchMyContests, + ); + const { contests: participantContests } = useAppSelector( + (state) => state.contests.fetchParticipating, + ); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const userId = useAppSelector((state) => state.auth.id); + + const started = new Date(startsAt) <= new Date(); + const finished = new Date(endsAt) <= new Date(); + const waitTime = !started + ? new Date(startsAt).getTime() - new Date().getTime() + : new Date(endsAt).getTime() - new Date().getTime(); + + useEffect(() => { + setRole( + (() => { + if (myContests?.some((c) => c.id === contestId)) { + return 'Organizer'; + } + if (participantContests?.some((c) => c.id === contestId)) { + return 'Participant'; + } + return 'None'; + })(), + ); + }, [myContests, participantContests]); + + return ( +
{ + if (!started) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: '/home/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ + {!started ? ( +
+ {'До начала\n' + formatWaitTime(waitTime)} +
+ ) : ( + !finished && ( +
+ {'До конца\n' + formatWaitTime(waitTime)} +
+ ) + )} + +
+ {new Date() < new Date(startsAt) ? ( + <>{'Не начался'} + ) : ( + <>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'} + )} +
+ + {userId && ( +
+ {role == 'Organizer' || role == 'Participant' ? ( + { + const params = new URLSearchParams({ + back: '/home/contests', + }); + navigate(`/contest/${contestId}?${params}`); + }} + text="Войти" + /> + ) : ( + { + dispatch( + addOrUpdateContestMember({ + contestId: contestId, + member: { + userId: Number(userId), + role: 'Participant', + }, + }), + ) + .unwrap() + .then(() => + dispatch( + fetchParticipatingContests({}), + ), + ); + }} + text="Регистрация" + /> + )} +
+ )} +
+ ); +}; + +export default UpcoingContestItem; diff --git a/src/views/home/group/Group.tsx b/src/views/home/group/Group.tsx index a982ba2..426de31 100644 --- a/src/views/home/group/Group.tsx +++ b/src/views/home/group/Group.tsx @@ -36,7 +36,10 @@ const Group: FC = () => { } /> } /> - } /> + } + /> } diff --git a/src/views/home/group/chat/Chat.tsx b/src/views/home/group/chat/Chat.tsx index e3a55b5..2c3b04f 100644 --- a/src/views/home/group/chat/Chat.tsx +++ b/src/views/home/group/chat/Chat.tsx @@ -6,7 +6,6 @@ import { sendGroupMessage, setGroupChatStatus, } from '../../../../redux/slices/groupChat'; -import { SearchInput } from '../../../../components/input/SearchInput'; import { MessageItem } from './MessageItem'; import { Send } from '../../../../assets/icons/input'; @@ -169,15 +168,7 @@ export const Chat: FC = ({ groupId }) => { return (
-
-
- {}} - placeholder="Поиск сообщений" - /> -
- +
{ +interface ContestsProps { + groupId: number; +} + +export const Contests: FC = ({ groupId }) => { const dispatch = useAppDispatch(); + const { contests, status } = useAppSelector( + (state) => state.contests.fetchContests, + ); + useEffect(() => { dispatch(setMenuActiveGroupPage('contests')); + dispatch(fetchContests({ groupId })); + dispatch(fetchParticipatingContests({ pageSize: 100 })); + dispatch(fetchMyContests()); }, []); + return ( -
- {' '} - Пока пусто :( +
+
+
+ {status == 'loading' && ( +
+ Загрузка контестов... +
+ )} + {status == 'successful' && ( +
+ c.scheduleType != 'AlwaysOpen', + ) + .filter((c) => + c.endsAt + ? new Date() < new Date(c.endsAt) + : false, + )} + type="upcoming" + /> + + + c.scheduleType == 'AlwaysOpen' || + !(c.endsAt + ? new Date() < new Date(c.endsAt) + : false), + )} + type="past" + /> +
+ )} +
+
); }; diff --git a/src/views/home/group/contests/ContestsBlock.tsx b/src/views/home/group/contests/ContestsBlock.tsx new file mode 100644 index 0000000..a3f4f4a --- /dev/null +++ b/src/views/home/group/contests/ContestsBlock.tsx @@ -0,0 +1,112 @@ +import { useState, FC } from 'react'; +import { cn } from '../../../../lib/cn'; +import { ChevroneDown } from '../../../../assets/icons/groups'; +import { Contest } from '../../../../redux/slices/contests'; +import PastContestItem from './PastContestItem'; +import UpcoingContestItem from './UpcomingContestItem'; + +interface ContestsBlockProps { + contests: Contest[]; + title: string; + className?: string; + type: 'upcoming' | 'past'; + groupId: number; +} + +const ContestsBlock: FC = ({ + contests, + title, + className, + type, + groupId, +}) => { + const [active, setActive] = useState(title != 'Скрытые'); + + return ( +
+
{ + setActive(!active); + }} + > + {title} + +
+
+
+
+ {contests.map((v, i) => { + if (type == 'past') { + return ( + + ); + } + + if (type == 'upcoming') { + return ( + + ); + } + + return <>; + })} +
+
+
+
+ ); +}; + +export default ContestsBlock; diff --git a/src/views/home/group/contests/ModalCreate.tsx b/src/views/home/group/contests/ModalCreate.tsx new file mode 100644 index 0000000..dcf8307 --- /dev/null +++ b/src/views/home/group/contests/ModalCreate.tsx @@ -0,0 +1,222 @@ +import { FC, useEffect, useState } from 'react'; +import { Modal } from '../../../../components/modal/Modal'; +import { PrimaryButton } from '../../../../components/button/PrimaryButton'; +import { SecondaryButton } from '../../../../components/button/SecondaryButton'; +import { Input } from '../../../../components/input/Input'; +import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; +import { + createContest, + setContestStatus, +} from '../../../../redux/slices/contests'; +import { CreateContestBody } from '../../../../redux/slices/contests'; +import DateRangeInput from '../../../../components/input/DateRangeInput'; +import { useNavigate } from 'react-router-dom'; + +function toUtc(localDateTime?: string): string { + if (!localDateTime) return ''; + + // Создаём дату (она автоматически считается как локальная) + const date = new Date(localDateTime); + + // Возвращаем ISO-строку с 'Z' (всегда в UTC) + return date.toISOString(); +} + +interface ModalCreateContestProps { + active: boolean; + setActive: (value: boolean) => void; +} + +const ModalCreateContest: FC = ({ + active, + setActive, +}) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const status = useAppSelector( + (state) => state.contests.createContest.status, + ); + + const [form, setForm] = useState({ + name: '', + description: '', + scheduleType: 'AlwaysOpen', + visibility: 'Public', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + missionIds: [], + articleIds: [], + }); + + const contest = useAppSelector( + (state) => state.contests.createContest.contest, + ); + + useEffect(() => { + if (status === 'successful') { + dispatch( + setContestStatus({ key: 'createContest', status: 'idle' }), + ); + navigate( + `/contest/create?back=/home/account/contests&contestId=${contest.id}`, + ); + } + }, [status]); + + const handleChange = (key: keyof CreateContestBody, value: any) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleSubmit = () => { + dispatch( + createContest({ + ...form, + endsAt: toUtc(form.endsAt), + startsAt: toUtc(form.startsAt), + }), + ); + }; + + return ( + +
+
+ Создать контест +
+ + handleChange('name', v)} + /> + + handleChange('description', v)} + /> + +
+
+ + +
+ +
+ + +
+
+ + {/* Даты начала и конца */} +
+ +
+ + {/* Продолжительность и лимиты */} +
+ + handleChange('attemptDurationMinutes', Number(v)) + } + /> + handleChange('maxAttempts', Number(v))} + /> +
+ + {/* Разрешить раннее завершение */} +
+ + handleChange('allowEarlyFinish', e.target.checked) + } + /> + +
+ + {/* Кнопки */} +
+ { + handleSubmit(); + }} + text="Создать" + disabled={status === 'loading'} + /> + setActive(false)} + text="Отмена" + /> +
+
+
+ ); +}; + +export default ModalCreateContest; diff --git a/src/views/home/group/contests/PastContestItem.tsx b/src/views/home/group/contests/PastContestItem.tsx new file mode 100644 index 0000000..b2f36c2 --- /dev/null +++ b/src/views/home/group/contests/PastContestItem.tsx @@ -0,0 +1,191 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { ReverseButton } from '../../../../components/button/ReverseButton'; +import { PrimaryButton } from '../../../../components/button/PrimaryButton'; +import { toastWarning } from '../../../../lib/toastNotification'; +import { useEffect, useState } from 'react'; +import { + addOrUpdateContestMember, + fetchParticipatingContests, +} from '../../../../redux/slices/contests'; + +export interface PastContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + type: 'first' | 'second'; + groupId: number; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +type Role = 'None' | 'Participant' | 'Organizer'; + +const PastContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, + groupId, +}) => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [role, setRole] = useState('None'); + + const myname = useAppSelector((state) => state.auth.username); + + const userId = useAppSelector((state) => state.auth.id); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const { contests: myContests } = useAppSelector( + (state) => state.contests.fetchMyContests, + ); + const { contests: participantContests } = useAppSelector( + (state) => state.contests.fetchParticipating, + ); + + useEffect(() => { + setRole( + (() => { + if (myContests?.some((c) => c.id === contestId)) { + return 'Organizer'; + } + if (participantContests?.some((c) => c.id === contestId)) { + return 'Participant'; + } + return 'None'; + })(), + ); + }, [myContests, participantContests]); + + return ( +
{ + if (role == 'None') { + toastWarning('Нужно зарегистрироваться на контест'); + return; + } + const params = new URLSearchParams({ + back: `/group/${groupId}/contests`, + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ +
+ {scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'} +
+ {userId && ( +
+ {role == 'Organizer' || role == 'Participant' ? ( + { + const params = new URLSearchParams({ + back: `/group/${groupId}/contests`, + }); + navigate(`/contest/${contestId}?${params}`); + }} + text="Войти" + /> + ) : ( + { + dispatch( + addOrUpdateContestMember({ + contestId: contestId, + member: { + userId: Number(userId), + role: 'Participant', + }, + }), + ) + .unwrap() + .then(() => + dispatch( + fetchParticipatingContests({}), + ), + ); + }} + text="Регистрация" + /> + )} +
+ )} +
+ ); +}; + +export default PastContestItem; diff --git a/src/views/home/group/contests/UpcomingContestItem.tsx b/src/views/home/group/contests/UpcomingContestItem.tsx new file mode 100644 index 0000000..f91a1ce --- /dev/null +++ b/src/views/home/group/contests/UpcomingContestItem.tsx @@ -0,0 +1,228 @@ +import { cn } from '../../../../lib/cn'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; +import { useQuery } from '../../../../hooks/useQuery'; +import { toastWarning } from '../../../../lib/toastNotification'; +import { useEffect, useState } from 'react'; +import { ReverseButton } from '../../../../components/button/ReverseButton'; +import { PrimaryButton } from '../../../../components/button/PrimaryButton'; +import { + addOrUpdateContestMember, + fetchParticipatingContests, +} from '../../../../redux/slices/contests'; + +type Role = 'None' | 'Participant' | 'Organizer'; + +export interface UpcoingContestItemProps { + name: string; + contestId: number; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + type: 'first' | 'second'; + groupId: number; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatDurationTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +function formatWaitTime(ms: number): string { + const minutes = Math.floor(ms / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +const UpcoingContestItem: React.FC = ({ + name, + contestId, + scheduleType, + startsAt, + endsAt, + attemptDurationMinutes, + type, + groupId, +}) => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [role, setRole] = useState('None'); + + const myname = useAppSelector((state) => state.auth.username); + + const { contests: myContests } = useAppSelector( + (state) => state.contests.fetchMyContests, + ); + const { contests: participantContests } = useAppSelector( + (state) => state.contests.fetchParticipating, + ); + + const query = useQuery(); + const username = query.get('username') ?? myname ?? ''; + + const userId = useAppSelector((state) => state.auth.id); + + const started = new Date(startsAt) <= new Date(); + const finished = new Date(endsAt) <= new Date(); + const waitTime = !started + ? new Date(startsAt).getTime() - new Date().getTime() + : new Date(endsAt).getTime() - new Date().getTime(); + + useEffect(() => { + setRole( + (() => { + if (myContests?.some((c) => c.id === contestId)) { + return 'Organizer'; + } + if (participantContests?.some((c) => c.id === contestId)) { + return 'Participant'; + } + return 'None'; + })(), + ); + }, [myContests, participantContests]); + + return ( +
{ + if (!started) { + toastWarning('Контест еще не начался'); + return; + } + + const params = new URLSearchParams({ + back: `/group/${groupId}/contests`, + }); + navigate(`/contest/${contestId}?${params}`); + }} + > +
{name}
+
+ {username} +
+ {scheduleType == 'AlwaysOpen' ? ( +
+ Всегда открыт +
+ ) : ( +
+
+ {formatDate(startsAt)} +
+
-
+
+ {formatDate(endsAt)} +
+
+ )} + +
+ {formatDurationTime(attemptDurationMinutes)} +
+ + {!started ? ( +
+ {'До начала\n' + formatWaitTime(waitTime)} +
+ ) : ( + !finished && ( +
+ {'До конца\n' + formatWaitTime(waitTime)} +
+ ) + )} + + {userId && ( +
+ {role == 'Organizer' || role == 'Participant' ? ( + { + const params = new URLSearchParams({ + back: `/group/${groupId}/contests`, + }); + navigate(`/contest/${contestId}?${params}`); + }} + text="Войти" + /> + ) : ( + { + dispatch( + addOrUpdateContestMember({ + contestId: contestId, + member: { + userId: Number(userId), + role: 'Participant', + }, + }), + ) + .unwrap() + .then(() => + dispatch( + fetchParticipatingContests({}), + ), + ); + }} + text="Регистрация" + /> + )} +
+ )} +
+ ); +}; + +export default UpcoingContestItem; diff --git a/src/views/home/group/posts/Posts.tsx b/src/views/home/group/posts/Posts.tsx index 0bee7e0..9c3df01 100644 --- a/src/views/home/group/posts/Posts.tsx +++ b/src/views/home/group/posts/Posts.tsx @@ -54,47 +54,55 @@ export const Posts: FC = ({ groupId }) => { const page0 = pages[0]; return ( -
-
- { - v; - }} - placeholder="Поиск сообщений" - /> - {isAdmin && ( -
- { - setModalCreateActive(true); - }} - text="Создать пост" - /> -
- )} -
- - {status === 'loading' &&
Загрузка...
} - {status === 'failed' &&
Ошибка загрузки постов
} - - {status == 'successful' && - page0?.items && - page0.items.length > 0 ? ( -
- {page0.items.map((post, i) => ( - - ))} +
+
+
+ { + v; + }} + placeholder="Поиск сообщений" + /> + {isAdmin && ( +
+ { + setModalCreateActive(true); + }} + text="Создать пост" + /> +
+ )}
- ) : status === 'successful' ? ( -
Постов пока нет
- ) : null} + + <> + {status === 'loading' &&
Загрузка...
} + {status === 'failed' &&
Ошибка загрузки постов
} + + {status == 'successful' && + page0?.items && + page0.items.length > 0 ? ( +
+
+ {page0.items.map((post, i) => ( + + ))} +
+
+ ) : status === 'successful' ? ( +
Постов пока нет
+ ) : null} + +
{ + const dispatch = useAppDispatch(); return (
- {}} placeholder="Поиск группы" /> + { + dispatch(setGroupFilter(v)); + }} + placeholder="Поиск группы" + />
); }; diff --git a/src/views/home/groups/GroupItem.tsx b/src/views/home/groups/GroupItem.tsx index a0a0d0b..3e92a7f 100644 --- a/src/views/home/groups/GroupItem.tsx +++ b/src/views/home/groups/GroupItem.tsx @@ -1,18 +1,12 @@ import { cn } from '../../../lib/cn'; -import { - Book, - UserAdd, - Edit, - EyeClosed, - EyeOpen, -} from '../../../assets/icons/groups'; +import { Book, UserAdd, Edit } from '../../../assets/icons/groups'; import { useNavigate } from 'react-router-dom'; import { GroupInvite, GroupUpdate } from './Groups'; +import { useAppSelector } from '../../../redux/hooks'; export interface GroupItemProps { id: number; role: 'menager' | 'member' | 'owner' | 'viewer'; - visible: boolean; name: string; description: string; setUpdateActive: (value: any) => void; @@ -43,7 +37,6 @@ const IconComponent: React.FC = ({ src, onClick }) => { const GroupItem: React.FC = ({ id, name, - visible, description, setUpdateGroup, setUpdateActive, @@ -53,6 +46,61 @@ const GroupItem: React.FC = ({ }) => { const navigate = useNavigate(); + const filter = useAppSelector( + (state) => state.store.group.groupFilter, + ).toLowerCase(); + + 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 (
= ({ className="bg-liquid-brightmain rounded-[10px]" />
-
{name}
+
+ {highlightZ(name, filter)} +
+
{type == 'manage' && ( = ({ }} /> )} - {visible == false && } - {visible == true && }
diff --git a/src/views/home/groups/Groups.tsx b/src/views/home/groups/Groups.tsx index 2752d3c..da8f0da 100644 --- a/src/views/home/groups/Groups.tsx +++ b/src/views/home/groups/Groups.tsx @@ -4,7 +4,7 @@ import { cn } from '../../../lib/cn'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import GroupsBlock from './GroupsBlock'; import { setMenuActivePage } from '../../../redux/slices/store'; -import { fetchMyGroups } from '../../../redux/slices/groups'; +import { fetchMyGroups, Group } from '../../../redux/slices/groups'; import ModalCreate from './ModalCreate'; import ModalUpdate from './ModalUpdate'; import Filters from './Filter'; @@ -45,6 +45,7 @@ const Groups = () => { const groupsError = useAppSelector( (store) => store.groups.fetchMyGroups.error, ); + const filter = useAppSelector((state) => state.store.group.groupFilter); // Берём текущего пользователя const currentUserName = useAppSelector((store) => store.auth.username); @@ -54,17 +55,21 @@ const Groups = () => { dispatch(fetchMyGroups()); }, [dispatch]); - // Разделяем группы - const { managedGroups, currentGroups, hiddenGroups } = useMemo(() => { + const applyFilter = (groups: Group[], filter: string) => { + if (!filter || filter.trim() === '') return groups; + const normalized = filter.toLowerCase(); + + return groups.filter((g) => g.name.toLowerCase().includes(normalized)); + }; + const { managedGroups, currentGroups } = useMemo(() => { if (!groups || !currentUserName) { - return { managedGroups: [], currentGroups: [], hiddenGroups: [] }; + return { managedGroups: [], currentGroups: [] }; } const managed: typeof groups = []; const current: typeof groups = []; - const hidden: typeof groups = []; // пока пустые, без логики - groups.forEach((group) => { + applyFilter(groups, filter).forEach((group) => { const me = group.members.find( (m) => m.username === currentUserName, ); @@ -80,9 +85,8 @@ const Groups = () => { return { managedGroups: managed, currentGroups: current, - hiddenGroups: hidden, }; - }, [groups, currentUserName]); + }, [groups, currentUserName, filter]); return (
@@ -137,16 +141,6 @@ const Groups = () => { setInviteGroup={setInviteGroup} type="member" /> - )}
diff --git a/src/views/home/groups/GroupsBlock.tsx b/src/views/home/groups/GroupsBlock.tsx index 2f64f17..aae2482 100644 --- a/src/views/home/groups/GroupsBlock.tsx +++ b/src/views/home/groups/GroupsBlock.tsx @@ -65,7 +65,6 @@ const GroupsBlock: FC = ({