diff --git a/.eslintrc.js b/.eslintrc.js index 69ab8a6..0b5fee5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -43,7 +43,7 @@ module.exports = { 'error', { markupOnly: true, - ignoreAttribute: ['data-testid', 'to'], + ignoreAttribute: ['data-testid', 'to', 'target'], }, ], 'max-len': ['error', { ignoreComments: true, code: 150 }], diff --git a/json-server/db.json b/json-server/db.json index 8eabb8e..a27ff04 100644 --- a/json-server/db.json +++ b/json-server/db.json @@ -14,10 +14,10 @@ "articles": [ { "id": "1", - "title": "Javascript news", + "title": "Javascript news ...", "subtitle": "Что нового в JS за 2024 год?", "img": "https://teknotower.com/wp-content/uploads/2020/11/js.png", - "views": 1022, + "views": 805, "createdAt": "26.02.2022", "userId": "1", "type": [ @@ -89,8 +89,8 @@ "title": "Python news", "subtitle": "Что нового в Python за 2024 год?", "img": "https://micros.uz/upload/iblock/85a/nwx4nuozlvjg8a4h57kqtpdqginf5ovg/python_programming_language.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 750, + "createdAt": "26.02.2023", "userId": "1", "type": [ "IT" @@ -113,8 +113,8 @@ "title": "Kotlin news", "subtitle": "Что нового в Kotlin за 2024 год?", "img": "https://maxilect.ru/wp-content/uploads/sites/1/2021/11/c344c59f81efa338f5dd4324a8411696.jpeg", - "views": 1022, - "createdAt": "26.02.2022", + "views": 850, + "createdAt": "26.02.2021", "userId": "1", "type": [ "IT" @@ -137,8 +137,8 @@ "title": "Python news", "subtitle": "Что нового в Python за 2024 год?", "img": "https://datascientest.com/en/wp-content/uploads/sites/9/2024/02/python.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 456, + "createdAt": "26.08.2022", "userId": "1", "type": [ "IT" @@ -161,8 +161,8 @@ "title": "Kotlin news", "subtitle": "Что нового в JS за 2024 год?", "img": "https://maspex.com/wp-content/uploads/2021/05/MAS2MA-PL-WWW-KOMPRO-KOTLIN-870X755PX-V02-870x755.jpg", - "views": 1022, - "createdAt": "26.02.2022", + "views": 20, + "createdAt": "26.12.2022", "userId": "1", "type": [ "IT" @@ -185,8 +185,8 @@ "title": "Python news", "subtitle": "Что нового в Python за 2024 год?", "img": "https://dlacademy.ru/media/blog/image_7.jpg", - "views": 1022, - "createdAt": "26.02.2022", + "views": 74, + "createdAt": "17.02.2024", "userId": "1", "type": [ "IT" @@ -209,8 +209,8 @@ "title": "Kotlin news", "subtitle": "Что нового в Kotlin за 2024 год?", "img": "https://habrastorage.org/getpro/habr/upload_files/784/040/572/784040572c499a4b59cbf7ce8d06e31e.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 898, + "createdAt": "26.01.2023", "userId": "1", "type": [ "IT" @@ -233,8 +233,8 @@ "title": "Python news", "subtitle": "Что нового в Python за 2024 год?", "img": "https://datascientest.com/en/wp-content/uploads/sites/9/2024/02/python.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 636, + "createdAt": "01.02.2024", "userId": "1", "type": [ "IT" @@ -257,8 +257,8 @@ "title": "Kotlin news", "subtitle": "Что нового в Kotlin за 2024 год?", "img": "https://habrastorage.org/getpro/habr/upload_files/784/040/572/784040572c499a4b59cbf7ce8d06e31e.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 225, + "createdAt": "18.04.2024", "userId": "1", "type": [ "IT" @@ -281,8 +281,8 @@ "title": "Python news", "subtitle": "Что нового в JS за 2024 год?", "img": "https://datascientest.com/en/wp-content/uploads/sites/9/2024/02/python.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 21, + "createdAt": "25.02.2022", "userId": "1", "type": [ "IT" @@ -305,8 +305,8 @@ "title": "Kotlin news", "subtitle": "Что нового в JS за 2024 год?", "img": "https://habrastorage.org/getpro/habr/upload_files/784/040/572/784040572c499a4b59cbf7ce8d06e31e.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 321, + "createdAt": "08.02.2022", "userId": "1", "type": [ "IT" @@ -329,8 +329,8 @@ "title": "Python news", "subtitle": "Что нового в JS за 2024 год?", "img": "https://datascientest.com/en/wp-content/uploads/sites/9/2024/02/python.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 889, + "createdAt": "26.06.2022", "userId": "1", "type": [ "IT" @@ -353,8 +353,8 @@ "title": "Kotlin news", "subtitle": "Что нового в JS за 2024 год?", "img": "https://habrastorage.org/getpro/habr/upload_files/784/040/572/784040572c499a4b59cbf7ce8d06e31e.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 864, + "createdAt": "08.02.2024", "userId": "1", "type": [ "IT" @@ -378,7 +378,7 @@ "subtitle": "Что нового в JS за 2024 год?", "img": "https://datascientest.com/en/wp-content/uploads/sites/9/2024/02/python.png", "views": 1022, - "createdAt": "26.02.2022", + "createdAt": "27.12.2024", "userId": "1", "type": [ "IT" @@ -401,8 +401,8 @@ "title": "Kotlin news", "subtitle": "Что нового в JS за 2024 год?", "img": "https://habrastorage.org/getpro/habr/upload_files/784/040/572/784040572c499a4b59cbf7ce8d06e31e.png", - "views": 1022, - "createdAt": "26.02.2022", + "views": 877, + "createdAt": "05.02.2023", "userId": "1", "type": [ "IT" diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 82da6ab..b585740 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -22,12 +22,25 @@ "incorrect_username_or_password": "Incorrect username or password", "profile_page": "Profile page", "error_loading_an_article": "Error loading an article", - "error_loading_articles_page": "Error loading the articles page", + "articles_not_found": "Articles not found", "article_not_found": "Article not found", "comments": "Comments", + "we_recommend": "We recommend", "start_of_discussion": "Start of discussion", "send": "Send", "enter_comment_text": "Enter comment text", "read_more": "Read more...", - "go_back": "Go back" + "go_back": "Go back", + "search": "Search", + "sort_software": "Sort Software", + "by": "by", + "ascending": "ascending", + "descending": "descending", + "date_of_creation": "date of creation", + "name": "name", + "views": "views", + "all_articles": "All Articles", + "it": "IT", + "economics": "Economy", + "science": "Science" } \ No newline at end of file diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 45f77c0..1e17b22 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -22,12 +22,25 @@ "incorrect_username_or_password": "Неверное имя пользователя или пароль", "profile_page": "Страница профиля", "error_loading_an_article": "Ошибка при загрузке статьи", - "error_loading_articles_page": "Ошибка при загрузке страницы статей", + "articles_not_found": "Статьи не найдены", "article_not_found": "Статья не найдена", "comments": "Комментарии", + "we_recommend": "Мы рекомендуем", "start_of_discussion": "Начало обсуждения", "send": "Отправить", "enter_comment_text": "Введите текст комментария", "read_more": "Читать далее...", - "go_back": "Вернуться назад" + "go_back": "Вернуться назад", + "search": "Поиск", + "sort_software": "Сортировать по", + "by": "по", + "ascending": "возрастанию", + "descending": "убыванию", + "date_of_creation": "дате создания", + "name": "названию", + "views": "просмотрам", + "all_articles": "Все статьи", + "it": "IT", + "economics": "Экономика", + "science": "Наука" } \ No newline at end of file diff --git a/src/app/providers/StoreProvider/config/StateSchema.ts b/src/app/providers/StoreProvider/config/StateSchema.ts index 0dfc6c3..b91bd02 100644 --- a/src/app/providers/StoreProvider/config/StateSchema.ts +++ b/src/app/providers/StoreProvider/config/StateSchema.ts @@ -8,7 +8,7 @@ import { CombinedState } from 'redux'; import { ProfileSchema } from 'entities/Profile'; import { AxiosInstance } from 'axios'; import { ArticleDetailsSchema } from 'entities/Article'; -import { ArticleDetailsCommentsSchema } from 'pages/ArticleDetailsPage'; +import { ArticleDetailsPageSchema } from 'pages/ArticleDetailsPage'; import { AddCommentFormSchema } from 'features/addCommentForm'; import { ArticlesPageSchema } from 'pages/ArticlesPage'; import { UISchema } from 'features/UI'; @@ -22,9 +22,9 @@ export interface StateSchema { loginForm?: LoginSchema; profile?: ProfileSchema; articleDetails?: ArticleDetailsSchema; - articleDetailsComments?: ArticleDetailsCommentsSchema; addCommentForm?: AddCommentFormSchema; articlesPage?: ArticlesPageSchema; + articleDetailsPage?: ArticleDetailsPageSchema; } export type StateSchemaKey = keyof StateSchema; diff --git a/src/app/styles/index.scss b/src/app/styles/index.scss index 048515e..04bb0ae 100644 --- a/src/app/styles/index.scss +++ b/src/app/styles/index.scss @@ -18,3 +18,18 @@ body { display: flex; } +*::-webkit-scrollbar { + width: 12px; /* ширина scrollbar */ + height: 8px; +} + +*::-webkit-scrollbar-track { + background: var(--card-bg); /* цвет дорожки */ +} + +*::-webkit-scrollbar-thumb { + background-color: var(--inverted-primary-color); /* цвет плашки */ + border-radius: 20px; /* закругления плашки */ + border: 2px solid var(--primary-color); /* padding вокруг плашки */ +} + diff --git a/src/app/styles/reset.scss b/src/app/styles/reset.scss index 4054425..3bbfd08 100644 --- a/src/app/styles/reset.scss +++ b/src/app/styles/reset.scss @@ -11,3 +11,7 @@ select { margin: 0; font: inherit; } + +a { + text-decoration: none; +} diff --git a/src/entities/Article/index.ts b/src/entities/Article/index.ts index b337cd0..93727b1 100644 --- a/src/entities/Article/index.ts +++ b/src/entities/Article/index.ts @@ -2,8 +2,12 @@ export { ArticleDetails, } from './ui/ArticleDetails/ArticleDetails'; -export { Article, ArticleView } from './model/types/article'; +export { + Article, ArticleView, ArticleSortField, ArticleType, +} from './model/types/article'; export type { ArticleDetailsSchema } from './model/types/articleDetailsSchema'; export { ArticleList } from './ui/ArticleList/ArticleList'; export { ArticleViewSelector } from './ui/ArticleViewSelector/ArticleViewSelector'; +export { ArticleSortSelector } from './ui/ArticleSortSelector/ArticleSortSelector'; +export { ArticleTypeTabs } from './ui/ArticleTypeTabs/ArticleTypeTabs'; diff --git a/src/entities/Article/model/types/article.ts b/src/entities/Article/model/types/article.ts index 5355b29..c9fbc18 100644 --- a/src/entities/Article/model/types/article.ts +++ b/src/entities/Article/model/types/article.ts @@ -1,5 +1,11 @@ import { User } from 'entities/User'; +export enum ArticleSortField { + VIEWS = 'views', + TITLE = 'title', + CREATED = 'createdAt', +} + export enum ArticleBlockType { CODE = 'CODE', IMAGE = 'IMAGE', @@ -31,6 +37,7 @@ export interface ArticleTextBlock extends ArticleBlockBase { export type ArticleBlock = ArticleCodeBlock | ArticleImageBlock | ArticleTextBlock; export enum ArticleType { + ALL = 'ALL', IT = 'IT', SCIENCE = 'SCIENCE', ECONOMICS = 'ECONOMICS' diff --git a/src/entities/Article/ui/ArticleList/ArticleList.module.scss b/src/entities/Article/ui/ArticleList/ArticleList.module.scss index 2205caa..9114423 100644 --- a/src/entities/Article/ui/ArticleList/ArticleList.module.scss +++ b/src/entities/Article/ui/ArticleList/ArticleList.module.scss @@ -7,9 +7,5 @@ .TILE { display: flex; flex-wrap: wrap; - - .card { - margin-right: 30px; - margin-bottom: 30px; - } + gap: 30px; } diff --git a/src/entities/Article/ui/ArticleList/ArticleList.tsx b/src/entities/Article/ui/ArticleList/ArticleList.tsx index ac3d6a4..083f161 100644 --- a/src/entities/Article/ui/ArticleList/ArticleList.tsx +++ b/src/entities/Article/ui/ArticleList/ArticleList.tsx @@ -1,7 +1,8 @@ import { classNames } from 'shared/lib/classNames/classNames'; import { useTranslation } from 'react-i18next'; -import { memo } from 'react'; +import { HTMLAttributeAnchorTarget, memo } from 'react'; import { ArticleListItemSkeleton } from 'entities/Article/ui/ArticleListItem/ArticleListItemSkeleton'; +import { Text, TextSize } from 'shared/ui/Text/Text'; import { ArticleListItem } from '../ArticleListItem/ArticleListItem'; import cls from './ArticleList.module.scss'; import { Article, ArticleView } from '../../model/types/article'; @@ -10,6 +11,7 @@ interface ArticleListProps { className?: string; articles: Article[] isLoading?: boolean; + target?: HTMLAttributeAnchorTarget; view?: ArticleView; } @@ -24,6 +26,7 @@ export const ArticleList = memo((props: ArticleListProps) => { className, articles, isLoading, + target, view = ArticleView.TILE, } = props; const { t } = useTranslation(); @@ -34,9 +37,21 @@ export const ArticleList = memo((props: ArticleListProps) => { className={cls.card} article={article} view={view} + target={target} /> ); + if (!isLoading && !articles.length) { + return ( +
+ +
+ ); + } + return (
{articles.length > 0 diff --git a/src/entities/Article/ui/ArticleListItem/ArticleListItem.tsx b/src/entities/Article/ui/ArticleListItem/ArticleListItem.tsx index 7f376be..df0720f 100644 --- a/src/entities/Article/ui/ArticleListItem/ArticleListItem.tsx +++ b/src/entities/Article/ui/ArticleListItem/ArticleListItem.tsx @@ -1,14 +1,14 @@ import { classNames } from 'shared/lib/classNames/classNames'; import { useTranslation } from 'react-i18next'; -import { memo, useCallback } from 'react'; +import { HTMLAttributeAnchorTarget, memo } from 'react'; import { Text } from 'shared/ui/Text/Text'; import { Icon } from 'shared/ui/Icon/Icon'; import EyeIcon from 'shared/assets/icon/eye-20-20.svg'; import { Card } from 'shared/ui/Card/Card'; import { Avatar } from 'shared/ui/Avatar/Avatar'; import { Button, ButtonTheme } from 'shared/ui/Button/Button'; -import { useNavigate } from 'react-router-dom'; import { RoutePath } from 'shared/config/routeConfig/routeConfig'; +import { AppLink } from 'shared/ui/AppLink/AppLink'; import cls from './ArticleListItem.module.scss'; import { Article, ArticleBlockType, ArticleTextBlock, ArticleView, @@ -18,17 +18,18 @@ import { ArticleTextBlockComponent } from '../ArticleTextBlockComponent/ArticleT interface ArticleListItemProps { className?: string; article: Article; + target?: HTMLAttributeAnchorTarget; view: ArticleView; } export const ArticleListItem = memo((props: ArticleListItemProps) => { - const { className, article, view } = props; + const { + className, + article, + target, + view, + } = props; const { t } = useTranslation(); - const navigate = useNavigate(); - - const onOpenArticle = useCallback(() => { - navigate(RoutePath.article_details + article.id); - }, [article.id, navigate]); const types = ; const views = ( @@ -58,9 +59,14 @@ export const ArticleListItem = memo((props: ArticleListItemProps) => { )}
- + + + {views}
@@ -69,8 +75,12 @@ export const ArticleListItem = memo((props: ArticleListItemProps) => { } return ( -
- + +
{article.title} @@ -81,6 +91,6 @@ export const ArticleListItem = memo((props: ArticleListItemProps) => {
-
+ ); }); diff --git a/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.module.scss b/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.module.scss new file mode 100644 index 0000000..2944e27 --- /dev/null +++ b/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.module.scss @@ -0,0 +1,8 @@ +.ArticleSortSelector { + display: flex; + align-items: center; +} + +.order { + margin-left: 8px; +} diff --git a/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.stories.tsx b/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.stories.tsx new file mode 100644 index 0000000..f7b9356 --- /dev/null +++ b/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { ArticleSortSelector } from './ArticleSortSelector'; + +export default { + title: 'entities/Article/ArticleSortSelector', + component: ArticleSortSelector, + argTypes: { + backgroundColor: { control: 'color' }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Normal = Template.bind({}); +Normal.args = {}; diff --git a/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.tsx b/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.tsx new file mode 100644 index 0000000..b654b8a --- /dev/null +++ b/src/entities/Article/ui/ArticleSortSelector/ArticleSortSelector.tsx @@ -0,0 +1,70 @@ +import { classNames } from 'shared/lib/classNames/classNames'; +import { useTranslation } from 'react-i18next'; +import { memo, useMemo } from 'react'; +import { Select, SelectOption } from 'shared/ui/Select/Select'; +import { ArticleSortField } from 'entities/Article/model/types/article'; +import { SortOrder } from 'shared/types'; +import cls from './ArticleSortSelector.module.scss'; + +interface ArticleSortSelectorProps { + className?: string; + order: SortOrder; + sort: ArticleSortField; + onChangeOrder: (newOrder: SortOrder) => void; + onChangeSort: (newSort: ArticleSortField) => void; +} + +export const ArticleSortSelector = memo((props: ArticleSortSelectorProps) => { + const { + className, + onChangeOrder, + onChangeSort, + order, + sort, + } = props; + const { t } = useTranslation(); + + const orderOptions = useMemo[]>(() => [ + { + value: 'asc', + content: t('ascending'), + }, + { + value: 'desc', + content: t('descending'), + }, + ], [t]); + + const sortFieldOptions = useMemo[]>(() => [ + { + value: ArticleSortField.CREATED, + content: t('date_of_creation'), + }, + { + value: ArticleSortField.TITLE, + content: t('name'), + }, + { + value: ArticleSortField.VIEWS, + content: t('views'), + }, + ], [t]); + + return ( +
+ +
+ ); +}); diff --git a/src/entities/Article/ui/ArticleTypeTabs/ArticleTypeTabs.stories.tsx b/src/entities/Article/ui/ArticleTypeTabs/ArticleTypeTabs.stories.tsx new file mode 100644 index 0000000..330ab98 --- /dev/null +++ b/src/entities/Article/ui/ArticleTypeTabs/ArticleTypeTabs.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import { ArticleTypeTabs } from './ArticleTypeTabs'; + +export default { + title: 'entities/Article/ArticleTypeTabs', + component: ArticleTypeTabs, + argTypes: { + backgroundColor: { control: 'color' }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Normal = Template.bind({}); +Normal.args = {}; diff --git a/src/entities/Article/ui/ArticleTypeTabs/ArticleTypeTabs.tsx b/src/entities/Article/ui/ArticleTypeTabs/ArticleTypeTabs.tsx new file mode 100644 index 0000000..6d8a379 --- /dev/null +++ b/src/entities/Article/ui/ArticleTypeTabs/ArticleTypeTabs.tsx @@ -0,0 +1,53 @@ +import { classNames } from 'shared/lib/classNames/classNames'; +import { useTranslation } from 'react-i18next'; +import { memo, useCallback, useMemo } from 'react'; +import { TabItem, Tabs } from 'shared/ui/Tabs/Tabs'; +import { ArticleType } from 'entities/Article'; + +interface ArticleTypeTabsProps { + className?: string; + value: ArticleType; + onChangeType: (type: ArticleType) => void; +} + +export const ArticleTypeTabs = memo((props: ArticleTypeTabsProps) => { + const { + className, + value, + onChangeType, + } = props; + const { t } = useTranslation(); + + // TODO: Когда будет больше типов, переделать в цикл + const typeTabs = useMemo(() => [ + { + value: ArticleType.ALL, + content: t('all_articles'), + }, + { + value: ArticleType.IT, + content: t('it'), + }, + { + value: ArticleType.ECONOMICS, + content: t('economics'), + }, + { + value: ArticleType.SCIENCE, + content: t('science'), + }, + ], [t]); + + const onTabClick = useCallback((tab: TabItem) => { + onChangeType(tab.value as ArticleType); + }, [onChangeType]); + + return ( + + ); +}); diff --git a/src/pages/ArticleDetailsPage/index.ts b/src/pages/ArticleDetailsPage/index.ts index a958908..9fd9162 100644 --- a/src/pages/ArticleDetailsPage/index.ts +++ b/src/pages/ArticleDetailsPage/index.ts @@ -1,5 +1,7 @@ export { ArticleDetailsPageLazy as ArticleDetailsPage, -} from 'pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.lazy'; +} from './ui/ArticleDetailsPage/ArticleDetailsPage.lazy'; -export { ArticleDetailsCommentsSchema } from 'pages/ArticleDetailsPage/model/types/ArticleDetailsCommentsSchema'; +export { ArticleDetailsCommentsSchema } from './model/types/ArticleDetailsCommentsSchema'; +export { ArticleDetailsRecommendationsSchema } from './model/types/ArticleDetailsRecommendationsSchema'; +export { ArticleDetailsPageSchema } from './model/types'; diff --git a/src/pages/ArticleDetailsPage/model/selectors/comments.ts b/src/pages/ArticleDetailsPage/model/selectors/comments.ts index e04070a..da9a625 100644 --- a/src/pages/ArticleDetailsPage/model/selectors/comments.ts +++ b/src/pages/ArticleDetailsPage/model/selectors/comments.ts @@ -1,4 +1,5 @@ import { StateSchema } from 'app/providers/StoreProvider'; -export const getArticleCommentsIsLoading = (state: StateSchema) => state.articleDetailsComments?.isLoading; -export const getArticleCommentsError = (state: StateSchema) => state.articleDetailsComments?.error; +export const getArticleCommentsIsLoading = (state: StateSchema) => state.articleDetailsPage?.comments?.isLoading; + +export const getArticleCommentsError = (state: StateSchema) => state.articleDetailsPage?.comments?.error; diff --git a/src/pages/ArticleDetailsPage/model/selectors/recommendations.ts b/src/pages/ArticleDetailsPage/model/selectors/recommendations.ts new file mode 100644 index 0000000..5a58006 --- /dev/null +++ b/src/pages/ArticleDetailsPage/model/selectors/recommendations.ts @@ -0,0 +1,5 @@ +import { StateSchema } from 'app/providers/StoreProvider'; + +export const getArticleRecommendationsIsLoading = (state: StateSchema) => state.articleDetailsPage?.recommendations?.isLoading; + +export const getArticleRecommendationsError = (state: StateSchema) => state.articleDetailsPage?.recommendations?.error; diff --git a/src/pages/ArticleDetailsPage/model/services/fetchArticleRecommendations/fetchArticleRecommendations.ts b/src/pages/ArticleDetailsPage/model/services/fetchArticleRecommendations/fetchArticleRecommendations.ts new file mode 100644 index 0000000..9631219 --- /dev/null +++ b/src/pages/ArticleDetailsPage/model/services/fetchArticleRecommendations/fetchArticleRecommendations.ts @@ -0,0 +1,30 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from 'app/providers/StoreProvider'; +import { Article } from 'entities/Article'; + +export const fetchArticleRecommendations = createAsyncThunk< + Article[], + void, + ThunkConfig +>( + 'articleDetailsPage/fetchArticleRecommendations', + async (props, thunkApi) => { + const { extra, rejectWithValue } = thunkApi; + + try { + const response = await extra.api.get('/articles', { + params: { + _limit: 4, + }, + }); + + if (!response.data) { + throw new Error(); + } + + return response.data; + } catch (e) { + return rejectWithValue('error'); + } + }, +); diff --git a/src/pages/ArticleDetailsPage/model/slices/articleDetailsCommentsSlice.ts b/src/pages/ArticleDetailsPage/model/slices/articleDetailsCommentsSlice.ts index 6effc6c..338ef18 100644 --- a/src/pages/ArticleDetailsPage/model/slices/articleDetailsCommentsSlice.ts +++ b/src/pages/ArticleDetailsPage/model/slices/articleDetailsCommentsSlice.ts @@ -1,20 +1,18 @@ -import { - createEntityAdapter, - createSlice, PayloadAction, -} from '@reduxjs/toolkit'; +import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { Comment } from 'entities/Comment'; import { StateSchema } from 'app/providers/StoreProvider'; import { fetchCommentsByArticleId, -} from 'pages/ArticleDetailsPage/model/services/fetchCommentsByArticleId/fetchCommentsByArticleId'; -import { ArticleDetailsCommentsSchema } from 'pages/ArticleDetailsPage/model/types/ArticleDetailsCommentsSchema'; +} from '../../model/services/fetchCommentsByArticleId/fetchCommentsByArticleId'; +import { ArticleDetailsCommentsSchema } from '../types/ArticleDetailsCommentsSchema'; const commentsAdapter = createEntityAdapter({ selectId: (comment) => comment.id, }); export const getArticleComments = commentsAdapter.getSelectors( - (state) => state.articleDetailsComments || commentsAdapter.getInitialState(), + (state) => state.articleDetailsPage?.comments || commentsAdapter.getInitialState(), ); const articleDetailsCommentsSlice = createSlice({ @@ -22,7 +20,7 @@ const articleDetailsCommentsSlice = createSlice({ initialState: commentsAdapter.getInitialState({ isLoading: false, error: undefined, - ids: ['1', '2'], + ids: [], entities: {}, }), reducers: {}, diff --git a/src/pages/ArticleDetailsPage/model/slices/articleDetailsPageRecommendationsSlice.ts b/src/pages/ArticleDetailsPage/model/slices/articleDetailsPageRecommendationsSlice.ts new file mode 100644 index 0000000..cd97ce7 --- /dev/null +++ b/src/pages/ArticleDetailsPage/model/slices/articleDetailsPageRecommendationsSlice.ts @@ -0,0 +1,46 @@ +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; +import { StateSchema } from 'app/providers/StoreProvider'; +import { Article } from 'entities/Article'; +import { fetchArticleRecommendations } from '../services/fetchArticleRecommendations/fetchArticleRecommendations'; +import { ArticleDetailsRecommendationsSchema } from '../types/ArticleDetailsRecommendationsSchema'; + +const recommendationsAdapter = createEntityAdapter
({ + selectId: (article) => article.id, +}); + +export const getArticleRecommendations = recommendationsAdapter.getSelectors( + (state) => state.articleDetailsPage?.recommendations || recommendationsAdapter.getInitialState(), +); + +const articleDetailsPageRecommendationsSlice = createSlice({ + name: 'articleDetailsPageRecommendationsSlice', + initialState: recommendationsAdapter.getInitialState({ + isLoading: false, + error: undefined, + ids: [], + entities: {}, + }), + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchArticleRecommendations.pending, (state) => { + state.error = undefined; + state.isLoading = true; + }) + .addCase(fetchArticleRecommendations.fulfilled, ( + state, + action, + ) => { + state.isLoading = false; + recommendationsAdapter.setAll(state, action.payload); + }) + .addCase(fetchArticleRecommendations.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }); + }, +}); + +export const { + reducer: articleDetailsPageRecommendationsReducer, +} = articleDetailsPageRecommendationsSlice; diff --git a/src/pages/ArticleDetailsPage/model/slices/index.ts b/src/pages/ArticleDetailsPage/model/slices/index.ts new file mode 100644 index 0000000..6b22384 --- /dev/null +++ b/src/pages/ArticleDetailsPage/model/slices/index.ts @@ -0,0 +1,11 @@ +import { combineReducers } from '@reduxjs/toolkit'; +import { ArticleDetailsPageSchema } from '../types'; +import { + articleDetailsPageRecommendationsReducer, +} from './articleDetailsPageRecommendationsSlice'; +import { articleDetailsCommentsReducer } from './articleDetailsCommentsSlice'; + +export const articleDetailsPageReducer = combineReducers({ + recommendations: articleDetailsPageRecommendationsReducer, + comments: articleDetailsCommentsReducer, +}); diff --git a/src/pages/ArticleDetailsPage/model/types/ArticleDetailsRecommendationsSchema.ts b/src/pages/ArticleDetailsPage/model/types/ArticleDetailsRecommendationsSchema.ts new file mode 100644 index 0000000..c8c7d78 --- /dev/null +++ b/src/pages/ArticleDetailsPage/model/types/ArticleDetailsRecommendationsSchema.ts @@ -0,0 +1,7 @@ +import { EntityState } from '@reduxjs/toolkit'; +import { Article } from 'entities/Article'; + +export interface ArticleDetailsRecommendationsSchema extends EntityState
{ + isLoading?: boolean; + error?: string; +} diff --git a/src/pages/ArticleDetailsPage/model/types/index.ts b/src/pages/ArticleDetailsPage/model/types/index.ts new file mode 100644 index 0000000..7895b4c --- /dev/null +++ b/src/pages/ArticleDetailsPage/model/types/index.ts @@ -0,0 +1,7 @@ +import { ArticleDetailsCommentsSchema } from './ArticleDetailsCommentsSchema'; +import { ArticleDetailsRecommendationsSchema } from './ArticleDetailsRecommendationsSchema'; + +export interface ArticleDetailsPageSchema { + comments: ArticleDetailsCommentsSchema; + recommendations: ArticleDetailsRecommendationsSchema; +} diff --git a/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.module.scss b/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.module.scss index 62505f4..8d04a20 100644 --- a/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.module.scss +++ b/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.module.scss @@ -5,3 +5,10 @@ .commentTitle { margin-top: 20px; } + +.recommendations { + margin-top: 20px; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; +} diff --git a/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.tsx b/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.tsx index a4dbf7a..6586a29 100644 --- a/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.tsx +++ b/src/pages/ArticleDetailsPage/ui/ArticleDetailsPage/ArticleDetailsPage.tsx @@ -1,9 +1,9 @@ import { classNames } from 'shared/lib/classNames/classNames'; import { useTranslation } from 'react-i18next'; import { memo, useCallback } from 'react'; -import { ArticleDetails } from 'entities/Article'; +import { ArticleDetails, ArticleList } from 'entities/Article'; import { useNavigate, useParams } from 'react-router-dom'; -import { Text } from 'shared/ui/Text/Text'; +import { Text, TextSize } from 'shared/ui/Text/Text'; import { CommentList } from 'entities/Comment'; import { DynamicModuleLoader, ReducersList } from 'shared/lib/components/DynamicModuleLoader/DynamicModuleLoader'; import { useSelector } from 'react-redux'; @@ -13,8 +13,14 @@ import { AddCommentForm } from 'features/addCommentForm'; import { Button, ButtonTheme } from 'shared/ui/Button/Button'; import { RoutePath } from 'shared/config/routeConfig/routeConfig'; import { Page } from 'widgets/Page/Page'; +import { articleDetailsPageReducer } from '../../model/slices'; +import { getArticleRecommendations } from '../../model/slices/articleDetailsPageRecommendationsSlice'; +import { getArticleRecommendationsIsLoading } from '../../model/selectors/recommendations'; +import { + fetchArticleRecommendations, +} from '../../model/services/fetchArticleRecommendations/fetchArticleRecommendations'; import { fetchCommentsByArticleId } from '../../model/services/fetchCommentsByArticleId/fetchCommentsByArticleId'; -import { articleDetailsCommentsReducer, getArticleComments } from '../../model/slices/articleDetailsCommentsSlice'; +import { getArticleComments } from '../../model/slices/articleDetailsCommentsSlice'; import { addCommentForArticle } from '../../model/services/addCommentForArticle/addCommentForArticle'; import { getArticleCommentsIsLoading } from '../../model/selectors/comments'; import cls from './ArticleDetailsPage.module.scss'; @@ -24,7 +30,7 @@ interface ArticleDetailsPageProps { } const reducers: ReducersList = { - articleDetailsComments: articleDetailsCommentsReducer, + articleDetailsPage: articleDetailsPageReducer, }; const ArticleDetailsPage = (props: ArticleDetailsPageProps) => { @@ -33,6 +39,8 @@ const ArticleDetailsPage = (props: ArticleDetailsPageProps) => { const { id } = useParams<{ id: string }>(); const dispatch = useAppDispatch(); const comments = useSelector(getArticleComments.selectAll); + const recommendations = useSelector(getArticleRecommendations.selectAll); + const recommendationsIsLoading = useSelector(getArticleRecommendationsIsLoading); const commentsIsLoading = useSelector(getArticleCommentsIsLoading); const navigate = useNavigate(); @@ -46,6 +54,7 @@ const ArticleDetailsPage = (props: ArticleDetailsPageProps) => { useInitialEffect(() => { dispatch(fetchCommentsByArticleId(id)); + dispatch(fetchArticleRecommendations()); }); if (!id) { @@ -57,16 +66,28 @@ const ArticleDetailsPage = (props: ArticleDetailsPageProps) => { } return ( - + - - + + + state.articlesPage?.isLoading || false; export const getArticlesPageError = (state: StateSchema) => state.articlesPage?.error; @@ -8,3 +8,7 @@ export const getArticlesPageNum = (state: StateSchema) => state.articlesPage?.pa export const getArticlesPageLimit = (state: StateSchema) => state.articlesPage?.limit || 9; export const getArticlesPageHasMore = (state: StateSchema) => state.articlesPage?.hasMore; export const getArticlesPageInited = (state: StateSchema) => state.articlesPage?._inited; +export const getArticlesPageOrder = (state: StateSchema) => state.articlesPage?.order ?? 'asc'; +export const getArticlesPageSort = (state: StateSchema) => state.articlesPage?.sort ?? ArticleSortField.CREATED; +export const getArticlesPageSearch = (state: StateSchema) => state.articlesPage?.search ?? ''; +export const getArticlesPageType = (state: StateSchema) => state.articlesPage?.type ?? ArticleType.ALL; diff --git a/src/pages/ArticlesPage/model/services/fetchArticlesList/fetchArticlesList.ts b/src/pages/ArticlesPage/model/services/fetchArticlesList/fetchArticlesList.ts index cd6335d..853ba9f 100644 --- a/src/pages/ArticlesPage/model/services/fetchArticlesList/fetchArticlesList.ts +++ b/src/pages/ArticlesPage/model/services/fetchArticlesList/fetchArticlesList.ts @@ -1,10 +1,18 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { ThunkConfig } from 'app/providers/StoreProvider'; -import { Article } from 'entities/Article'; -import { getArticlesPageLimit } from 'pages/ArticlesPage/model/selectors/articlesPageSelectors'; +import { Article, ArticleType } from 'entities/Article'; +import { addQueryParams } from 'shared/lib/url/addQueryParams/addQueryParams'; +import { + getArticlesPageLimit, + getArticlesPageNum, + getArticlesPageOrder, + getArticlesPageSearch, + getArticlesPageSort, + getArticlesPageType, +} from '../../selectors/articlesPageSelectors'; interface FetchArticlesListProps { - page?: number; + replace?: boolean; } export const fetchArticlesList = createAsyncThunk< @@ -15,15 +23,26 @@ export const fetchArticlesList = createAsyncThunk< 'articlesPage/fetchArticlesList', async (props, thunkApi) => { const { extra, rejectWithValue, getState } = thunkApi; - const { page = 1 } = props; const limit = getArticlesPageLimit(getState()); + const sort = getArticlesPageSort(getState()); + const order = getArticlesPageOrder(getState()); + const search = getArticlesPageSearch(getState()); + const page = getArticlesPageNum(getState()); + const type = getArticlesPageType(getState()); try { + addQueryParams({ + order, sort, search, type, + }); const response = await extra.api.get('/articles', { params: { _expand: 'user', _page: page, _limit: limit, + _order: order, + _sort: sort, + q: search, + type: type === ArticleType.ALL ? undefined : type, }, }); diff --git a/src/pages/ArticlesPage/model/services/fetchNextArticlesPage/fetchNextArticlesPage.ts b/src/pages/ArticlesPage/model/services/fetchNextArticlesPage/fetchNextArticlesPage.ts index 5111548..118453e 100644 --- a/src/pages/ArticlesPage/model/services/fetchNextArticlesPage/fetchNextArticlesPage.ts +++ b/src/pages/ArticlesPage/model/services/fetchNextArticlesPage/fetchNextArticlesPage.ts @@ -22,9 +22,7 @@ export const fetchNextArticlesPage = createAsyncThunk< if (hasMore && !isLoading) { dispatch(articlesPageActions.setPage(page + 1)); - dispatch(fetchArticlesList({ - page: page + 1, - })); + dispatch(fetchArticlesList({})); } }, ); diff --git a/src/pages/ArticlesPage/model/services/initArticlesPage/initArticlesPage.ts b/src/pages/ArticlesPage/model/services/initArticlesPage/initArticlesPage.ts index 7c9db99..f8d1a45 100644 --- a/src/pages/ArticlesPage/model/services/initArticlesPage/initArticlesPage.ts +++ b/src/pages/ArticlesPage/model/services/initArticlesPage/initArticlesPage.ts @@ -1,24 +1,42 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { ThunkConfig } from 'app/providers/StoreProvider'; +import { ArticleSortField, ArticleType } from 'entities/Article'; +import { SortOrder } from 'shared/types'; import { getArticlesPageInited } from '../../selectors/articlesPageSelectors'; import { articlesPageActions } from '../../slices/articlesPageSlice'; import { fetchArticlesList } from '../fetchArticlesList/fetchArticlesList'; export const initArticlesPage = createAsyncThunk< void, - void, + URLSearchParams, ThunkConfig >( 'articlesPage/initArticlesPage', - async (_, thunkApi) => { + async (searchParams, thunkApi) => { const { getState, dispatch } = thunkApi; const inited = getArticlesPageInited(getState()); if (!inited) { + const orderFromUrl = searchParams.get('order') as SortOrder; + const sortFromUrl = searchParams.get('sort') as ArticleSortField; + const searchFromUrl = searchParams.get('search'); + const typeFromUrl = searchParams.get('type') as ArticleType; + + if (orderFromUrl) { + dispatch(articlesPageActions.setOrder(orderFromUrl)); + } + if (sortFromUrl) { + dispatch(articlesPageActions.setSort(sortFromUrl)); + } + if (searchFromUrl) { + dispatch(articlesPageActions.setSearch(searchFromUrl)); + } + if (typeFromUrl) { + dispatch(articlesPageActions.setType(typeFromUrl)); + } + dispatch(articlesPageActions.initState()); - dispatch(fetchArticlesList({ - page: 1, - })); + dispatch(fetchArticlesList({})); } }, ); diff --git a/src/pages/ArticlesPage/model/slices/articlesPageSlice.ts b/src/pages/ArticlesPage/model/slices/articlesPageSlice.ts index 11529b1..9d9dcb8 100644 --- a/src/pages/ArticlesPage/model/slices/articlesPageSlice.ts +++ b/src/pages/ArticlesPage/model/slices/articlesPageSlice.ts @@ -1,8 +1,11 @@ import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { StateSchema } from 'app/providers/StoreProvider'; -import { Article, ArticleView } from 'entities/Article'; -import { ArticlesPageSchema } from 'pages/ArticlesPage'; +import { + Article, ArticleType, ArticleView, ArticleSortField, +} from 'entities/Article'; import { ARTICLES_VIEW_LOCALSTORAGE_KEY } from 'shared/const/localstorage'; +import { SortOrder } from 'shared/types'; +import { ArticlesPageSchema } from '../types/articlesPageSchema'; import { fetchArticlesList } from '../../model/services/fetchArticlesList/fetchArticlesList'; const articlesAdapter = createEntityAdapter
({ @@ -20,9 +23,14 @@ const articlesPageSlice = createSlice({ error: undefined, ids: [], entities: {}, - view: ArticleView.TILE, page: 1, + limit: 9, hasMore: true, + view: ArticleView.TILE, + order: 'asc', + sort: ArticleSortField.CREATED, + search: '', + type: ArticleType.ALL, _inited: false, }), reducers: { @@ -33,6 +41,18 @@ const articlesPageSlice = createSlice({ setPage: (state, action: PayloadAction) => { state.page = action.payload; }, + setOrder: (state, action: PayloadAction) => { + state.order = action.payload; + }, + setSort: (state, action: PayloadAction) => { + state.sort = action.payload; + }, + setType: (state, action: PayloadAction) => { + state.type = action.payload; + }, + setSearch: (state, action: PayloadAction) => { + state.search = action.payload; + }, initState: (state) => { const view = localStorage.getItem(ARTICLES_VIEW_LOCALSTORAGE_KEY) as ArticleView; state.view = view; @@ -42,17 +62,26 @@ const articlesPageSlice = createSlice({ }, extraReducers: (builder) => { builder - .addCase(fetchArticlesList.pending, (state) => { + .addCase(fetchArticlesList.pending, (state, action) => { state.error = undefined; state.isLoading = true; + + if (action.meta.arg.replace) { + articlesAdapter.removeAll(state); + } }) .addCase(fetchArticlesList.fulfilled, ( state, - action: PayloadAction, + action, ) => { state.isLoading = false; - articlesAdapter.addMany(state, action.payload); - state.hasMore = action.payload.length > 0; + state.hasMore = action.payload.length >= state.limit; + + if (action.meta.arg.replace) { + articlesAdapter.setAll(state, action.payload); + } else { + articlesAdapter.addMany(state, action.payload); + } }) .addCase(fetchArticlesList.rejected, (state, action) => { state.isLoading = false; diff --git a/src/pages/ArticlesPage/model/types/articlesPageSchema.ts b/src/pages/ArticlesPage/model/types/articlesPageSchema.ts index 3d54561..5bfd627 100644 --- a/src/pages/ArticlesPage/model/types/articlesPageSchema.ts +++ b/src/pages/ArticlesPage/model/types/articlesPageSchema.ts @@ -1,13 +1,24 @@ import { EntityState } from '@reduxjs/toolkit'; -import { Article, ArticleView } from 'entities/Article'; +import { + Article, ArticleView, ArticleSortField, ArticleType, +} from 'entities/Article'; +import { SortOrder } from 'shared/types'; export interface ArticlesPageSchema extends EntityState
{ isLoading?: boolean; error?: string; - view: ArticleView; - // for pagination + + // pagination page: number; - limit?: number; + limit: number; hasMore: boolean; + + // filters + view: ArticleView; + order: SortOrder; + sort: ArticleSortField; + search: string; + type: ArticleType; + _inited: boolean; } diff --git a/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.module.scss b/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.module.scss index 6c8a6eb..902a172 100644 --- a/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.module.scss +++ b/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.module.scss @@ -1,3 +1,7 @@ .ArticlesPage { min-height: 100%; } + +.list { + margin-top: 30px; +} diff --git a/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.stories.tsx b/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.stories.tsx index 52fb620..3f0dc1a 100644 --- a/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.stories.tsx +++ b/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.stories.tsx @@ -3,7 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'; import ArticlesPage from './ArticlesPage'; export default { - title: 'pages/ArticlesPage', + title: 'pages/Article/ArticlesPage', component: ArticlesPage, argTypes: { backgroundColor: { control: 'color' }, diff --git a/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.tsx b/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.tsx index 47cd856..e5d7016 100644 --- a/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.tsx +++ b/src/pages/ArticlesPage/ui/ArticlesPage/ArticlesPage.tsx @@ -1,22 +1,23 @@ import { classNames } from 'shared/lib/classNames/classNames'; -import { memo, useCallback } from 'react'; -import { ArticleList, ArticleView, ArticleViewSelector } from 'entities/Article'; import { useTranslation } from 'react-i18next'; -import { useAppDispatch } from 'shared/lib/hooks/useAppDispatch/useAppDispatch'; -import { useSelector } from 'react-redux'; -import { useInitialEffect } from 'shared/lib/hooks/useInitialEffect/useInitialEffect'; +import { memo, useCallback } from 'react'; +import { ArticleList } from 'entities/Article'; import { DynamicModuleLoader, ReducersList } from 'shared/lib/components/DynamicModuleLoader/DynamicModuleLoader'; +import { useInitialEffect } from 'shared/lib/hooks/useInitialEffect/useInitialEffect'; +import { useSelector } from 'react-redux'; +import { useAppDispatch } from 'shared/lib/hooks/useAppDispatch/useAppDispatch'; import { Page } from 'widgets/Page/Page'; -import { Text, TextAlign, TextTheme } from 'shared/ui/Text/Text'; -import { initArticlesPage } from '../../model/services/initArticlesPage/initArticlesPage'; +import { ArticlesPageFilters } from 'pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters'; +import { useSearchParams } from 'react-router-dom'; import { fetchNextArticlesPage } from '../../model/services/fetchNextArticlesPage/fetchNextArticlesPage'; -import { articlesPageActions, articlesPageReducer, getArticles } from '../../model/slices/articlesPageSlice'; +import { initArticlesPage } from '../../model/services/initArticlesPage/initArticlesPage'; +import { articlesPageReducer, getArticles } from '../../model/slices/articlesPageSlice'; +import cls from './ArticlesPage.module.scss'; import { getArticlesPageError, getArticlesPageIsLoading, getArticlesPageView, } from '../../model/selectors/articlesPageSelectors'; -import cls from './ArticlesPage.module.scss'; interface ArticlesPageProps { className?: string; @@ -32,46 +33,30 @@ const ArticlesPage = (props: ArticlesPageProps) => { const dispatch = useAppDispatch(); const articles = useSelector(getArticles.selectAll); const isLoading = useSelector(getArticlesPageIsLoading); - const error = useSelector(getArticlesPageError); const view = useSelector(getArticlesPageView); - - const onChangeView = useCallback((view: ArticleView) => { - dispatch(articlesPageActions.setView(view)); - }, [dispatch]); + const error = useSelector(getArticlesPageError); + const [searchParams] = useSearchParams(); const onLoadNextPart = useCallback(() => { dispatch(fetchNextArticlesPage()); }, [dispatch]); useInitialEffect(() => { - dispatch(initArticlesPage()); + dispatch(initArticlesPage(searchParams)); }); - if (error) { - return ( -
- -
- ); - } return ( - + diff --git a/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.module.scss b/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.module.scss new file mode 100644 index 0000000..de7e360 --- /dev/null +++ b/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.module.scss @@ -0,0 +1,13 @@ +.sortWrapper { + display: flex; + align-items: center; + justify-content: space-between; +} + +.search { + margin-top: 16px; +} + +.tabs { + margin-top: 16px; +} diff --git a/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.stories.tsx b/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.stories.tsx new file mode 100644 index 0000000..58a18e7 --- /dev/null +++ b/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { ArticlesPageFilters } from './ArticlesPageFilters'; + +export default { + title: 'pages/Article/ArticlesPageFilters', + component: ArticlesPageFilters, + argTypes: { + backgroundColor: { control: 'color' }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Normal = Template.bind({}); +Normal.args = {}; diff --git a/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.tsx b/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.tsx new file mode 100644 index 0000000..d04fa21 --- /dev/null +++ b/src/pages/ArticlesPage/ui/ArticlesPageFilters/ArticlesPageFilters.tsx @@ -0,0 +1,105 @@ +import { classNames } from 'shared/lib/classNames/classNames'; +import { useTranslation } from 'react-i18next'; +import { memo, useCallback } from 'react'; +import { + ArticleSortField, + ArticleSortSelector, + ArticleTypeTabs, + ArticleView, + ArticleViewSelector, +} from 'entities/Article'; +import { useSelector } from 'react-redux'; +import { useAppDispatch } from 'shared/lib/hooks/useAppDispatch/useAppDispatch'; +import { Card } from 'shared/ui/Card/Card'; +import { Input } from 'shared/ui/Input/Input'; +import { SortOrder } from 'shared/types'; +import { useDebounce } from 'shared/lib/hooks/useDebounce/useDebounce'; +import { ArticleType } from 'entities/Article/model/types/article'; +import { fetchArticlesList } from '../../model/services/fetchArticlesList/fetchArticlesList'; +import cls from './ArticlesPageFilters.module.scss'; +import { + getArticlesPageOrder, + getArticlesPageSearch, + getArticlesPageSort, + getArticlesPageType, + getArticlesPageView, +} from '../../model/selectors/articlesPageSelectors'; +import { articlesPageActions } from '../../model/slices/articlesPageSlice'; + +interface ArticlesPageFiltersProps { + className?: string; +} + +export const ArticlesPageFilters = memo((props: ArticlesPageFiltersProps) => { + const { className } = props; + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const view = useSelector(getArticlesPageView); + const sort = useSelector(getArticlesPageSort); + const order = useSelector(getArticlesPageOrder); + const search = useSelector(getArticlesPageSearch); + const type = useSelector(getArticlesPageType); + + const fetchData = useCallback(() => { + dispatch(fetchArticlesList({ replace: true })); + }, [dispatch]); + + const debouncedFetchData = useDebounce(fetchData, 500); + + const onChangeView = useCallback((view: ArticleView) => { + dispatch(articlesPageActions.setView(view)); + }, [dispatch]); + + const onChangeSort = useCallback((newSort: ArticleSortField) => { + dispatch(articlesPageActions.setSort(newSort)); + dispatch(articlesPageActions.setPage(1)); + fetchData(); + }, [dispatch, fetchData]); + + const onChangeOrder = useCallback((newOrder: SortOrder) => { + dispatch(articlesPageActions.setOrder(newOrder)); + dispatch(articlesPageActions.setPage(1)); + fetchData(); + }, [dispatch, fetchData]); + + const onChangeSearch = useCallback((search: string) => { + dispatch(articlesPageActions.setSearch(search)); + dispatch(articlesPageActions.setPage(1)); + debouncedFetchData(); + }, [dispatch, debouncedFetchData]); + + const onChangeType = useCallback((value: ArticleType) => { + dispatch(articlesPageActions.setType(value)); + dispatch(articlesPageActions.setPage(1)); + fetchData(); + }, [dispatch, fetchData]); + + return ( +
+
+ + +
+ + + + +
+ ); +}); diff --git a/src/shared/config/storybook/StoreDecorator/StoreDecorator.tsx b/src/shared/config/storybook/StoreDecorator/StoreDecorator.tsx index f2d4b7f..28efbfc 100644 --- a/src/shared/config/storybook/StoreDecorator/StoreDecorator.tsx +++ b/src/shared/config/storybook/StoreDecorator/StoreDecorator.tsx @@ -5,14 +5,14 @@ import { profileReducer } from 'entities/Profile'; import { ReducersList } from 'shared/lib/components/DynamicModuleLoader/DynamicModuleLoader'; import { articleDetailsReducer } from 'entities/Article/model/slice/articleDetailsSlice'; import { addCommentFormReducer } from 'features/addCommentForm/model/slices/addCommentFormSlice'; -import { articleDetailsCommentsReducer } from 'pages/ArticleDetailsPage/model/slices/articleDetailsCommentsSlice'; +import { articleDetailsPageReducer } from 'pages/ArticleDetailsPage/model/slices'; const defaultAsyncReducers: ReducersList = { loginForm: loginReducer, profile: profileReducer, articleDetails: articleDetailsReducer, - articleDetailsComments: articleDetailsCommentsReducer, addCommentForm: addCommentFormReducer, + articleDetailsPage: articleDetailsPageReducer, }; export const StoreDecorator = ( diff --git a/src/shared/lib/components/DynamicModuleLoader/DynamicModuleLoader.tsx b/src/shared/lib/components/DynamicModuleLoader/DynamicModuleLoader.tsx index 0a34504..94be225 100644 --- a/src/shared/lib/components/DynamicModuleLoader/DynamicModuleLoader.tsx +++ b/src/shared/lib/components/DynamicModuleLoader/DynamicModuleLoader.tsx @@ -1,10 +1,10 @@ import { FC, useEffect } from 'react'; import { useDispatch, useStore } from 'react-redux'; -import { ReduxStoreWithManager, StateSchemaKey } from 'app/providers/StoreProvider/config/StateSchema'; +import { ReduxStoreWithManager, StateSchema, StateSchemaKey } from 'app/providers/StoreProvider/config/StateSchema'; import { Reducer } from '@reduxjs/toolkit'; export type ReducersList = { - [name in StateSchemaKey]?: Reducer; + [name in StateSchemaKey]?: Reducer>; } interface DynamicModuleLoaderProps { diff --git a/src/shared/lib/hooks/useDebounce/useDebounce.ts b/src/shared/lib/hooks/useDebounce/useDebounce.ts new file mode 100644 index 0000000..d97e323 --- /dev/null +++ b/src/shared/lib/hooks/useDebounce/useDebounce.ts @@ -0,0 +1,14 @@ +import { MutableRefObject, useCallback, useRef } from 'react'; + +export function useDebounce(callback: (...args: any[]) => void, delay: number) { + const timer = useRef() as MutableRefObject; + + return useCallback((...args: any[]) => { + if (timer.current) { + clearTimeout(timer.current); + } + timer.current = setTimeout(() => { + callback(...args); + }, delay); + }, [callback, delay]); +} diff --git a/src/shared/lib/url/addQueryParams/addQueryParams.test.ts b/src/shared/lib/url/addQueryParams/addQueryParams.test.ts new file mode 100644 index 0000000..6fa139d --- /dev/null +++ b/src/shared/lib/url/addQueryParams/addQueryParams.test.ts @@ -0,0 +1,24 @@ +import { getQueryParams } from './addQueryParams'; + +describe('shared/url/addQueryParams', () => { + test('test with one param', () => { + const params = getQueryParams({ + test: 'value', + }); + expect(params).toBe('?test=value'); + }); + test('test with multiple params', () => { + const params = getQueryParams({ + test: 'value', + second: '2', + }); + expect(params).toBe('?test=value&second=2'); + }); + test('test with undefined', () => { + const params = getQueryParams({ + test: 'value', + second: undefined, + }); + expect(params).toBe('?test=value'); + }); +}); diff --git a/src/shared/lib/url/addQueryParams/addQueryParams.ts b/src/shared/lib/url/addQueryParams/addQueryParams.ts new file mode 100644 index 0000000..ae19014 --- /dev/null +++ b/src/shared/lib/url/addQueryParams/addQueryParams.ts @@ -0,0 +1,17 @@ +export function getQueryParams(params: OptionalRecord) { + const searchParams = new URLSearchParams(window.location.search); + Object.entries(params).forEach(([name, value]) => { + if (value !== undefined) { + searchParams.set(name, value); + } + }); + return `?${searchParams.toString()}`; +} + +/** + * Function for adding query string parameters to URL + * @param params + */ +export function addQueryParams(params: OptionalRecord) { + window.history.pushState(null, '', getQueryParams(params)); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..31f824b --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export type SortOrder = 'asc' | 'desc'; diff --git a/src/shared/ui/Card/Card.module.scss b/src/shared/ui/Card/Card.module.scss index 22c4047..39d0347 100644 --- a/src/shared/ui/Card/Card.module.scss +++ b/src/shared/ui/Card/Card.module.scss @@ -1,5 +1,12 @@ .Card { padding: 15px; border-radius: 12px; +} + +.normal { background: var(--card-bg); } + +.outlined { + border: 1px solid var(--primary-color); +} diff --git a/src/shared/ui/Card/Card.stories.tsx b/src/shared/ui/Card/Card.stories.tsx index a8bdddf..e8c7042 100644 --- a/src/shared/ui/Card/Card.stories.tsx +++ b/src/shared/ui/Card/Card.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; + import { Text } from 'shared/ui/Text/Text'; import { Card } from './Card'; @@ -15,5 +16,5 @@ const Template: ComponentStory = (args) => ; export const Normal = Template.bind({}); Normal.args = { - children: , + children: , }; diff --git a/src/shared/ui/Card/Card.tsx b/src/shared/ui/Card/Card.tsx index 8d9e11b..6091906 100644 --- a/src/shared/ui/Card/Card.tsx +++ b/src/shared/ui/Card/Card.tsx @@ -2,17 +2,28 @@ import { classNames } from 'shared/lib/classNames/classNames'; import { HTMLAttributes, memo, ReactNode } from 'react'; import cls from './Card.module.scss'; +export enum CardTheme { + NORMAL = 'normal', + OUTLINED = 'outlined', +} + interface CardProps extends HTMLAttributes { className?: string; children: ReactNode; + theme?: CardTheme; } export const Card = memo((props: CardProps) => { - const { className, children, ...otherProps } = props; + const { + className, + children, + theme = CardTheme.NORMAL, + ...otherProps + } = props; return (
{children} diff --git a/src/shared/ui/Select/Select.tsx b/src/shared/ui/Select/Select.tsx index 2ef4802..a1f83ec 100644 --- a/src/shared/ui/Select/Select.tsx +++ b/src/shared/ui/Select/Select.tsx @@ -1,22 +1,22 @@ import { classNames, Mods } from 'shared/lib/classNames/classNames'; -import { ChangeEvent, memo, useMemo } from 'react'; +import { ChangeEvent, useMemo } from 'react'; import cls from './Select.module.scss'; -export interface SelectOption { - value: string; +export interface SelectOption { + value: T; content: string; } -interface SelectProps { +interface SelectProps { className?: string; label?: string; - options?: SelectOption[]; - value?: string; - onChange?: (value: string) => void; + options?: SelectOption[]; + value?: T; + onChange?: (value: T) => void; readonly?: boolean; } -export const Select = memo((props: SelectProps) => { +export const Select = (props: SelectProps) => { const { className, label, @@ -29,7 +29,7 @@ export const Select = memo((props: SelectProps) => { const onChangeHandler = (e: ChangeEvent) => { if (onChange) { - onChange(e.target.value); + onChange(e.target.value as T); } }; @@ -60,4 +60,4 @@ export const Select = memo((props: SelectProps) => {
); -}); +}; diff --git a/src/shared/ui/Tabs/Tabs.module.scss b/src/shared/ui/Tabs/Tabs.module.scss new file mode 100644 index 0000000..ffa5975 --- /dev/null +++ b/src/shared/ui/Tabs/Tabs.module.scss @@ -0,0 +1,5 @@ +.Tabs { + display: flex; + gap: 8px; + cursor: pointer; +} diff --git a/src/shared/ui/Tabs/Tabs.stories.tsx b/src/shared/ui/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000..1dc7253 --- /dev/null +++ b/src/shared/ui/Tabs/Tabs.stories.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { Tabs } from './Tabs'; + +export default { + title: 'shared/Tabs', + component: Tabs, + argTypes: { + backgroundColor: { control: 'color' }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Normal = Template.bind({}); +Normal.args = { + tabs: [ + { + value: 'tab 1', + content: 'tab 1', + }, + { + value: 'tab 2', + content: 'tab 2', + }, + { + value: 'tab 3', + content: 'tab 3', + }, + ], + value: 'tab 2', + onTabClick: action('onTabClick'), +}; diff --git a/src/shared/ui/Tabs/Tabs.tsx b/src/shared/ui/Tabs/Tabs.tsx new file mode 100644 index 0000000..f81a836 --- /dev/null +++ b/src/shared/ui/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import { classNames } from 'shared/lib/classNames/classNames'; +import { memo, ReactNode, useCallback } from 'react'; +import { Card, CardTheme } from 'shared/ui/Card/Card'; +import cls from './Tabs.module.scss'; + +export interface TabItem { + value: string; + content: ReactNode; +} + +interface TabsProps { + className?: string; + tabs: TabItem[]; + value: string; + onTabClick: (tab: TabItem) => void; +} + +export const Tabs = memo((props: TabsProps) => { + const { + className, tabs, onTabClick, value, + } = props; + + const clickHandle = useCallback((tab: TabItem) => () => { + onTabClick(tab); + }, [onTabClick]); + + return ( +
+ {tabs.map((tab) => ( + + {tab.content} + + ))} +
+ ); +}); diff --git a/src/widgets/Page/Page.module.scss b/src/widgets/Page/Page.module.scss index 6e09fd8..f2c9d45 100644 --- a/src/widgets/Page/Page.module.scss +++ b/src/widgets/Page/Page.module.scss @@ -5,3 +5,8 @@ overflow-y: auto; flex-shrink: 100; } + +.trigger { + height: 20px; + margin: 10px; +} \ No newline at end of file diff --git a/src/widgets/Page/Page.tsx b/src/widgets/Page/Page.tsx index ebaf9be..f31cb67 100644 --- a/src/widgets/Page/Page.tsx +++ b/src/widgets/Page/Page.tsx @@ -1,13 +1,13 @@ import { classNames } from 'shared/lib/classNames/classNames'; import { - memo, MutableRefObject, ReactNode, useRef, UIEvent, + memo, MutableRefObject, ReactNode, UIEvent, useRef, } from 'react'; import { useInfiniteScroll } from 'shared/lib/hooks/useInfiniteScroll/useInfiniteScroll'; import { useAppDispatch } from 'shared/lib/hooks/useAppDispatch/useAppDispatch'; import { getUIScrollByPath, uiActions } from 'features/UI'; import { useLocation } from 'react-router-dom'; -import { useSelector } from 'react-redux'; import { useInitialEffect } from 'shared/lib/hooks/useInitialEffect/useInitialEffect'; +import { useSelector } from 'react-redux'; import { StateSchema } from 'app/providers/StoreProvider'; import { useThrottle } from 'shared/lib/hooks/useThrottle/useThrottle'; import cls from './Page.module.scss'; @@ -20,8 +20,8 @@ interface PageProps { export const Page = memo((props: PageProps) => { const { className, children, onScrollEnd } = props; - const triggerRef = useRef() as MutableRefObject; const wrapperRef = useRef() as MutableRefObject; + const triggerRef = useRef() as MutableRefObject; const dispatch = useAppDispatch(); const { pathname } = useLocation(); const scrollPosition = useSelector( @@ -52,7 +52,7 @@ export const Page = memo((props: PageProps) => { onScroll={onScroll} > {children} -
+ {onScrollEnd ?
: null} ); });