diff --git a/src/assets/icons/group/cup.svg b/src/assets/icons/group/cup.svg new file mode 100644 index 0000000..65b15e5 --- /dev/null +++ b/src/assets/icons/group/cup.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/group/home.svg b/src/assets/icons/group/home.svg new file mode 100644 index 0000000..76e7b28 --- /dev/null +++ b/src/assets/icons/group/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/group/index.ts b/src/assets/icons/group/index.ts new file mode 100644 index 0000000..1faa753 --- /dev/null +++ b/src/assets/icons/group/index.ts @@ -0,0 +1,5 @@ +import Cup from './cup.svg'; +import Home from './home.svg'; +import MessageChat from './message-chat.svg'; + +export { Cup, MessageChat, Home }; diff --git a/src/assets/icons/group/message-chat.svg b/src/assets/icons/group/message-chat.svg new file mode 100644 index 0000000..1e32098 --- /dev/null +++ b/src/assets/icons/group/message-chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/input/SearchInput.tsx b/src/components/input/SearchInput.tsx index f091b4f..a006e99 100644 --- a/src/components/input/SearchInput.tsx +++ b/src/components/input/SearchInput.tsx @@ -39,7 +39,7 @@ export const SearchInput: React.FC = ({ > = ({ if (onKeyDown) onKeyDown(e); }} /> - + ); }; diff --git a/src/components/router/ProtectedRoute.tsx b/src/components/router/ProtectedRoute.tsx index c78e309..b704bf7 100644 --- a/src/components/router/ProtectedRoute.tsx +++ b/src/components/router/ProtectedRoute.tsx @@ -6,8 +6,6 @@ export default function ProtectedRoute() { const isAuthenticated = useAppSelector((state) => !!state.auth.jwt); const location = useLocation(); - console.log('location', location); - if (!isAuthenticated) { return ; } diff --git a/src/pages/ContestEditor.tsx b/src/pages/ContestEditor.tsx index 6b28820..a2abf04 100644 --- a/src/pages/ContestEditor.tsx +++ b/src/pages/ContestEditor.tsx @@ -69,7 +69,6 @@ const ContestEditor = () => { const { contest: contestById, status: contestByIdstatus } = useAppSelector( (state) => state.contests.fetchContestById, ); - console.log(contestByIdstatus, contestById); useEffect(() => { if (status === 'successful') { } @@ -100,9 +99,7 @@ const ContestEditor = () => { })); setMissionIdInput(''); }) - .catch((err) => { - console.error('Ошибка при загрузке миссии:', err); - }); + .catch((err) => {}); }; const removeMission = (removeId: number) => { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index b133b62..71d967c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -42,7 +42,7 @@ const Home = () => { path="group-invite/*" element={} /> - } /> + } /> } /> @@ -85,7 +85,7 @@ const Home = () => { } /> } /> } /> diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx index 161d516..0a6435b 100644 --- a/src/pages/Mission.tsx +++ b/src/pages/Mission.tsx @@ -149,9 +149,7 @@ const Mission = () => { html: htmlStatement.statementTexts['problem.html'], mediaFiles: latexStatement.mediaFiles, }; - } catch (err) { - console.error('Ошибка парсинга statementTexts:', err); - } + } catch (err) {} return (
@@ -180,7 +178,6 @@ const Mission = () => { { - console.log(contestId); await dispatch( submitMission({ missionId: missionIdNumber, @@ -200,7 +197,10 @@ const Mission = () => {
- +
diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index c4f8930..eeabf20 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -286,7 +286,6 @@ const authSlice = createSlice({ state.error = action.payload as string; // Если пользователь не авторизован (401), делаем logout и пытаемся refresh - console.log(action); if ( action.payload === 'Unauthorized' || action.payload === 'Failed to fetch user info' diff --git a/src/redux/slices/groupfeed.ts b/src/redux/slices/groupfeed.ts new file mode 100644 index 0000000..011267b --- /dev/null +++ b/src/redux/slices/groupfeed.ts @@ -0,0 +1,336 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; + +// ===================== +// Типы +// ===================== + +type Status = 'idle' | 'loading' | 'successful' | 'failed'; + +export interface Post { + id: number; + groupId: number; + authorId: number; + authorUsername: string; + name: string; + content: string; + createdAt: string; + updatedAt: string; +} + +export interface PostsPage { + items: Post[]; + hasNext: boolean; +} + +// ===================== +// Состояние +// ===================== + +interface PostsState { + fetchPosts: { + pages: Record; // страница => данные + status: Status; + error?: string; + }; + fetchPostById: { + post?: Post; + status: Status; + error?: string; + }; + createPost: { + post?: Post; + status: Status; + error?: string; + }; + updatePost: { + post?: Post; + status: Status; + error?: string; + }; + deletePost: { + deletedId?: number; + status: Status; + error?: string; + }; +} + +const initialState: PostsState = { + fetchPosts: { + pages: {}, + status: 'idle', + error: undefined, + }, + fetchPostById: { + post: undefined, + status: 'idle', + error: undefined, + }, + createPost: { + post: undefined, + status: 'idle', + error: undefined, + }, + updatePost: { + post: undefined, + status: 'idle', + error: undefined, + }, + deletePost: { + deletedId: undefined, + status: 'idle', + error: undefined, + }, +}; + +// ===================== +// Async Thunks +// ===================== + +// Получить посты группы (пагинация) +export const fetchGroupPosts = createAsyncThunk( + 'posts/fetchGroupPosts', + async ( + { + groupId, + page = 0, + pageSize = 20, + }: { groupId: number; page?: number; pageSize?: number }, + { rejectWithValue }, + ) => { + try { + const response = await axios.get( + `/groups/${groupId}/feed?page=${page}&pageSize=${pageSize}`, + ); + return { page, data: response.data as PostsPage }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки постов', + ); + } + }, +); + +// Получить один пост +export const fetchPostById = createAsyncThunk( + 'posts/fetchPostById', + async ( + { groupId, postId }: { groupId: number; postId: number }, + { rejectWithValue }, + ) => { + try { + const response = await axios.get( + `/groups/${groupId}/feed/${postId}`, + ); + return response.data as Post; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка загрузки поста', + ); + } + }, +); + +// Создать пост +export const createPost = createAsyncThunk( + 'posts/createPost', + async ( + { + groupId, + name, + content, + }: { groupId: number; name: string; content: string }, + { rejectWithValue }, + ) => { + try { + const response = await axios.post(`/groups/${groupId}/feed`, { + name, + content, + }); + return response.data as Post; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка создания поста', + ); + } + }, +); + +// Обновить пост +export const updatePost = createAsyncThunk( + 'posts/updatePost', + async ( + { + groupId, + postId, + name, + content, + }: { + groupId: number; + postId: number; + name: string; + content: string; + }, + { rejectWithValue }, + ) => { + try { + const response = await axios.put( + `/groups/${groupId}/feed/${postId}`, + { + name, + content, + }, + ); + return response.data as Post; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка обновления поста', + ); + } + }, +); + +// Удалить пост +export const deletePost = createAsyncThunk( + 'posts/deletePost', + async ( + { groupId, postId }: { groupId: number; postId: number }, + { rejectWithValue }, + ) => { + try { + await axios.delete(`/groups/${groupId}/feed/${postId}`); + return postId; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка удаления поста', + ); + } + }, +); + +// ===================== +// Slice +// ===================== + +const postsSlice = createSlice({ + name: 'posts', + initialState, + reducers: {}, + extraReducers: (builder) => { + // fetchGroupPosts + builder.addCase(fetchGroupPosts.pending, (state) => { + state.fetchPosts.status = 'loading'; + }); + builder.addCase( + fetchGroupPosts.fulfilled, + ( + state, + action: PayloadAction<{ page: number; data: PostsPage }>, + ) => { + const { page, data } = action.payload; + state.fetchPosts.status = 'successful'; + state.fetchPosts.pages[page] = data; + }, + ); + builder.addCase(fetchGroupPosts.rejected, (state, action: any) => { + state.fetchPosts.status = 'failed'; + state.fetchPosts.error = action.payload; + }); + + // fetchPostById + builder.addCase(fetchPostById.pending, (state) => { + state.fetchPostById.status = 'loading'; + }); + builder.addCase( + fetchPostById.fulfilled, + (state, action: PayloadAction) => { + state.fetchPostById.status = 'successful'; + state.fetchPostById.post = action.payload; + }, + ); + builder.addCase(fetchPostById.rejected, (state, action: any) => { + state.fetchPostById.status = 'failed'; + state.fetchPostById.error = action.payload; + }); + + // createPost + builder.addCase(createPost.pending, (state) => { + state.createPost.status = 'loading'; + }); + builder.addCase( + createPost.fulfilled, + (state, action: PayloadAction) => { + state.createPost.status = 'successful'; + state.createPost.post = action.payload; + + // добавляем сразу в первую страницу (page = 0) + if (state.fetchPosts.pages[0]) { + state.fetchPosts.pages[0].items.unshift(action.payload); + } + }, + ); + builder.addCase(createPost.rejected, (state, action: any) => { + state.createPost.status = 'failed'; + state.createPost.error = action.payload; + }); + + // updatePost + builder.addCase(updatePost.pending, (state) => { + state.updatePost.status = 'loading'; + }); + builder.addCase( + updatePost.fulfilled, + (state, action: PayloadAction) => { + state.updatePost.status = 'successful'; + state.updatePost.post = action.payload; + + // обновим в списках + for (const page of Object.values(state.fetchPosts.pages)) { + const index = page.items.findIndex( + (p) => p.id === action.payload.id, + ); + if (index !== -1) page.items[index] = action.payload; + } + + // обновим если открыт одиночный пост + if (state.fetchPostById.post?.id === action.payload.id) { + state.fetchPostById.post = action.payload; + } + }, + ); + builder.addCase(updatePost.rejected, (state, action: any) => { + state.updatePost.status = 'failed'; + state.updatePost.error = action.payload; + }); + + // deletePost + builder.addCase(deletePost.pending, (state) => { + state.deletePost.status = 'loading'; + }); + builder.addCase( + deletePost.fulfilled, + (state, action: PayloadAction) => { + state.deletePost.status = 'successful'; + state.deletePost.deletedId = action.payload; + + // удалить из всех страниц + for (const page of Object.values(state.fetchPosts.pages)) { + page.items = page.items.filter( + (p) => p.id !== action.payload, + ); + } + + // если открыт индивидуальный пост + if (state.fetchPostById.post?.id === action.payload) { + state.fetchPostById.post = undefined; + } + }, + ); + builder.addCase(deletePost.rejected, (state, action: any) => { + state.deletePost.status = 'failed'; + state.deletePost.error = action.payload; + }); + }, +}); + +export const groupFeedReducer = postsSlice.reducer; diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index bd54ba6..cc713a0 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -5,6 +5,7 @@ interface StorState { menu: { activePage: string; activeProfilePage: string; + activeGroupPage: string; }; } @@ -13,6 +14,7 @@ const initialState: StorState = { menu: { activePage: '', activeProfilePage: '', + activeGroupPage: '', }, }; @@ -30,9 +32,19 @@ const storeSlice = createSlice({ ) => { state.menu.activeProfilePage = activeProfilePage.payload; }, + setMenuActiveGroupPage: ( + state, + activeGroupPage: PayloadAction, + ) => { + state.menu.activeGroupPage = activeGroupPage.payload; + }, }, }); -export const { setMenuActivePage, setMenuActiveProfilePage } = - storeSlice.actions; +export const { + setMenuActivePage, + setMenuActiveProfilePage, + setMenuActiveGroupPage, +} = storeSlice.actions; + export const storeReducer = storeSlice.reducer; diff --git a/src/redux/slices/submit.ts b/src/redux/slices/submit.ts index 1b627f2..21522b1 100644 --- a/src/redux/slices/submit.ts +++ b/src/redux/slices/submit.ts @@ -56,7 +56,6 @@ const initialState: SubmitState = { export const submitMission = createAsyncThunk( 'submit/submitMission', async (submitData: Submit, { rejectWithValue }) => { - console.log(submitData); try { const response = await axios.post('/submits', submitData); return response.data; diff --git a/src/redux/store.ts b/src/redux/store.ts index edbe49c..84a6fb3 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -6,6 +6,7 @@ import { submitReducer } from './slices/submit'; import { contestsReducer } from './slices/contests'; import { groupsReducer } from './slices/groups'; import { articlesReducer } from './slices/articles'; +import { groupFeedReducer } from './slices/groupfeed'; // использование // import { useAppDispatch, useAppSelector } from '../redux/hooks'; @@ -25,6 +26,7 @@ export const store = configureStore({ contests: contestsReducer, groups: groupsReducer, articles: articlesReducer, + groupfeed: groupFeedReducer, }, }); diff --git a/src/views/home/account/AccoutMenu.tsx b/src/views/home/account/AccoutMenu.tsx index f8a51b1..321defb 100644 --- a/src/views/home/account/AccoutMenu.tsx +++ b/src/views/home/account/AccoutMenu.tsx @@ -76,8 +76,6 @@ const AccountMenu = () => { (state) => state.store.menu.activeProfilePage, ); - console.log('active', [activeProfilePage]); - return (
{menuItems.map((v, i) => ( diff --git a/src/views/home/account/contests/Contests.tsx b/src/views/home/account/contests/Contests.tsx index 67fdbb7..0c62c1a 100644 --- a/src/views/home/account/contests/Contests.tsx +++ b/src/views/home/account/contests/Contests.tsx @@ -22,8 +22,6 @@ const Contests = () => { dispatch(fetchRegisteredContests({})); }, []); - console.log(myContestsState); - return (
{/* Контесты, в которых я участвую */} diff --git a/src/views/home/articles/Filter.tsx b/src/views/home/articles/Filter.tsx index ca01a9d..1d8a82f 100644 --- a/src/views/home/articles/Filter.tsx +++ b/src/views/home/articles/Filter.tsx @@ -36,13 +36,13 @@ const Filters = () => { text: 'ID', }, ]} - onChange={(v) => console.log(v)} + onChange={(v) => {}} /> console.log(values)} + onChange={(values) => {}} />
); diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx index d83cea5..a660b35 100644 --- a/src/views/home/auth/Login.tsx +++ b/src/views/home/auth/Login.tsx @@ -27,7 +27,7 @@ const Login = () => { // После успешного логина useEffect(() => { dispatch(setMenuActivePage('account')); - console.log(submitClicked); + submitClicked; }, []); useEffect(() => { diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index d096422..4eee15a 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -38,7 +38,7 @@ const Register = () => { const path = from ? from.pathname + from.search : '/home/account'; navigate(path, { replace: true }); } - console.log(submitClicked); + submitClicked; }, [jwt]); const handleRegister = () => { diff --git a/src/views/home/contests/ContestItem.tsx b/src/views/home/contests/ContestItem.tsx index edf948e..1abbf4a 100644 --- a/src/views/home/contests/ContestItem.tsx +++ b/src/views/home/contests/ContestItem.tsx @@ -73,7 +73,6 @@ const ContestItem: React.FC = ({ : ' bg-liquid-background', )} onClick={() => { - console.log(456); navigate(`/contest/${id}`); }} > @@ -99,12 +98,7 @@ const ContestItem: React.FC = ({ {statusRegister == 'reg' ? ( <> {' '} - { - console.log(123); - }} - text="Регистрация" - /> + {}} text="Регистрация" /> ) : ( <> diff --git a/src/views/home/group/Group.tsx b/src/views/home/group/Group.tsx index cc6ad00..ef34d56 100644 --- a/src/views/home/group/Group.tsx +++ b/src/views/home/group/Group.tsx @@ -1,24 +1,50 @@ -import { FC } from 'react'; +import { FC, useEffect } from 'react'; import { cn } from '../../../lib/cn'; -import { useParams, Navigate } from 'react-router-dom'; +import { useParams, Navigate, Routes, Route } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { fetchGroupById } from '../../../redux/slices/groups'; +import GroupMenu from './GroupMenu'; +import { Posts } from './posts/Posts'; +import { SearchInput } from '../../../components/input/SearchInput'; +import { Chat } from './chat/Chat'; +import { Contests } from './contests/Contests'; interface GroupsBlockProps {} const Group: FC = () => { - const { groupId } = useParams<{ groupId: string }>(); - const groupIdNumber = Number(groupId); - - if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) { + const groupId = Number(useParams<{ groupId: string }>().groupId); + if (!groupId) { return ; } + const dispatch = useAppDispatch(); + const group = useAppSelector((state) => state.groups.fetchGroupById.group); + + useEffect(() => { + dispatch(fetchGroupById(groupId)); + }, [groupId]); + + console.log(group); + return (
- {groupIdNumber} +
{group?.name}
+ + + + + } /> + } /> + } /> + } + /> +
); }; diff --git a/src/views/home/group/GroupMenu.tsx b/src/views/home/group/GroupMenu.tsx new file mode 100644 index 0000000..081c9f2 --- /dev/null +++ b/src/views/home/group/GroupMenu.tsx @@ -0,0 +1,96 @@ +import { MessageChat, Home, Cup } from '../../../assets/icons/group'; + +import React, { FC } from 'react'; +import { Link } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { + setMenuActivePage, + setMenuActiveProfilePage, +} from '../../../redux/slices/store'; + +interface MenuItemProps { + icon: string; + text: string; + href: string; + page: string; + profilePage: string; + active?: boolean; +} + +const MenuItem: React.FC = ({ + icon, + text = '', + href = '', + active = false, + page = '', + profilePage = '', +}) => { + const dispatch = useAppDispatch(); + + return ( + { + dispatch(setMenuActivePage(page)); + dispatch(setMenuActiveProfilePage(profilePage)); + }} + > + + {text} + + ); +}; + +interface GroupMenuProps { + groupId: number; +} + +const GroupMenu: FC = ({ groupId }) => { + const menuItems = [ + { + text: 'Главная', + href: `/group/${groupId}/home`, + icon: Home, + page: 'group', + profilePage: 'home', + }, + { + text: 'Чат', + href: `/group/${groupId}/chat`, + icon: MessageChat, + page: 'group', + profilePage: 'chat', + }, + { + text: 'Контесты', + href: `/group/${groupId}/contests`, + icon: Cup, + page: 'group', + profilePage: 'contests', + }, + ]; + + const activeGroupPage = useAppSelector( + (state) => state.store.menu.activeGroupPage, + ); + + return ( +
+ {menuItems.map((v, i) => ( + + ))} +
+ ); +}; + +export default GroupMenu; diff --git a/src/views/home/group/chat/Chat.tsx b/src/views/home/group/chat/Chat.tsx new file mode 100644 index 0000000..7464e22 --- /dev/null +++ b/src/views/home/group/chat/Chat.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { useAppDispatch } from '../../../../redux/hooks'; +import { setMenuActiveGroupPage } from '../../../../redux/slices/store'; + +export const Chat = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setMenuActiveGroupPage('chat')); + }, []); + return <>; +}; diff --git a/src/views/home/group/contests/Contests.tsx b/src/views/home/group/contests/Contests.tsx new file mode 100644 index 0000000..39faea4 --- /dev/null +++ b/src/views/home/group/contests/Contests.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { useAppDispatch } from '../../../../redux/hooks'; +import { setMenuActiveGroupPage } from '../../../../redux/slices/store'; + +export const Contests = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setMenuActiveGroupPage('contests')); + }, []); + return <>; +}; diff --git a/src/views/home/group/posts/PostItem.tsx b/src/views/home/group/posts/PostItem.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/views/home/group/posts/Posts.tsx b/src/views/home/group/posts/Posts.tsx new file mode 100644 index 0000000..8e9a0b0 --- /dev/null +++ b/src/views/home/group/posts/Posts.tsx @@ -0,0 +1,83 @@ +import { FC, useEffect } from 'react'; + +import { useAppSelector, useAppDispatch } from '../../../../redux/hooks'; +import { fetchGroupPosts } from '../../../../redux/slices/groupfeed'; +import { SearchInput } from '../../../../components/input/SearchInput'; +import { setMenuActiveGroupPage } from '../../../../redux/slices/store'; + +interface PostsProps { + groupId: number; +} + +export const Posts: FC = ({ groupId }) => { + const dispatch = useAppDispatch(); + + const { pages, status } = useAppSelector( + (state) => state.groupfeed.fetchPosts, + ); + + // Загружаем только первую страницу + useEffect(() => { + dispatch(fetchGroupPosts({ groupId, page: 0, pageSize: 20 })); + }, [groupId]); + + useEffect(() => { + dispatch(setMenuActiveGroupPage('home')); + }, []); + + const page0 = pages[0]; + + return ( +
+
+ {}} + placeholder="Поиск сообщений" + /> +
+ + {status === 'loading' &&
Загрузка...
} + {status === 'failed' &&
Ошибка загрузки постов
} + + {status == 'successful' && + page0?.items && + page0.items.length > 0 ? ( +
+ {page0.items.map((post) => ( +
+
+ ID: {post.id} +
+
+ Название: {post.name} +
+
+ Содержимое: {post.content} +
+
+ Автор: {post.authorUsername} +
+
+ Автор ID: {post.authorId} +
+
+ Group ID: {post.groupId} +
+
+ Создан: {post.createdAt} +
+
+ Обновлён: {post.updatedAt} +
+
+ ))} +
+ ) : status === 'successful' ? ( +
Постов пока нет
+ ) : null} +
+ ); +}; diff --git a/src/views/home/groupinviter/GroupInvite.tsx b/src/views/home/groupinviter/GroupInvite.tsx index 893d817..1b3cc7c 100644 --- a/src/views/home/groupinviter/GroupInvite.tsx +++ b/src/views/home/groupinviter/GroupInvite.tsx @@ -63,9 +63,7 @@ const GroupInvite = () => { if (!token) return; try { await dispatch(joinGroupByToken(token)).unwrap(); - } catch (err) { - console.error('Failed to join group', err); - } + } catch (err) {} }; const handleCancel = () => { diff --git a/src/views/home/groups/Filter.tsx b/src/views/home/groups/Filter.tsx index ca01a9d..1d8a82f 100644 --- a/src/views/home/groups/Filter.tsx +++ b/src/views/home/groups/Filter.tsx @@ -36,13 +36,13 @@ const Filters = () => { text: 'ID', }, ]} - onChange={(v) => console.log(v)} + onChange={(v) => {}} /> console.log(values)} + onChange={(values) => {}} />
);