diff --git a/.eslintrc.json b/.eslintrc.json index 0438653ca..bd9072b33 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -33,6 +33,8 @@ { "labelAttributes": ["htmlFor"] } - ] + ], + "consistent-return": "off", + "react/require-default-props": "off" } } diff --git a/components/BestBoards.tsx b/components/BestBoards.tsx index 392aff461..662aa5ce2 100644 --- a/components/BestBoards.tsx +++ b/components/BestBoards.tsx @@ -1,51 +1,51 @@ -import getArticles, { Article } from '@/pages/api/client'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { DeviceContext } from '@/contexts/DeviceContext'; +import getArticles from '@/lib/api/getArticles'; +import Link from 'next/link'; +import { Article } from '@/types/Article'; import BestMedal from './boards/BestMedal'; import BestContent from './boards/BestContent'; import BestInfo from './boards/BestInfo'; +const pageSizeMap = { + mobile: 1, + tablet: 2, + desktop: 3, +}; + function BestBoards() { const device = useContext(DeviceContext); const [boards, setBoards] = useState([]); - const pageSizeByDevice = useCallback(() => { - if (device === 'mobile') return 1; - if (device === 'tablet') return 2; - return 3; - }, [device]); - useEffect(() => { const handleLoad = async () => { const { list } = await getArticles({ page: 1, - pageSize: pageSizeByDevice(), + pageSize: pageSizeMap[device], orderBy: 'like', }); - setBoards(() => list); + setBoards(list); }; handleLoad(); - }, [pageSizeByDevice]); - - if (!boards) return null; + }, [device]); return ( -
+

베스트 게시글

-
- {boards && - boards.map(board => ( -
-
- - - -
+
+ {boards.map(board => ( + +
+ + +
- ))} + + ))}
); diff --git a/components/FileInput.tsx b/components/FileInput.tsx new file mode 100644 index 000000000..4f1ff660d --- /dev/null +++ b/components/FileInput.tsx @@ -0,0 +1,76 @@ +import Image from 'next/image'; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; + +type Props = { + value: Blob | MediaSource | null; + name: string; + onChange: (name: string, file: File | null) => void; +}; + +function FileInput({ value, name, onChange }: Props) { + const [preview, setPreview] = useState(''); + const fileInputRef = useRef(null); + + const handleFileInputChange = (e: ChangeEvent) => { + if (e.target.files) { + const nextFile = e.target.files[0]; + onChange(name, nextFile); + } + }; + + const handleFileInputCancel = () => { + onChange(name, null); + }; + + useEffect(() => { + if (!value) return; + + const nextPreview = URL.createObjectURL(value); + setPreview(nextPreview); + + return () => { + setPreview(''); + URL.revokeObjectURL(nextPreview); + }; + }, [value]); + + return ( +
+ + {preview && ( +
+ preview + cancel +
+ )} + +
+ ); +} + +export default FileInput; diff --git a/components/Header/index.tsx b/components/Header/index.tsx index fff99f7f6..1f0b7c3ac 100644 --- a/components/Header/index.tsx +++ b/components/Header/index.tsx @@ -1,27 +1,56 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useContext, useEffect, useState } from 'react'; import Image from 'next/image'; import VerticalDivider from '@/components/elements/VerticalDivider'; +import Link from 'next/link'; +import { DeviceContext } from '@/contexts/DeviceContext'; +import { useAuth } from '@/contexts/AuthProvider'; +import Button from '../elements/button/Button'; function Header({ children }: PropsWithChildren) { + const { user } = useAuth(); + const [mount, setMount] = useState(false); + const device = useContext(DeviceContext); + + useEffect(() => { + setMount(true); + }, []); + return (
-
-
- logo -
+
+
+ + {mount && device === 'mobile' ? ( + logo + ) : ( + logo + )} + +
{children}
- profile + {mount && user ? ( + profile + ) : ( + + + + )}
diff --git a/components/MainBoards.tsx b/components/MainBoards.tsx index 7654798a8..76101623b 100644 --- a/components/MainBoards.tsx +++ b/components/MainBoards.tsx @@ -1,63 +1,65 @@ -import getArticles, { Article, ArticlesQuery } from '@/pages/api/client'; import React, { useCallback, useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; +import Link from 'next/link'; +import { Article, GetArticlesQuery } from '@/types/Article'; +import getArticles from '@/lib/api/getArticles'; import RecentContent from './boards/MainContent'; import RecentInfo from './boards/MainInfo'; import VerticalDivider from './elements/VerticalDivider'; import BoardTitle from './boards/BoardTitle'; function MainBoards() { - const router = useRouter(); const [boards, setBoards] = useState([]); const [keyword, setKeyword] = useState(''); - const [orderBy, setOrderBy] = useState('recent'); - const [isLoading, setIsLoading] = useState(true); + const [orderBy, setOrderBy] = useState('recent'); + const [isLoading, setIsLoading] = useState(false); - const handleLoad = useCallback(async () => { + const fetchArticles = useCallback(async () => { const query = { page: 1, pageSize: 6, orderBy, keyword, }; - const { list } = await getArticles(query); - router.push({ - query, - }); - setBoards(() => list); - }, [keyword, orderBy, router]); - - useEffect(() => { - if (isLoading === true) { - handleLoad(); - setIsLoading(() => false); + if (isLoading === false) { + setIsLoading(true); + const { list } = await getArticles(query); + setIsLoading(false); + setBoards(list); } - }, [isLoading, keyword, orderBy, handleLoad]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyword, orderBy]); - if (!boards) return null; + useEffect(() => { + fetchArticles(); + }, [fetchArticles]); return ( -
+
-
- {boards && - boards.map((board, i) => ( +
+ {boards.map((board, i) => { + const isLastArticle = i !== boards.length - 1; + return ( <> -
+
-
- {i !== boards.length - 1 && } + + {isLastArticle && } - ))} + ); + })}
); diff --git a/components/article/ArticleComments.tsx b/components/article/ArticleComments.tsx new file mode 100644 index 000000000..740fd896c --- /dev/null +++ b/components/article/ArticleComments.tsx @@ -0,0 +1,79 @@ +import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import getCommentsByArticleId from '@/lib/api/getCommentsByArticleId'; +import { useRouter } from 'next/router'; +import { ArticleComment } from '@/types/Article'; +import postArticleComment from '@/lib/api/postArticleComment'; +import TextAreaElement from '../elements/TextAreaElement'; +import SubmitButton from '../elements/button/SubmitButton'; +import CommentList from '../comments/CommentList'; +import BackLinkButton from '../elements/BackLinkButton'; + +function ArticleComments() { + const [input, setInput] = useState(''); + const router = useRouter(); + const { id } = router.query; + const [comments, setComments] = useState([]); + + const validationSubmit = input; + + const handleInputChange = (e: ChangeEvent) => { + const { value } = e.target; + setInput(value); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const { data } = await postArticleComment(id as string, { + content: input, + }); + if (!data.id) return; + setComments(prev => [data, ...prev]); + setInput(''); + }; + + useEffect(() => { + const fetchCommentsByArticleId = async () => { + if (id) { + const { data } = await getCommentsByArticleId(id as string, { + limit: 3, + }); + setComments(data.list); + } + }; + fetchCommentsByArticleId(); + }, [id]); + + return ( + <> +
+ + + + 등록 + + + + 아직 댓글이 없어요, + 지금 댓글을 달아보세요! + + } + comments={comments} + /> + + + ); +} + +export default ArticleComments; diff --git a/components/article/ArticleInfo.tsx b/components/article/ArticleInfo.tsx new file mode 100644 index 000000000..eb391751a --- /dev/null +++ b/components/article/ArticleInfo.tsx @@ -0,0 +1,74 @@ +import getArticleById from '@/lib/api/getArticleById'; +import { Article } from '@/types/Article'; +import Image from 'next/image'; +import React, { useCallback, useEffect, useState } from 'react'; +import dayjs from 'dayjs'; +import HorizentalBar from '../elements/HorizentalBar'; +import VerticalDivider from '../elements/VerticalDivider'; + +type Props = { + articleId: string; +}; + +function ArticleInfo({ articleId }: Props) { + const [article, setArticle] = useState
(null); + + const fetchGetArticle = useCallback(async () => { + if (articleId) { + const { data } = await getArticleById(articleId as string); + setArticle(data); + } + }, [articleId]); + + useEffect(() => { + fetchGetArticle(); + }, [fetchGetArticle]); + + if (!article) return null; + + const { title, writer, image, content, likeCount, updatedAt } = article; + + return ( + <> +
+

{title}

+ options +
+
+ profile +
+ {writer.nickname} + + {dayjs(updatedAt).format('YYYY. MM. DD')} + +
+ + +
+ +
+

{content}

+
+ + ); +} + +export default ArticleInfo; diff --git a/components/boards/BestContent.tsx b/components/boards/BestContent.tsx index 408572893..40fe78c48 100644 --- a/components/boards/BestContent.tsx +++ b/components/boards/BestContent.tsx @@ -1,4 +1,4 @@ -import { Article } from '@/pages/api/client'; +import { Article } from '@/lib/api'; import Image from 'next/image'; type Props = { @@ -7,7 +7,7 @@ type Props = { function BestContent({ board }: Props) { return ( -
+

{board.title}

diff --git a/components/boards/BestInfo.tsx b/components/boards/BestInfo.tsx index 56c5a7f3f..a388aa92a 100644 --- a/components/boards/BestInfo.tsx +++ b/components/boards/BestInfo.tsx @@ -1,4 +1,4 @@ -import { Article } from '@/pages/api/client'; +import { Article } from '@/lib/api'; import dayjs from 'dayjs'; import Image from 'next/image'; diff --git a/components/boards/BestMedal.tsx b/components/boards/BestMedal.tsx index c162d59b9..2f33bf42e 100644 --- a/components/boards/BestMedal.tsx +++ b/components/boards/BestMedal.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; function BestMedal() { return ( -
+
best Best
diff --git a/components/boards/BoardTitle.tsx b/components/boards/BoardTitle.tsx index c91fa1d3c..964036c62 100644 --- a/components/boards/BoardTitle.tsx +++ b/components/boards/BoardTitle.tsx @@ -1,7 +1,14 @@ import { DeviceContext } from '@/contexts/DeviceContext'; -import { ArticlesQuery } from '@/pages/api/client'; +import { ArticlesQuery } from '@/lib/api'; import Image from 'next/image'; -import { ChangeEvent, FormEvent, useContext, useEffect, useState } from 'react'; +import { + ChangeEvent, + FormEvent, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; type Props = { keyword: string; @@ -10,7 +17,6 @@ type Props = { onChangeOrderBy: React.Dispatch< React.SetStateAction >; - setIsLoading: React.Dispatch>; }; function BoardTitle({ @@ -18,15 +24,15 @@ function BoardTitle({ orderBy, onChangeKeyword, onChangeOrderBy, - setIsLoading, }: Props) { const device = useContext(DeviceContext); const [isOpen, setIsOpen] = useState(false); const [mounted, setMounted] = useState(false); - let orderName; - if (orderBy === 'recent') orderName = '최신순'; - else orderName = '좋아요순'; + const orderName = useMemo(() => { + if (orderBy === 'recent') return '최신순'; + return '좋아요순'; + }, [orderBy]); const handleIsOpen = () => { setIsOpen(p => !p); @@ -39,16 +45,12 @@ function BoardTitle({ const handleSubmit = (e: FormEvent) => { e.preventDefault(); - setIsLoading(() => true); }; const handleChangeOrderBy = (e: React.MouseEvent) => { const { currentTarget } = e; const order = currentTarget.dataset.order as ArticlesQuery['orderBy']; - onChangeOrderBy(prev => { - if (prev !== order) setIsLoading(() => true); - return order; - }); + onChangeOrderBy(order); }; useEffect(() => { @@ -66,7 +68,7 @@ function BoardTitle({ 글쓰기
-
+
diff --git a/components/boards/MainInfo.tsx b/components/boards/MainInfo.tsx index 101fdf3f9..9202e728b 100644 --- a/components/boards/MainInfo.tsx +++ b/components/boards/MainInfo.tsx @@ -1,5 +1,4 @@ -import { Article } from '@/pages/api/client'; - +import { Article } from '@/types/Article'; import dayjs from 'dayjs'; import Image from 'next/image'; import React from 'react'; @@ -11,7 +10,7 @@ type Props = { function RecentInfo({ board }: Props) { const createDate = dayjs(board.createdAt).format('YYYY. MM. DD'); return ( -
+
profile {board.writer.nickname} {createDate} diff --git a/components/comments/CommentList.tsx b/components/comments/CommentList.tsx new file mode 100644 index 000000000..d9e2a9973 --- /dev/null +++ b/components/comments/CommentList.tsx @@ -0,0 +1,72 @@ +import { ArticleComment } from '@/types/Article'; +import Image from 'next/image'; +import { ReactElement } from 'react'; +import VerticalDivider from '@/components/elements/VerticalDivider'; +import formatTimeFromNow from '@/lib/utils/fotmatTimeFromNow'; + +type Props = { + comments: ArticleComment[]; + imageWhenEmpty?: string; + textWhenEmpty?: ReactElement; +}; + +function CommentList({ comments, imageWhenEmpty, textWhenEmpty }: Props) { + if (comments.length === 0) + return ( +
+ empty reply +

+ {textWhenEmpty ?? ( + <> + 아직 댓글이 없어요, + 지금 댓글을 달아보세요! + + )} +

+
+ ); + + return ( +
    + {comments.map(comment => ( +
  • +
    +
    +

    {comment.content}

    + options +
    +
    + profile +
    + + {comment.writer.nickname} + + + {formatTimeFromNow(comment.updatedAt)} + +
    +
    +
    + +
  • + ))} +
+ ); +} + +export default CommentList; diff --git a/components/container/MainContainer.tsx b/components/container/MainContainer.tsx new file mode 100644 index 000000000..0d25ab3c1 --- /dev/null +++ b/components/container/MainContainer.tsx @@ -0,0 +1,20 @@ +import cn from '@/lib/utils'; +import { PropsWithChildren } from 'react'; + +function MainContainer({ + children, + className, +}: PropsWithChildren<{ className?: string }>) { + return ( +
+ {children} +
+ ); +} + +export default MainContainer; diff --git a/components/elements/BackLinkButton.tsx b/components/elements/BackLinkButton.tsx new file mode 100644 index 000000000..2f3eaaab2 --- /dev/null +++ b/components/elements/BackLinkButton.tsx @@ -0,0 +1,27 @@ +import Image from 'next/image'; +import router from 'next/router'; +import Button from './button/Button'; + +type Props = { + className?: string; +}; + +function BackLinkButton({ className = '' }: Props) { + const handleButtonClick = () => { + router.back(); + }; + + return ( + + ); +} + +export default BackLinkButton; diff --git a/components/elements/HeaderContainer.tsx b/components/elements/HeaderContainer.tsx index 2e6c34bb0..130630392 100644 --- a/components/elements/HeaderContainer.tsx +++ b/components/elements/HeaderContainer.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren } from 'react'; function HeaderContainer({ children }: PropsWithChildren) { return ( -
+
logo
{children} diff --git a/components/elements/HorizentalBar.tsx b/components/elements/HorizentalBar.tsx new file mode 100644 index 000000000..de8447d70 --- /dev/null +++ b/components/elements/HorizentalBar.tsx @@ -0,0 +1,8 @@ +import cn from '@/lib/utils'; +import React from 'react'; + +function HorizentalBar({ className }: { className?: string }) { + return
; +} + +export default HorizentalBar; diff --git a/components/elements/InputElement.tsx b/components/elements/InputElement.tsx new file mode 100644 index 000000000..6a877788b --- /dev/null +++ b/components/elements/InputElement.tsx @@ -0,0 +1,34 @@ +import cn from '@/lib/utils'; +import React, { ChangeEvent } from 'react'; + +type Props = { + value: string; + onChange: (e: ChangeEvent) => void; + name: string; + className?: string; + placeholder: string; +}; + +function InputElement({ + value, + onChange, + name, + className, + placeholder, +}: Props) { + return ( + + ); +} + +export default InputElement; diff --git a/components/elements/TextAreaElement.tsx b/components/elements/TextAreaElement.tsx new file mode 100644 index 000000000..bf65d81a8 --- /dev/null +++ b/components/elements/TextAreaElement.tsx @@ -0,0 +1,35 @@ +import cn from '@/lib/utils'; +import React, { ChangeEvent } from 'react'; + +type Props = { + value: string; + onChange: (e: ChangeEvent) => void; + name: string; + className?: string; + placeholder: string; +}; + +function TextAreaElement({ + value, + onChange, + name, + className, + placeholder, +}: Props) { + return ( +