From c6303758e133f1f7a52076acad5aa7c925fdb25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:43:18 +0300 Subject: [PATCH] Add account and articles updater --- src/assets/icons/account/clipboard.svg | 3 + src/assets/icons/account/cup.svg | 3 + src/assets/icons/account/index.ts | 5 + src/assets/icons/account/openbook.svg | 3 + src/assets/icons/input/edit.svg | 3 + src/assets/icons/input/index.ts | 2 + src/pages/Article.tsx | 8 +- src/pages/ArticleEditor.tsx | 261 +++++++++++++++-------- src/pages/Mission.tsx | 1 + src/redux/slices/auth.ts | 2 +- src/redux/slices/store.ts | 11 +- src/views/article/Header.tsx | 16 +- src/views/articleeditor/Header.tsx | 6 +- src/views/home/account/Account.tsx | 30 ++- src/views/home/account/AccoutMenu.tsx | 91 +++++++- src/views/home/account/ArticlesBlock.tsx | 123 ++++++++++- src/views/home/account/ContestsBlock.tsx | 15 +- src/views/home/account/MissionsBlock.tsx | 16 +- src/views/home/account/RightPanel.tsx | 99 ++++++++- src/views/home/contest/Missions.tsx | 3 +- src/views/home/menu/MenuItem.tsx | 2 +- tsconfig.app.tsbuildinfo | 2 +- 22 files changed, 581 insertions(+), 124 deletions(-) create mode 100644 src/assets/icons/account/clipboard.svg create mode 100644 src/assets/icons/account/cup.svg create mode 100644 src/assets/icons/account/index.ts create mode 100644 src/assets/icons/account/openbook.svg create mode 100644 src/assets/icons/input/edit.svg diff --git a/src/assets/icons/account/clipboard.svg b/src/assets/icons/account/clipboard.svg new file mode 100644 index 0000000..fb6e81b --- /dev/null +++ b/src/assets/icons/account/clipboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/account/cup.svg b/src/assets/icons/account/cup.svg new file mode 100644 index 0000000..330aeb0 --- /dev/null +++ b/src/assets/icons/account/cup.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/account/index.ts b/src/assets/icons/account/index.ts new file mode 100644 index 0000000..5fa91fb --- /dev/null +++ b/src/assets/icons/account/index.ts @@ -0,0 +1,5 @@ +import Clipboard from './clipboard.svg'; +import Cup from './cup.svg'; +import OpenBook from './openbook.svg'; + +export { Clipboard, Cup, OpenBook }; diff --git a/src/assets/icons/account/openbook.svg b/src/assets/icons/account/openbook.svg new file mode 100644 index 0000000..db632c4 --- /dev/null +++ b/src/assets/icons/account/openbook.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/input/edit.svg b/src/assets/icons/input/edit.svg new file mode 100644 index 0000000..27910a5 --- /dev/null +++ b/src/assets/icons/input/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/input/index.ts b/src/assets/icons/input/index.ts index 4888d41..08cfb70 100644 --- a/src/assets/icons/input/index.ts +++ b/src/assets/icons/input/index.ts @@ -4,8 +4,10 @@ import googleLogo from './google-logo.svg'; import upload from './upload.svg'; import chevroneDropDownList from './chevron-drop-down.svg'; import checkMark from './check-mark.svg'; +import Edit from './edit.svg'; export { + Edit, eyeClosed, eyeOpen, googleLogo, diff --git a/src/pages/Article.tsx b/src/pages/Article.tsx index e6e8ad7..44b910a 100644 --- a/src/pages/Article.tsx +++ b/src/pages/Article.tsx @@ -4,12 +4,18 @@ import Header from '../views/article/Header'; import { useEffect } from 'react'; import { fetchArticleById } from '../redux/slices/articles'; import MarkdownPreview from '../views/articleeditor/MarckDownPreview'; +import { useQuery } from '../hooks/useQuery'; const Article = () => { // Получаем параметры из URL const { articleId } = useParams<{ articleId: string }>(); const articleIdNumber = Number(articleId); + + const query = useQuery(); + const back = query.get('back') ?? undefined; + if (!articleId || isNaN(articleIdNumber)) { + if (back) return ; return ; } const dispatch = useAppDispatch(); @@ -24,7 +30,7 @@ const Article = () => {
-
+
{status == 'loading' || !article ? ( diff --git a/src/pages/ArticleEditor.tsx b/src/pages/ArticleEditor.tsx index 10b99f3..79002b8 100644 --- a/src/pages/ArticleEditor.tsx +++ b/src/pages/ArticleEditor.tsx @@ -1,4 +1,4 @@ -import { Route, Routes, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import Header from '../views/articleeditor/Header'; import MarkdownEditor from '../views/articleeditor/Editor'; import { useEffect, useState } from 'react'; @@ -6,19 +6,42 @@ import { PrimaryButton } from '../components/button/PrimaryButton'; import MarkdownPreview from '../views/articleeditor/MarckDownPreview'; import { Input } from '../components/input/Input'; import { useAppDispatch, useAppSelector } from '../redux/hooks'; -import { createArticle, setArticlesStatus } from '../redux/slices/articles'; +import { + createArticle, + deleteArticle, + fetchArticleById, + setArticlesStatus, + updateArticle, +} from '../redux/slices/articles'; +import { useQuery } from '../hooks/useQuery'; +import { ReverseButton } from '../components/button/ReverseButton'; const ArticleEditor = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); - const [code, setCode] = useState(''); - const [name, setName] = useState(''); + const query = useQuery(); + const back = query.get('back') ?? undefined; + const articleId = Number(query.get('articleId') ?? undefined); + const article = useAppSelector((state) => state.articles.currentArticle); + const refactor = articleId != undefined && !isNaN(articleId); + const [code, setCode] = useState(article?.content || ''); + const [name, setName] = useState(article?.name || ''); const [tagInput, setTagInput] = useState(''); - const [tags, setTags] = useState([]); + const [tags, setTags] = useState(article?.tags || []); - const status = useAppSelector((state) => state.articles.statuses.create); + const [activeEditor, setActiveEditor] = useState(false); + + const statusCreate = useAppSelector( + (state) => state.articles.statuses.create, + ); + const statusUpdate = useAppSelector( + (state) => state.articles.statuses.update, + ); + const statusDelete = useAppSelector( + (state) => state.articles.statuses.delete, + ); const addTag = () => { const newTag = tagInput.trim(); @@ -33,40 +56,92 @@ const ArticleEditor = () => { }; useEffect(() => { - if (status == 'successful') { + if (statusCreate == 'successful') { dispatch(setArticlesStatus({ key: 'create', status: 'idle' })); - navigate('/home/articles'); + navigate(back ? back : '/home/articles'); } - }, [status]); + }, [statusCreate]); + + useEffect(() => { + if (statusDelete == 'successful') { + dispatch(setArticlesStatus({ key: 'delete', status: 'idle' })); + navigate(back ? back : '/home/articles'); + } + }, [statusDelete]); + + useEffect(() => { + if (statusUpdate == 'successful') { + dispatch(setArticlesStatus({ key: 'update', status: 'idle' })); + navigate(back ? back : '/home/articles'); + } + }, [statusUpdate]); + + useEffect(() => { + if (articleId) { + dispatch(fetchArticleById(articleId)); + } + }, [articleId]); + + useEffect(() => { + if (article && refactor) { + setCode(article?.content || ''); + setName(article?.name || ''); + setTags(article?.tags || []); + } + }, [article]); return (
- - } + {activeEditor ? ( +
{ + setActiveEditor(false); + }} /> - } /> - + ) : ( +
navigate(back ? back : '/home/articles')} + /> + )} - - - } - /> - -
- Создание статьи + {activeEditor ? ( + + ) : ( +
+
+ {refactor + ? `Редактирование статьи: \"${article?.name}\"` + : 'Создание статьи'} +
+
+ {refactor ? ( +
+ { + dispatch( + updateArticle({ + articleId, + name, + tags, + content: code, + }), + ); + }} + text="Обновить" + className="mt-[20px]" + disabled={statusUpdate == 'loading'} + /> + { + dispatch(deleteArticle(articleId)); + }} + color="error" + text="Удалить" + className="mt-[20px]" + disabled={statusDelete == 'loading'} + />
- + ) : ( { dispatch( @@ -79,78 +154,78 @@ const ArticleEditor = () => { }} text="Опубликовать" className="mt-[20px]" - disabled={status == 'loading'} + disabled={statusCreate == 'loading'} /> + )} +
+ { + setName(v); + }} + placeholder="Новая статья" + /> + + {/* Блок для тегов */} +
+
{ - setName(v); + setTagInput(v); + }} + defaultState={tagInput} + placeholder="arrays" + onKeyDown={(e) => { + console.log(e.key); + if (e.key == 'Enter') addTag(); }} - placeholder="Новая статья" /> - - {/* Блок для тегов */} -
-
- { - setTagInput(v); - }} - defaultState={tagInput} - placeholder="arrays" - onKeyDown={(e) => { - console.log(e.key); - if (e.key == 'Enter') addTag(); - }} - /> - -
-
- {tags.map((tag) => ( -
- {tag} - -
- ))} -
-
- navigate('editor')} - text="Редактировать текст" - className="mt-[20px]" - /> -
- } - /> - +
+ {tags.map((tag) => ( +
+ {tag} + +
+ ))} +
+
+ + setActiveEditor(true)} + text="Редактировать текст" + className="mt-[20px]" + /> + +
+ )}
); }; diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx index 66f1191..b09c25a 100644 --- a/src/pages/Mission.tsx +++ b/src/pages/Mission.tsx @@ -22,6 +22,7 @@ const Mission = () => { const back = query.get('back') ?? undefined; if (!missionId || isNaN(missionIdNumber)) { + if (back) return ; return ; } diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index 50f6523..9b04ff6 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -1,4 +1,4 @@ -import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import axios from '../../axios'; // 🔹 Декодирование JWT diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index d67bb2b..bd54ba6 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -4,6 +4,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface StorState { menu: { activePage: string; + activeProfilePage: string; }; } @@ -11,6 +12,7 @@ interface StorState { const initialState: StorState = { menu: { activePage: '', + activeProfilePage: '', }, }; @@ -22,8 +24,15 @@ const storeSlice = createSlice({ setMenuActivePage: (state, activePage: PayloadAction) => { state.menu.activePage = activePage.payload; }, + setMenuActiveProfilePage: ( + state, + activeProfilePage: PayloadAction, + ) => { + state.menu.activeProfilePage = activeProfilePage.payload; + }, }, }); -export const { setMenuActivePage } = storeSlice.actions; +export const { setMenuActivePage, setMenuActiveProfilePage } = + storeSlice.actions; export const storeReducer = storeSlice.reducer; diff --git a/src/views/article/Header.tsx b/src/views/article/Header.tsx index 93920fe..dad0203 100644 --- a/src/views/article/Header.tsx +++ b/src/views/article/Header.tsx @@ -9,9 +9,10 @@ import { useNavigate } from 'react-router-dom'; interface HeaderProps { articleId: number; + back?: string; } -const Header: React.FC = ({ articleId }) => { +const Header: React.FC = ({ articleId, back }) => { const navigate = useNavigate(); return (
@@ -29,7 +30,7 @@ const Header: React.FC = ({ articleId }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - navigate('/home/articles'); + navigate(back ? back : '/home/articles'); }} /> @@ -39,8 +40,11 @@ const Header: React.FC = ({ articleId }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - if (articleId > 1) - navigate(`/article/${articleId - 1}`); + if (articleId <= 1) return; + + if (back) + navigate(`/article/${articleId - 1}?back=${back}`); + else navigate(`/article/${articleId - 1}`); }} /> #{articleId} @@ -49,7 +53,9 @@ const Header: React.FC = ({ articleId }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - navigate(`/article/${articleId + 1}`); + if (back) + navigate(`/article/${articleId + 1}?back=${back}`); + else navigate(`/article/${articleId + 1}`); }} />
diff --git a/src/views/articleeditor/Header.tsx b/src/views/articleeditor/Header.tsx index 384972f..7109c5d 100644 --- a/src/views/articleeditor/Header.tsx +++ b/src/views/articleeditor/Header.tsx @@ -4,10 +4,10 @@ import { Logo } from '../../assets/logos'; import { useNavigate } from 'react-router-dom'; interface HeaderProps { - backUrl?: string; + backClick?: () => void; } -const Header: React.FC = ({ backUrl = '/home/articles' }) => { +const Header: React.FC = ({ backClick }) => { const navigate = useNavigate(); return (
@@ -25,7 +25,7 @@ const Header: React.FC = ({ backUrl = '/home/articles' }) => { alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { - navigate(backUrl); + if (backClick) backClick(); }} /> diff --git a/src/views/home/account/Account.tsx b/src/views/home/account/Account.tsx index b508835..7dbf7af 100644 --- a/src/views/home/account/Account.tsx +++ b/src/views/home/account/Account.tsx @@ -1,33 +1,47 @@ -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import AccountMenu from './AccoutMenu'; import RightPanel from './RightPanel'; import MissionsBlock from './MissionsBlock'; import ContestsBlock from './ContestsBlock'; import ArticlesBlock from './ArticlesBlock'; +import { useAppDispatch } from '../../../redux/hooks'; +import { useEffect } from 'react'; +import { setMenuActivePage } from '../../../redux/slices/store'; const Account = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setMenuActivePage('account')); + }, []); + return ( -
+
-
-
+
+
} /> } /> } /> - } /> + + } + />
diff --git a/src/views/home/account/AccoutMenu.tsx b/src/views/home/account/AccoutMenu.tsx index 204bf1b..f8a51b1 100644 --- a/src/views/home/account/AccoutMenu.tsx +++ b/src/views/home/account/AccoutMenu.tsx @@ -1,5 +1,94 @@ +import { Openbook, Cup, Clipboard } from '../../../assets/icons/menu'; + +import React 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} + + ); +}; + const AccountMenu = () => { - return
; + const menuItems = [ + { + text: 'Задачи', + href: '/home/account/missions', + icon: Clipboard, + page: 'account', + profilePage: 'missions', + }, + { + text: 'Статьи', + href: '/home/account/articles', + icon: Openbook, + page: 'account', + profilePage: 'articles', + }, + { + text: 'Контесты', + href: '/home/account/contests', + icon: Cup, + page: 'account', + profilePage: 'contests', + }, + ]; + + const activeProfilePage = useAppSelector( + (state) => state.store.menu.activeProfilePage, + ); + + console.log('active', [activeProfilePage]); + + return ( +
+ {menuItems.map((v, i) => ( + + ))} +
+ ); }; export default AccountMenu; diff --git a/src/views/home/account/ArticlesBlock.tsx b/src/views/home/account/ArticlesBlock.tsx index a0ec79c..1d4f141 100644 --- a/src/views/home/account/ArticlesBlock.tsx +++ b/src/views/home/account/ArticlesBlock.tsx @@ -1,5 +1,124 @@ -const ArticlesBlock = () => { - return
; +import { FC, useEffect, useState } from 'react'; +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 { fetchArticles } from '../../../redux/slices/articles'; + +import { useNavigate } from 'react-router-dom'; + +export interface ArticleItemProps { + id: number; + name: string; + tags: string[]; +} + +const ArticleItem: React.FC = ({ id, name, tags }) => { + const navigate = useNavigate(); + return ( +
{ + navigate(`/article/${id}?back=/home/account/articles`); + }} + > +
+
+ #{id} +
+
+ {name} +
+
+
+ {tags.map((v, i) => ( +
+ {v} +
+ ))} +
+ + { + e.stopPropagation(); + navigate( + `/article/create?back=/home/account/articles&articleId=${id}`, + ); + }} + /> +
+ ); +}; + +interface ArticlesBlockProps { + className?: string; +} + +const ArticlesBlock: FC = ({ className = '' }) => { + const dispatch = useAppDispatch(); + const articles = useAppSelector((state) => state.articles.articles); + const [active, setActive] = useState(true); + + useEffect(() => { + dispatch(setMenuActiveProfilePage('articles')); + dispatch(fetchArticles({})); + }, []); + return ( +
+
+
{ + setActive(!active); + }} + > + Мои статьи + +
+
+
+
+ {articles.map((v, i) => ( + + ))} +
+
+
+
+
+ ); }; export default ArticlesBlock; diff --git a/src/views/home/account/ContestsBlock.tsx b/src/views/home/account/ContestsBlock.tsx index ef6d1f9..91b13ac 100644 --- a/src/views/home/account/ContestsBlock.tsx +++ b/src/views/home/account/ContestsBlock.tsx @@ -1,5 +1,18 @@ +import { useEffect } from 'react'; +import { useAppDispatch } from '../../../redux/hooks'; +import { setMenuActiveProfilePage } from '../../../redux/slices/store'; + const ContestsBlock = () => { - return
; + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setMenuActiveProfilePage('contests')); + }, []); + return ( +
+ Пока пусто :( +
+ ); }; export default ContestsBlock; diff --git a/src/views/home/account/MissionsBlock.tsx b/src/views/home/account/MissionsBlock.tsx index b439d81..1fce2a8 100644 --- a/src/views/home/account/MissionsBlock.tsx +++ b/src/views/home/account/MissionsBlock.tsx @@ -1,5 +1,19 @@ +import { useEffect } from 'react'; +import { useAppDispatch } from '../../../redux/hooks'; +import { setMenuActiveProfilePage } from '../../../redux/slices/store'; + const MissionsBlock = () => { - return
; + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setMenuActiveProfilePage('missions')); + }, []); + + return ( +
+ Пока пусто :( +
+ ); }; export default MissionsBlock; diff --git a/src/views/home/account/RightPanel.tsx b/src/views/home/account/RightPanel.tsx index effd006..b1b63d8 100644 --- a/src/views/home/account/RightPanel.tsx +++ b/src/views/home/account/RightPanel.tsx @@ -1,15 +1,108 @@ +import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { ReverseButton } from '../../../components/button/ReverseButton'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { logout } from '../../../redux/slices/auth'; +import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account'; +import { FC } from 'react'; + +interface StatisticItemProps { + icon: string; + title: string; + count?: number; + countLastWeek?: number; +} +const StatisticItem: FC = ({ + title, + icon, + count = 0, + countLastWeek = 0, +}) => { + return ( +
+ +
+
+
{title}
+
{count}
+
+
+ {'За 7 дней '} + {countLastWeek} +
+
+
+ ); +}; const RightPanel = () => { const dispatch = useAppDispatch(); const name = useAppSelector((state) => state.auth.username); const email = useAppSelector((state) => state.auth.email); return ( -
-
{name}
-
{email}
+
+
+
+
+
+ {name} +
+
+ {email} +
+
+ Топ 50% +
+
+
+ + {}} + text="Редактировать" + className="w-full" + /> + +
+ +
+ {'Статистика решений'} +
+ + + + +
+ {'Статистика созданий'} +
+ + + + + { diff --git a/src/views/home/contest/Missions.tsx b/src/views/home/contest/Missions.tsx index 00d9824..535cf05 100644 --- a/src/views/home/contest/Missions.tsx +++ b/src/views/home/contest/Missions.tsx @@ -1,6 +1,5 @@ import { FC } from 'react'; -import { useAppDispatch } from '../../../redux/hooks'; -import MissionItem, { MissionItemProps } from './MissionItem'; +import MissionItem from './MissionItem'; import { Contest } from '../../../redux/slices/contests'; export interface Article { diff --git a/src/views/home/menu/MenuItem.tsx b/src/views/home/menu/MenuItem.tsx index 501b3c0..db112df 100644 --- a/src/views/home/menu/MenuItem.tsx +++ b/src/views/home/menu/MenuItem.tsx @@ -24,7 +24,7 @@ const MenuItem: React.FC = ({