diff --git a/frontend/src/apis/articles.ts b/frontend/src/apis/articles.ts index f45c3bd81..e3f223ea0 100644 --- a/frontend/src/apis/articles.ts +++ b/frontend/src/apis/articles.ts @@ -1,5 +1,10 @@ import { client } from '.'; -import { ArticleRequest, MetaOgRequest, MetaOgResponse } from '../models/Article'; +import { + ArticleBookmarkPutRequest, + ArticleRequest, + MetaOgRequest, + MetaOgResponse, +} from '../models/Article'; export const requestGetArticles = () => client.get(`/articles`); @@ -8,3 +13,11 @@ export const requestPostArticles = (body: ArticleRequest) => client.post('/artic export const requestGetMetaOg = ({ url }: MetaOgRequest) => { return client.get<MetaOgResponse>(`/meta-og?url=${url}`); }; + +export const requestPutArticleBookmark = ({ articleId, bookmark }: ArticleBookmarkPutRequest) => { + return client.put(`/articles/${articleId}/bookmark`, { checked: bookmark }); +}; + +export const requestGetFilteredArticle = (course: string, bookmark: boolean) => { + return client.get(`/articles?course=${course}&onlyBookmarked=${bookmark}`); +}; diff --git a/frontend/src/components/Article/Article.style.tsx b/frontend/src/components/Article/Article.style.tsx index 049d4cba4..13980b9b4 100644 --- a/frontend/src/components/Article/Article.style.tsx +++ b/frontend/src/components/Article/Article.style.tsx @@ -1,11 +1,12 @@ import styled from '@emotion/styled'; +import { css } from '@emotion/react'; import { COLOR } from '../../constants'; export const Container = styled.li` width: 100%; - height: 340px; - padding: 20px; - border-radius: 15px; + height: 100%; + padding: 10px; + border-radius: 8px; background-color: #ffffff; list-style: none; @@ -20,16 +21,16 @@ export const ThumbnailWrapper = styled.div` display: flex; justify-content: center; align-items: center; + aspect-ratio: 16/9; width: 100%; - height: 154px; border-radius: 15px; - margin-bottom: 20px; + margin-bottom: 10px; `; export const Thumbnail = styled.img` width: 100%; - height: 154px; - border-radius: 15px; + height: 100%; + border-radius: 8px; object-fit: cover; `; @@ -41,34 +42,57 @@ export const ArticleInfoContainer = styled.div` padding: 10px; `; -export const UserName = styled.p` - width: 250px; - margin: 0; - color: ${COLOR.DARK_GRAY_400}; - font-size: 14px; +export const ArticleInfoWrapper = styled.div` + display: flex; + width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-all; + font-size: 14px; +`; + +export const UserName = styled.p` + margin: 0; + color: ${COLOR.DARK_GRAY_400}; `; export const Title = styled.p` - width: 250px; + width: 100%; + height: 50px; margin: 0; color: ${COLOR.BLACK_900}; font-size: 16px; font-weight: 700; - overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; - word-break: break-all; + overflow: hidden; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +export const BookmarkWrapper = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; +`; + +export const ArticleBookmarkButtonStyle = css` + width: initial; + background-color: transparent; + text-align: right; + + & > img { + width: 2.3rem; + height: 2.3rem; + } `; export const CreatedAt = styled.span` width: 100%; - margin-top: 16px; color: ${COLOR.DARK_GRAY_400}; text-align: right; - font-size: 16px; + font-size: 12px; font-weight: 700; `; diff --git a/frontend/src/components/Article/Article.tsx b/frontend/src/components/Article/Article.tsx index c610c38af..0d55d5f71 100644 --- a/frontend/src/components/Article/Article.tsx +++ b/frontend/src/components/Article/Article.tsx @@ -1,7 +1,26 @@ import * as Styled from './Article.style'; import type { ArticleType } from '../../models/Article'; +import Scrap from '../Reaction/Scrap'; +import { useRef, useState } from 'react'; +import { usePutArticleBookmarkMutation } from '../../hooks/queries/article'; +import debounce from '../../utils/debounce'; + +const Article = ({ id, title, userName, url, createdAt, imageUrl, isBookMarked }: ArticleType) => { + const bookmarkRef = useRef(false); + const [bookmark, setBookmark] = useState(isBookMarked); + const { mutate: putBookmark } = usePutArticleBookmarkMutation(); + + const toggleBookmark: React.MouseEventHandler<HTMLButtonElement> = (e) => { + e.preventDefault(); + + bookmarkRef.current = !bookmarkRef.current; + setBookmark((prev) => !prev); + + debounce(() => { + putBookmark({ articleId: id, bookmark: bookmarkRef.current }); + }, 300); + }; -const Article = ({ title, userName, url, createdAt, imageUrl }: ArticleType) => { return ( <Styled.Container> <Styled.Anchor href={url} target="_blank" rel="noopener noreferrer"> @@ -9,9 +28,18 @@ const Article = ({ title, userName, url, createdAt, imageUrl }: ArticleType) => <Styled.Thumbnail src={imageUrl} /> </Styled.ThumbnailWrapper> <Styled.ArticleInfoContainer> - <Styled.UserName>{userName}</Styled.UserName> + <Styled.ArticleInfoWrapper> + <Styled.UserName>{userName}</Styled.UserName> + <Styled.CreatedAt>{createdAt.split(' ')[0]}</Styled.CreatedAt> + </Styled.ArticleInfoWrapper> <Styled.Title>{title}</Styled.Title> - <Styled.CreatedAt>{createdAt}</Styled.CreatedAt> + <Styled.BookmarkWrapper> + <Scrap + scrap={bookmark} + onClick={toggleBookmark} + cssProps={Styled.ArticleBookmarkButtonStyle} + /> + </Styled.BookmarkWrapper> </Styled.ArticleInfoContainer> </Styled.Anchor> </Styled.Container> diff --git a/frontend/src/components/Article/ArticleBookmarkFIlter.tsx b/frontend/src/components/Article/ArticleBookmarkFIlter.tsx new file mode 100644 index 000000000..9fb48f7d9 --- /dev/null +++ b/frontend/src/components/Article/ArticleBookmarkFIlter.tsx @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; + +interface ArticleBookmarkFilterProps { + checked: boolean; + handleCheckBookmark: React.ChangeEventHandler<HTMLInputElement>; +} + +const ArticleBookmarkFilter = ({ checked, handleCheckBookmark }: ArticleBookmarkFilterProps) => { + return ( + <ArticleBookmarkFilterContainer> + <label> + <input type="checkbox" checked={checked} onChange={handleCheckBookmark} /> + 북마크한 아티클 + </label> + </ArticleBookmarkFilterContainer> + ); +}; + +export default ArticleBookmarkFilter; + +const ArticleBookmarkFilterContainer = styled.div` + display: flex; + align-items: center; + margin-left: 10px; + width: 150px; + height: 100%; + font-size: 1.5rem; +`; diff --git a/frontend/src/components/Article/ArticleList.style.tsx b/frontend/src/components/Article/ArticleList.style.tsx index b7d21cadd..e941cfc56 100644 --- a/frontend/src/components/Article/ArticleList.style.tsx +++ b/frontend/src/components/Article/ArticleList.style.tsx @@ -3,19 +3,19 @@ import MEDIA_QUERY from '../../constants/mediaQuery'; export const Container = styled.ul` display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); grid-auto-rows: 1fr; - gap: 30px 64px; + gap: 10px; - ${MEDIA_QUERY.xl} { - gap: 30px 40px; + ${MEDIA_QUERY.lg} { + grid-template-columns: repeat(3, 1fr); } - ${MEDIA_QUERY.lg} { + ${MEDIA_QUERY.md} { grid-template-columns: repeat(2, 1fr); } - ${MEDIA_QUERY.md} { + ${MEDIA_QUERY.sm} { grid-template-columns: repeat(1, 1fr); } `; diff --git a/frontend/src/components/Article/ArticleList.tsx b/frontend/src/components/Article/ArticleList.tsx index 5aebbfe49..59fd386ce 100644 --- a/frontend/src/components/Article/ArticleList.tsx +++ b/frontend/src/components/Article/ArticleList.tsx @@ -1,13 +1,12 @@ import * as Styled from './ArticleList.style'; import Article from './Article'; -import { useGetRequestArticleQuery } from '../../hooks/queries/article'; +import { ArticleType } from '../../models/Article'; -const ArticleList = () => { - const { data: articles, isLoading, isError } = useGetRequestArticleQuery(); - - if (isLoading) return <div>loading...</div>; - if (isError) return <div>error...</div>; +interface ArticleListProps { + articles: ArticleType[]; +} +const ArticleList = ({ articles }: ArticleListProps) => { return ( <Styled.Container> {articles?.map((article) => ( diff --git a/frontend/src/components/Controls/SelectBox.tsx b/frontend/src/components/Controls/SelectBox.tsx index 7a0fa1edb..168875962 100644 --- a/frontend/src/components/Controls/SelectBox.tsx +++ b/frontend/src/components/Controls/SelectBox.tsx @@ -34,6 +34,25 @@ interface SelectOption { label: string; } +/** + FIXME: value props type SelectOption['value'] 로 변경되어야 함. + 아래 예시처럼 type을 좁힐 수 없는 문제가 있음. + + const CATEGORY_OPTIONS = [ + { value: '', label: '전체보기' }, + { value: 'frontend', label: '프론트엔드' }, + { value: 'backend', label: '백엔드' }, + { value: 'android', label: '안드로이드' }, + ]; + + type CategoryOptions = typeof CATEGORY_OPTIONS[number]; + + ->type CategoryOptions = { + value: string; + label: string; + } + 위 처럼 value type을 좁힐 수 없음. + */ interface SelectBoxProps { isMulti?: boolean; options: SelectOption[]; @@ -46,6 +65,7 @@ interface SelectBoxProps { } const SelectBox: React.VFC<SelectBoxProps> = ({ + isClearable = true, isMulti = false, options, placeholder, @@ -75,7 +95,7 @@ const SelectBox: React.VFC<SelectBoxProps> = ({ `} > <Select - isClearable={true} + isClearable={isClearable} isMulti={isMulti} options={options} placeholder={placeholder} @@ -83,7 +103,6 @@ const SelectBox: React.VFC<SelectBoxProps> = ({ styles={selectStyles} defaultValue={defaultOption} value={value} - // theme={(theme) => ({ ...theme, colors: { ...theme.colors, primary: 'transparent' } })} /> </div> ); diff --git a/frontend/src/components/Reaction/Scrap.styles.ts b/frontend/src/components/Reaction/Scrap.styles.ts index 2335d252a..976533905 100644 --- a/frontend/src/components/Reaction/Scrap.styles.ts +++ b/frontend/src/components/Reaction/Scrap.styles.ts @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; import { COLOR } from '../../enumerations/color'; -export const ScrapButtonStyle = css` +export const DefaultScrapButtonStyle = css` flex-direction: column; padding: 0; width: fit-content; diff --git a/frontend/src/components/Reaction/Scrap.tsx b/frontend/src/components/Reaction/Scrap.tsx index 08bfad860..9a30aa9ec 100644 --- a/frontend/src/components/Reaction/Scrap.tsx +++ b/frontend/src/components/Reaction/Scrap.tsx @@ -3,14 +3,16 @@ import { Button, BUTTON_SIZE } from '..'; import scrappedIcon from '../../assets/images/scrap_filled.svg'; import unScrapIcon from '../../assets/images/scrap.svg'; -import { ScrapButtonStyle } from './Scrap.styles'; +import { DefaultScrapButtonStyle } from './Scrap.styles'; +import { SerializedStyles } from '@emotion/react'; interface Props { scrap: boolean; onClick: MouseEventHandler<HTMLButtonElement>; + cssProps?: SerializedStyles; } -const Scrap = ({ scrap, onClick }: Props) => { +const Scrap = ({ scrap, onClick, cssProps }: Props) => { const scrapIcon = scrap ? scrappedIcon : unScrapIcon; const scrapIconAlt = scrap ? '스크랩 취소' : '스크랩'; @@ -20,7 +22,7 @@ const Scrap = ({ scrap, onClick }: Props) => { size={BUTTON_SIZE.X_SMALL} icon={scrapIcon} alt={scrapIconAlt} - cssProps={ScrapButtonStyle} + cssProps={cssProps ?? DefaultScrapButtonStyle} onClick={onClick} /> ); diff --git a/frontend/src/hooks/queries/article.ts b/frontend/src/hooks/queries/article.ts index 4e6780cbf..f62cd49f8 100644 --- a/frontend/src/hooks/queries/article.ts +++ b/frontend/src/hooks/queries/article.ts @@ -1,17 +1,20 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { UserContext } from '../../contexts/UserProvider'; -import { requestGetArticles, requestPostArticles } from '../../apis/articles'; -import { ArticleType } from '../../models/Article'; +import { + requestGetFilteredArticle, + requestPostArticles, + requestPutArticleBookmark, +} from '../../apis/articles'; +import { ArticleType, CourseFilter } from '../../models/Article'; import { ERROR_MESSAGE } from '../../constants'; import { SUCCESS_MESSAGE } from '../../constants/message'; const QUERY_KEY = { - articles: 'articles', + filteredArticles: 'filteredArticles', }; -export const useGetRequestArticleQuery = () => { - return useQuery<ArticleType[]>([QUERY_KEY.articles], async () => { - const response = await requestGetArticles(); +export const useGetFilteredArticleQuery = (course: string, bookmark: boolean) => { + return useQuery<ArticleType[]>([QUERY_KEY.filteredArticles], async () => { + const response = await requestGetFilteredArticle(course, bookmark); return response.data; }); @@ -22,7 +25,7 @@ export const usePostArticlesMutation = () => { return useMutation(requestPostArticles, { onSuccess: () => { - queryClient.invalidateQueries([QUERY_KEY.articles]); + queryClient.invalidateQueries([QUERY_KEY.filteredArticles]); alert(SUCCESS_MESSAGE.CREATE_ARTICLE); }, onError: () => { @@ -30,3 +33,10 @@ export const usePostArticlesMutation = () => { }, }); }; + +export const usePutArticleBookmarkMutation = () => { + return useMutation(requestPutArticleBookmark, { + onSuccess: () => {}, + onError: () => {}, + }); +}; diff --git a/frontend/src/mocks/db/articles-android.json b/frontend/src/mocks/db/articles-android.json new file mode 100644 index 000000000..865954a4c --- /dev/null +++ b/frontend/src/mocks/db/articles-android.json @@ -0,0 +1,29 @@ +[ + { + "id": 7, + "userName": "도밥", + "title": "직렬화, 역직렬화는 무엇일까?", + "url": "https://think0wise.tistory.com/107", + "createdAt": "2023-07-08 16:48", + "isBookMarked": false, + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 8, + "userName": "도밥", + "title": "직렬화, 역직렬화는 무엇일까?", + "url": "https://think0wise.tistory.com/107", + "createdAt": "2023-07-08 16:48", + "isBookMarked": true, + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 9, + "userName": "도밥", + "title": "직렬화, 역직렬화는 무엇일까?", + "url": "https://think0wise.tistory.com/107", + "createdAt": "2023-07-08 16:48", + "isBookMarked": false, + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + } +] diff --git a/frontend/src/mocks/db/articles-backend.json b/frontend/src/mocks/db/articles-backend.json new file mode 100644 index 000000000..f3fdaae4c --- /dev/null +++ b/frontend/src/mocks/db/articles-backend.json @@ -0,0 +1,29 @@ +[ + { + "id": 4, + "userName": "패트릭", + "title": "CORS", + "isBookMarked": false, + "url": "https://pgccoding.tistory.com/66", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 5, + "userName": "패트릭", + "title": "CORS", + "isBookMarked": true, + "url": "https://pgccoding.tistory.com/66", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 6, + "userName": "패트릭", + "title": "CORS", + "isBookMarked": true, + "url": "https://pgccoding.tistory.com/66", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + } +] diff --git a/frontend/src/mocks/db/articles-frontend.json b/frontend/src/mocks/db/articles-frontend.json new file mode 100644 index 000000000..d7da0c5e8 --- /dev/null +++ b/frontend/src/mocks/db/articles-frontend.json @@ -0,0 +1,29 @@ +[ + { + "id": 1, + "userName": "해온", + "title": "Axios", + "isBookMarked": true, + "url": "https://hae-on.tistory.com/104", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 2, + "userName": "해온", + "title": "Axios", + "isBookMarked": true, + "url": "https://hae-on.tistory.com/104", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 3, + "userName": "해온", + "title": "Axios", + "isBookMarked": true, + "url": "https://hae-on.tistory.com/104", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + } +] diff --git a/frontend/src/mocks/db/articles.json b/frontend/src/mocks/db/articles.json index 9105fbb0a..35b46c204 100644 --- a/frontend/src/mocks/db/articles.json +++ b/frontend/src/mocks/db/articles.json @@ -3,16 +3,81 @@ "id": 1, "userName": "해온", "title": "Axios", + "isBookMarked": true, "url": "https://hae-on.tistory.com/104", "createdAt": "2023-07-24 18:18", "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" }, { "id": 2, + "userName": "해온", + "title": "Axios", + "isBookMarked": true, + "url": "https://hae-on.tistory.com/104", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 3, + "userName": "해온", + "title": "Axios", + "isBookMarked": true, + "url": "https://hae-on.tistory.com/104", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 4, "userName": "패트릭", "title": "CORS", + "isBookMarked": false, "url": "https://pgccoding.tistory.com/66", "createdAt": "2023-07-24 18:18", "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 5, + "userName": "패트릭", + "title": "CORS", + "isBookMarked": true, + "url": "https://pgccoding.tistory.com/66", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 6, + "userName": "패트릭", + "title": "CORS", + "isBookMarked": true, + "url": "https://pgccoding.tistory.com/66", + "createdAt": "2023-07-24 18:18", + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 7, + "userName": "도밥", + "title": "직렬화, 역직렬화는 무엇일까?", + "url": "https://think0wise.tistory.com/107", + "createdAt": "2023-07-08 16:48", + "isBookMarked": false, + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 8, + "userName": "도밥", + "title": "직렬화, 역직렬화는 무엇일까?", + "url": "https://think0wise.tistory.com/107", + "createdAt": "2023-07-08 16:48", + "isBookMarked": true, + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" + }, + { + "id": 9, + "userName": "도밥", + "title": "직렬화, 역직렬화는 무엇일까?", + "url": "https://think0wise.tistory.com/107", + "createdAt": "2023-07-08 16:48", + "isBookMarked": false, + "imageUrl": "https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60" } ] diff --git a/frontend/src/mocks/handlers/articles.ts b/frontend/src/mocks/handlers/articles.ts index c9b74676e..6571fa4fa 100644 --- a/frontend/src/mocks/handlers/articles.ts +++ b/frontend/src/mocks/handlers/articles.ts @@ -1,15 +1,18 @@ import { rest } from 'msw'; import { BASE_URL } from '../../configs/environment'; import articles from '../db/articles.json'; +import articlesFrontend from '../db/articles-frontend.json'; +import articlesBackend from '../db/articles-backend.json'; +import articlesAndroid from '../db/articles-android.json'; import metaOg from '../db/metaog.json'; import { ArticleType } from '../../models/Article'; const articleUrl = 'https://think0wise.tistory.com/107'; export const articlesHandler = [ - rest.get(`${BASE_URL}/articles`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(articles)); - }), + // rest.get(`${BASE_URL}/articles`, (req, res, ctx) => { + // return res(ctx.status(200), ctx.json(articles)); + // }), rest.get(`${BASE_URL}/meta-og?url=${articleUrl}`, async (req, res, ctx) => { const data = metaOg; @@ -24,6 +27,7 @@ export const articlesHandler = [ title: '직렬화, 역직렬화는 무엇일까?', url: 'https://think0wise.tistory.com/107', createdAt: '2023-07-08 16:48', + isBookMarked: false, imageUrl: 'https://plus.unsplash.com/premium_photo-1682088845396-1b310a002302?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJTk1JTg0JUVEJThCJUIwJUVEJTgxJUI0fGVufDB8fDB8fHww&auto=format&fit=crop&w=500&q=60', }; @@ -31,4 +35,30 @@ export const articlesHandler = [ articles.push(newArticle); return res(ctx.status(200), ctx.json(newArticle)); }), + + rest.put(`${BASE_URL}/articles/:articleId/bookmark`, (req, res, ctx) => { + return res(ctx.status(200)); + }), + + rest.get(`${BASE_URL}/articles`, (req, res, ctx) => { + const course = req.url.searchParams.get('course') ?? 'all'; + const onlyBookmarked = req.url.searchParams.get('onlyBookmarked') as string; + + const filteredCourse = (course: string) => { + if (course === 'all') return articles; + if (course === 'frontend') return articlesFrontend; + if (course === 'backend') return articlesBackend; + if (course === 'android') return articlesAndroid; + }; + + if (onlyBookmarked === 'false') { + return res(ctx.status(200), ctx.json(filteredCourse(course))); + } + + const filteredArticle = filteredCourse(course)?.filter((article) => { + return onlyBookmarked === String(article.isBookMarked) ? true : false; + }); + + return res(ctx.status(200), ctx.json(filteredArticle)); + }), ]; diff --git a/frontend/src/models/Article.ts b/frontend/src/models/Article.ts index 742bcfd9b..dc7540160 100644 --- a/frontend/src/models/Article.ts +++ b/frontend/src/models/Article.ts @@ -8,6 +8,7 @@ export interface ArticleType { id: number; userName: string; title: string; + isBookMarked: boolean; url: string; createdAt: string; imageUrl: string; @@ -21,3 +22,12 @@ export interface MetaOgResponse { imageUrl: string; title: string; } + +export interface ArticleBookmarkPutRequest { + articleId: number; + bookmark: boolean; +} + +export type Course = '프론트엔드' | '백엔드' | '안드로이드'; + +export type CourseFilter = Course | '전체보기'; diff --git a/frontend/src/pages/ArticleListPage/index.tsx b/frontend/src/pages/ArticleListPage/index.tsx index 4c4f8cc2c..9f6882c0e 100644 --- a/frontend/src/pages/ArticleListPage/index.tsx +++ b/frontend/src/pages/ArticleListPage/index.tsx @@ -4,28 +4,82 @@ import ArticleList from '../../components/Article/ArticleList'; import { Button } from '../../components'; import PencilIcon from '../../assets/images/pencil_icon.svg'; import { useHistory } from 'react-router-dom'; -import { PATH } from '../../constants'; +import { COLOR, PATH } from '../../constants'; import styled from '@emotion/styled'; import { MainContentStyle } from '../../PageRouter'; +import SelectBox from '../../components/Controls/SelectBox'; +import { useContext, useEffect, useState } from 'react'; +import { css } from '@emotion/react'; +import ArticleBookmarkFilter from '../../components/Article/ArticleBookmarkFIlter'; +import { useGetFilteredArticleQuery } from '../../hooks/queries/article'; +import { UserContext } from '../../contexts/UserProvider'; + +const CATEGORY_OPTIONS = [ + { value: 'all', label: '전체보기' }, + { value: 'frontend', label: '프론트엔드' }, + { value: 'backend', label: '백엔드' }, + { value: 'android', label: '안드로이드' }, +]; + +type CategoryOptions = typeof CATEGORY_OPTIONS[number]; const ArticleListPage = () => { const history = useHistory(); const goNewArticlePage = () => history.push(PATH.NEW_ARTICLE); + const [selectedCourse, setSelectedCourse] = useState<CategoryOptions>(CATEGORY_OPTIONS[0]); + const [checked, setChecked] = useState(false); + + const { user } = useContext(UserContext); + const { isLoggedIn } = user; + + const { data: filteredArticles = [], refetch: getFilteredArticles } = useGetFilteredArticleQuery( + selectedCourse.value, + checked + ); + + const changeFilterOption: (option: { value: string; label: string }) => void = (option) => { + setSelectedCourse(option); + }; + + const handleCheckBookmark: React.ChangeEventHandler<HTMLInputElement> = (e) => { + setChecked(e.currentTarget.checked); + }; + + useEffect(() => { + getFilteredArticles(); + }, [checked, selectedCourse]); return ( <div css={[MainContentStyle]}> <Container> - <Button - type="button" - size="SMALL" - icon={PencilIcon} - alt="새 아티클 쓰기 아이콘" - onClick={goNewArticlePage} - > - 글쓰기 - </Button> + <FilteringWrapper> + <SelectBoxWrapper> + <SelectBox + isClearable={false} + value={selectedCourse} + defaultOption={selectedCourse} + options={CATEGORY_OPTIONS} + onChange={changeFilterOption} + /> + </SelectBoxWrapper> + {isLoggedIn && ( + <ArticleBookmarkFilter checked={checked} handleCheckBookmark={handleCheckBookmark} /> + )} + </FilteringWrapper> + {isLoggedIn && ( + <Button + type="button" + size="X_SMALL" + icon={PencilIcon} + alt="새 아티클 쓰기 아이콘" + onClick={goNewArticlePage} + cssProps={WriteButtonStyle} + > + 글쓰기 + </Button> + )} </Container> - <ArticleList /> + <ArticleList articles={filteredArticles} /> </div> ); }; @@ -34,7 +88,31 @@ export default ArticleListPage; export const Container = styled.div` display: flex; - flex-direction: column; - align-items: flex-end; + flex-direction: row; + justify-content: space-between; margin-bottom: 20px; + gap: 15px; +`; + +const FilteringWrapper = styled.div` + display: flex; +`; + +const SelectBoxWrapper = styled.div` + width: 150px; +`; + +export const WriteButtonStyle = css` + width: 100px; + height: 42px; + padding: 0.2rem 0.8rem; + + border-radius: 0.6rem; + font-size: 1.6rem; + + color: ${COLOR.WHITE}; + + :hover { + background-color: ${COLOR.DARK_BLUE_600}; + } `;