diff --git a/src/App.tsx b/src/App.tsx index 7bf8098..6d0ec91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Route, Routes } from 'react-router-dom'; import Home from './pages/Home'; import Mission from './pages/Mission'; import ArticleEditor from './pages/ArticleEditor'; +import Article from './pages/Article'; function App() { return ( @@ -19,6 +20,7 @@ function App() { path="/article/create/*" element={} /> + } /> } /> diff --git a/src/pages/Article.tsx b/src/pages/Article.tsx new file mode 100644 index 0000000..e6e8ad7 --- /dev/null +++ b/src/pages/Article.tsx @@ -0,0 +1,67 @@ +import { useParams, Navigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../redux/hooks'; +import Header from '../views/article/Header'; +import { useEffect } from 'react'; +import { fetchArticleById } from '../redux/slices/articles'; +import MarkdownPreview from '../views/articleeditor/MarckDownPreview'; + +const Article = () => { + // Получаем параметры из URL + const { articleId } = useParams<{ articleId: string }>(); + const articleIdNumber = Number(articleId); + if (!articleId || isNaN(articleIdNumber)) { + return ; + } + const dispatch = useAppDispatch(); + const article = useAppSelector((state) => state.articles.currentArticle); + const status = useAppSelector((state) => state.articles.statuses.fetchById); + + useEffect(() => { + dispatch(fetchArticleById(articleIdNumber)); + }, [articleIdNumber]); + + return ( +
+
+
+
+
+ + {status == 'loading' || !article ? ( +
Загрузка...
+ ) : ( +
+
+
+ {article.name} +
+
+ #{article.id} +
+
+ {article.tags.length && ( +
+ {article.tags.map((v, i) => ( +
+ {v} +
+ ))} +
+ )} + +
+ )} +
+ +
+
+ ); +}; + +export default Article; diff --git a/src/pages/ArticleEditor.tsx b/src/pages/ArticleEditor.tsx index 9b06271..10b99f3 100644 --- a/src/pages/ArticleEditor.tsx +++ b/src/pages/ArticleEditor.tsx @@ -1,19 +1,25 @@ import { Route, Routes, useNavigate } from 'react-router-dom'; import Header from '../views/articleeditor/Header'; import MarkdownEditor from '../views/articleeditor/Editor'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; 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'; const ArticleEditor = () => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const [code, setCode] = useState(''); const [name, setName] = useState(''); - const navigate = useNavigate(); const [tagInput, setTagInput] = useState(''); const [tags, setTags] = useState([]); + const status = useAppSelector((state) => state.articles.statuses.create); + const addTag = () => { const newTag = tagInput.trim(); if (newTag && !tags.includes(newTag)) { @@ -26,6 +32,13 @@ const ArticleEditor = () => { setTags(tags.filter((tag) => tag !== tagToRemove)); }; + useEffect(() => { + if (status == 'successful') { + dispatch(setArticlesStatus({ key: 'create', status: 'idle' })); + navigate('/home/articles'); + } + }, [status]); + return (
@@ -39,7 +52,12 @@ const ArticleEditor = () => { } + element={ + + } /> { { - console.log({ - name: name, - tags: tags, - text: code, - }); + dispatch( + createArticle({ + name, + tags, + content: code, + }), + ); }} text="Опубликовать" className="mt-[20px]" + disabled={status == 'loading'} /> { + try { + const response = await axios.post('/articles', { + name, + content, + tags, + }); + return response.data as Article; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при создании статьи', + ); + } + }, +); + +// PUT /articles/{articleId} +export const updateArticle = createAsyncThunk( + 'articles/updateArticle', + async ( + { + articleId, + name, + content, + tags, + }: { articleId: number; name: string; content: string; tags: string[] }, + { rejectWithValue }, + ) => { + try { + const response = await axios.put(`/articles/${articleId}`, { + name, + content, + tags, + }); + return response.data as Article; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при обновлении статьи', + ); + } + }, +); + +// DELETE /articles/{articleId} +export const deleteArticle = createAsyncThunk( + 'articles/deleteArticle', + async (articleId: number, { rejectWithValue }) => { + try { + await axios.delete(`/articles/${articleId}`); + return articleId; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при удалении статьи', + ); + } + }, +); + +// GET /articles +export const fetchArticles = createAsyncThunk( + 'articles/fetchArticles', + async ( + { + page = 0, + pageSize = 10, + tags, + }: { page?: number; pageSize?: number; tags?: string[] }, + { rejectWithValue }, + ) => { + try { + const params: any = { page, pageSize }; + if (tags && tags.length > 0) params.tags = tags; + const response = await axios.get('/articles', { params }); + return response.data as { + hasNextPage: boolean; + articles: Article[]; + }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при получении статей', + ); + } + }, +); + +// GET /articles/{articleId} +export const fetchArticleById = createAsyncThunk( + 'articles/fetchArticleById', + async (articleId: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/articles/${articleId}`); + return response.data as Article; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при получении статьи', + ); + } + }, +); + +// ─── Slice ──────────────────────────────────────────── + +const articlesSlice = createSlice({ + name: 'articles', + initialState, + reducers: { + clearCurrentArticle: (state) => { + state.currentArticle = undefined; + }, + setArticlesStatus: ( + state, + action: PayloadAction<{ + key: keyof ArticlesState['statuses']; + status: Status; + }>, + ) => { + const { key, status } = action.payload; + state.statuses[key] = status; + }, + }, + extraReducers: (builder) => { + // ─── CREATE ARTICLE ─── + builder.addCase(createArticle.pending, (state) => { + state.statuses.create = 'loading'; + state.error = null; + }); + builder.addCase( + createArticle.fulfilled, + (state, action: PayloadAction
) => { + state.statuses.create = 'successful'; + state.articles.push(action.payload); + }, + ); + builder.addCase( + createArticle.rejected, + (state, action: PayloadAction) => { + state.statuses.create = 'failed'; + state.error = action.payload; + }, + ); + + // ─── UPDATE ARTICLE ─── + builder.addCase(updateArticle.pending, (state) => { + state.statuses.update = 'loading'; + state.error = null; + }); + builder.addCase( + updateArticle.fulfilled, + (state, action: PayloadAction
) => { + state.statuses.update = 'successful'; + const index = state.articles.findIndex( + (a) => a.id === action.payload.id, + ); + if (index !== -1) state.articles[index] = action.payload; + if (state.currentArticle?.id === action.payload.id) + state.currentArticle = action.payload; + }, + ); + builder.addCase( + updateArticle.rejected, + (state, action: PayloadAction) => { + state.statuses.update = 'failed'; + state.error = action.payload; + }, + ); + + // ─── DELETE ARTICLE ─── + builder.addCase(deleteArticle.pending, (state) => { + state.statuses.delete = 'loading'; + state.error = null; + }); + builder.addCase( + deleteArticle.fulfilled, + (state, action: PayloadAction) => { + state.statuses.delete = 'successful'; + state.articles = state.articles.filter( + (a) => a.id !== action.payload, + ); + if (state.currentArticle?.id === action.payload) + state.currentArticle = undefined; + }, + ); + builder.addCase( + deleteArticle.rejected, + (state, action: PayloadAction) => { + state.statuses.delete = 'failed'; + state.error = action.payload; + }, + ); + + // ─── FETCH ARTICLES ─── + builder.addCase(fetchArticles.pending, (state) => { + state.statuses.fetchAll = 'loading'; + state.error = null; + }); + builder.addCase( + fetchArticles.fulfilled, + ( + state, + action: PayloadAction<{ + hasNextPage: boolean; + articles: Article[]; + }>, + ) => { + state.statuses.fetchAll = 'successful'; + state.articles = action.payload.articles; + state.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase( + fetchArticles.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchAll = 'failed'; + state.error = action.payload; + }, + ); + + // ─── FETCH ARTICLE BY ID ─── + builder.addCase(fetchArticleById.pending, (state) => { + state.statuses.fetchById = 'loading'; + state.error = null; + }); + builder.addCase( + fetchArticleById.fulfilled, + (state, action: PayloadAction
) => { + state.statuses.fetchById = 'successful'; + state.currentArticle = action.payload; + }, + ); + builder.addCase( + fetchArticleById.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchById = 'failed'; + state.error = action.payload; + }, + ); + }, +}); + +export const { clearCurrentArticle, setArticlesStatus } = articlesSlice.actions; +export const articlesReducer = articlesSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 5075b68..edbe49c 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -5,6 +5,7 @@ import { missionsReducer } from './slices/missions'; import { submitReducer } from './slices/submit'; import { contestsReducer } from './slices/contests'; import { groupsReducer } from './slices/groups'; +import { articlesReducer } from './slices/articles'; // использование // import { useAppDispatch, useAppSelector } from '../redux/hooks'; @@ -23,6 +24,7 @@ export const store = configureStore({ submin: submitReducer, contests: contestsReducer, groups: groupsReducer, + articles: articlesReducer, }, }); diff --git a/src/views/article/Header.tsx b/src/views/article/Header.tsx new file mode 100644 index 0000000..93920fe --- /dev/null +++ b/src/views/article/Header.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { + chevroneLeft, + chevroneRight, + arrowLeft, +} from '../../assets/icons/header'; +import { Logo } from '../../assets/logos'; +import { useNavigate } from 'react-router-dom'; + +interface HeaderProps { + articleId: number; +} + +const Header: React.FC = ({ articleId }) => { + const navigate = useNavigate(); + return ( +
+ Logo { + navigate('/home'); + }} + /> + + back { + navigate('/home/articles'); + }} + /> + +
+ back { + if (articleId > 1) + navigate(`/article/${articleId - 1}`); + }} + /> + #{articleId} + back { + navigate(`/article/${articleId + 1}`); + }} + /> +
+
+ ); +}; + +export default Header; diff --git a/src/views/home/articles/ArticleItem.tsx b/src/views/home/articles/ArticleItem.tsx index afe5ef5..6ab32dd 100644 --- a/src/views/home/articles/ArticleItem.tsx +++ b/src/views/home/articles/ArticleItem.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import { cn } from '../../../lib/cn'; export interface ArticleItemProps { @@ -7,14 +8,18 @@ export interface ArticleItemProps { } const ArticleItem: React.FC = ({ id, name, tags }) => { + const navigate = useNavigate(); return (
{ + navigate(`/article/${id}`); + }} >
diff --git a/src/views/home/articles/Articles.tsx b/src/views/home/articles/Articles.tsx index b8f3cdd..13519fd 100644 --- a/src/views/home/articles/Articles.tsx +++ b/src/views/home/articles/Articles.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { SecondaryButton } from '../../../components/button/SecondaryButton'; -import { useAppDispatch } from '../../../redux/hooks'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import ArticleItem from './ArticleItem'; import { setMenuActivePage } from '../../../redux/slices/store'; import { useNavigate } from 'react-router-dom'; +import { fetchArticles } from '../../../redux/slices/articles'; export interface Article { id: number; @@ -15,123 +16,16 @@ const Articles = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const articles: Article[] = [ - { - id: 1, - name: 'Todo List App', - tags: ['Sertificated', 'state', 'list'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - { - id: 2, - name: 'Search Filter Component', - tags: ['filter', 'props', 'hooks'], - }, - { - id: 3, - name: 'User Card List', - tags: ['components', 'props', 'array'], - }, - { - id: 4, - name: 'Theme Switcher', - tags: ['Sertificated', 'theme', 'hooks'], - }, - ]; + const articles = useAppSelector((state) => state.articles.articles); + const status = useAppSelector((state) => state.articles.statuses.fetchAll); useEffect(() => { dispatch(setMenuActivePage('articles')); + dispatch(fetchArticles({})); }, []); + if (status == 'loading') return
Загрузка...
; + return (