diff --git a/api/articleApi.ts b/api/articleApi.ts new file mode 100644 index 000000000..cbd396229 --- /dev/null +++ b/api/articleApi.ts @@ -0,0 +1,32 @@ +export async function getArticleDetail(articleId: number) { + try { + const response = await fetch(`https://panda-market-api.vercel.app/articles/${articleId}`); + + if (!response.ok) throw new Error(`error: ${response.status}`); + + const body = await response.json(); + + return body; + } catch (e) { + console.error("error:", e); + throw e; + } +} + +export async function getArticleComment({ articleId, limit = 10, }: { articleId: number; limit?: number;}) { + const params = { limit: String(limit), }; + + try { + const query = new URLSearchParams(params).toString(); + const response = await fetch(`https://panda-market-api.vercel.app/articles/${articleId}/comments?${query}`); + + if (!response.ok) throw new Error(`error: ${response.status}`); + + const body = await response.json(); + + return body; + } catch (e) { + console.error("Failed to fetch article comments:", e); + throw e; + } +} diff --git a/api/itemApi.ts b/api/itemApi.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/components/TimestampCal.tsx b/components/TimestampCal.tsx new file mode 100644 index 000000000..6c5d28ab8 --- /dev/null +++ b/components/TimestampCal.tsx @@ -0,0 +1,17 @@ +import { format, differenceInDays, differenceInHours, differenceInMinutes, differenceInSeconds, } from "date-fns"; + +export const TimestampCal = (dateString: Date) => { + const date = new Date(dateString); + const now = new Date(); + + const Day = differenceInDays(now, date); + const Hour = differenceInHours(now, date); + const Minute = differenceInMinutes(now, date); + const Sec = differenceInSeconds(now, date); + + if (Sec < 60) return "방금 전"; + else if (Minute < 60) return `${Minute}분 전`; + else if (Hour < 24) return `${Hour}시간 전`; + else if (Day < 7) return `${Day}일 전`; + else return format(date, "yyyy.MM.dd hh:mm a"); +}; \ No newline at end of file diff --git a/pages/addBoard/ImgUpload.tsx b/pages/addBoard/ImgUpload.tsx new file mode 100644 index 000000000..09c3dbb7b --- /dev/null +++ b/pages/addBoard/ImgUpload.tsx @@ -0,0 +1,123 @@ +import { ChangeEvent, useState } from 'react'; +import styled from 'styled-components'; +import AddIcon from '@/public/images/icons/ic_add.svg'; +import DelIcon from '@/public/images/icons/ic_del.svg'; + +interface ImgUploadProps { + title: string; +} + +const ImgUpload: React.FC = ({ title }) => { + const [imgPreviewUrl, setImgPreviewUrl] = useState(''); + + const handleImgChange = (e: ChangeEvent) => { + const file= e.target.files?.[0]; + + if(file) { + const imgUrl = URL.createObjectURL(file); + setImgPreviewUrl(imgUrl); + } + } + + const handleDelete = () => { + setImgPreviewUrl(''); // 미리보기 URL 리셋 + }; + + return ( +
+ {title && } + + + + 이미지 등록 + + + + + {imgPreviewUrl && ( + + + + + + + + )} + + + +
+ ); +} + +const Label = styled.label` + display: block; + font-size: 14px; + font-weight: bold; + margin-bottom: 12px; + @media (min-width: 768px) { + font-size: 18px; + } +`; + +const ImgUploadContainer = styled.div` + display: flex; + gap: 10px; + @media (min-width: 768px) { + gap: 24px; + } +`; + +const UploadLabel = styled.label` + background-color:#F3F4F6; + color: #9CA3AF; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 16px; + width: 282px; + aspect-ratio: 1 / 1; // 정사각형 + border-radius: 12px; + &:hover { + background-color: #F9FAFB; + } +`; + +const HiddenFileInput = styled.input` + display: none; +` +// src를 props로 전달받아 background 처리 +const ImgPreview = styled.div<{ src: string }>` + background-image: url(${({ src }) => src}); + background-size: cover; + background-position: center; + position: relative; + width: 282px; + aspect-ratio: 1 / 1; + border-radius: 12px; +`; + +const DeleteBtnSection = styled.div` + position: absolute; + top: 12px; + right: 12px; +` + +const DeleteBtn = styled.button` + background-color: #9CA3AF; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; +`; + +export default ImgUpload; \ No newline at end of file diff --git a/pages/addBoard/InputItem.tsx b/pages/addBoard/InputItem.tsx new file mode 100644 index 000000000..2eae1f206 --- /dev/null +++ b/pages/addBoard/InputItem.tsx @@ -0,0 +1,96 @@ +import styled from "styled-components"; +import { ChangeEvent, KeyboardEvent } from "react"; + +interface InputItemProps { + id: string; + label: string; + value: string; + onChange: (e: ChangeEvent) => void; + placeholder: string; + onKeyDown?: (e: KeyboardEvent) => void; + isTextArea?: boolean; + errorMessage?: string; + type?: string; +} + +const InputItem: React.FC = ({ + id, + label, + value, + onChange, + placeholder, + onKeyDown, + isTextArea, + type = "text", +}) => { + return ( +
+ {label && } + + {isTextArea ? ( + + ) : ( + + )} +
+ ); +} + +const Label = styled.label` + display: block; + font-size: 14px; + font-weight: bold; + margin-bottom: 12px; + @media (min-width: 768px) { + font-size: 18px; + } +`; + +const InputSection = styled.input` + padding: 16px 24px; + background-color: #F3F4F6; + color: #1F2937; + border: none; + border-radius: 12px; + font-size: 16px; + line-height: 24px; + width: 100%; + &::placeholder { + color: #9CA3AF; + } + &:focus { + outline-color: #3692FF; + } +`; + +const TextSection = styled.textarea` + padding: 16px 24px; + background-color: #F3F4F6; + color: #1F2937; + border: none; + border-radius: 12px; + font-size: 16px; + line-height: 24px; + width: 100%; + height: 200px; + &::placeholder { + color: #9CA3AF; + } + &:focus { + outline-color: #3692FF; + } +`; + +export default InputItem; \ No newline at end of file diff --git a/pages/addBoard/index.tsx b/pages/addBoard/index.tsx new file mode 100644 index 000000000..69e108c17 --- /dev/null +++ b/pages/addBoard/index.tsx @@ -0,0 +1,113 @@ +import styled from 'styled-components'; +import { FormEvent, useState } from 'react'; +import InputItem from './InputItem'; +import ImgUpload from './ImgUpload'; + +const AddBoard = () => { + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + + const isSubmitDisabled = !title.trim() ||! !content.trim(); + + return ( + +
+ + 게시글 쓰기 + + + + + setTitle(e.target.value)} + placeholder="제목을 입력해 주세요" + /> + + setContent(e.target.value)} + placeholder="내용을 입력해 주세요" + isTextArea + /> + + + +
+
+ ) +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + padding: 16px; + + @media (min-width: 768px) { + padding: 16px 24px; + } + + @media (min-width: 1200px) { + max-width: 1200px; + padding: 24px 0; + margin: 0 auto; + } +`; + +const TitleSection = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +`; + +const Title = styled.h1` + font-size: 20px; + font-weight: bold; + color: ${({ theme }) => theme.colors.gray[800]}; + + @media (min-width: 768px) { + font-size: 28px; + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.blue.primary}; + color: ${({ theme }) => theme.colors.white}; + padding: 11px 23px; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + align-self: flex-end; + font-weight: 600; + font-size: 14px; + + @media (min-width: 768px) { + font-size: 16px; + } + + &:disabled { + background-color: ${({ theme }) => theme.colors.gray[400]}; + cursor: default; + pointer-events: none; + } +`; + +const InputSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + + @media (min-width: 768px) { + gap: 24px; + } +`; + +export default AddBoard; \ No newline at end of file diff --git a/pages/board/[id].tsx b/pages/board/[id].tsx deleted file mode 100644 index 322cb654e..000000000 --- a/pages/board/[id].tsx +++ /dev/null @@ -1,27 +0,0 @@ -import styled from 'styled-components'; -import { useRouter } from 'next/router'; - -const BoardsPage = () => { - const router = useRouter(); - const { id } = router.query; - - return {id}번 게시글 페이지; -}; - -const Container = styled.div` - display: flex; - flex-direction: column; - padding: 16px; - - @media (min-width: 768px) { - padding: 16px 24px; - } - - @media (min-width: 1200px) { - max-width: 1200px; - padding: 24px 0; - margin: 0 auto; - } -`; - -export default BoardsPage; diff --git a/pages/board/AllArticleSection.tsx b/pages/boards/AllArticleSection.tsx similarity index 98% rename from pages/board/AllArticleSection.tsx rename to pages/boards/AllArticleSection.tsx index fb2b7d7a2..f0c819c96 100644 --- a/pages/board/AllArticleSection.tsx +++ b/pages/boards/AllArticleSection.tsx @@ -158,7 +158,7 @@ const AllArticleSection: React.FC = ({ <> 게시글 - 글쓰기 + 글쓰기 diff --git a/pages/boards/AritcleCommentThread.tsx b/pages/boards/AritcleCommentThread.tsx new file mode 100644 index 000000000..da8dc53f8 --- /dev/null +++ b/pages/boards/AritcleCommentThread.tsx @@ -0,0 +1,157 @@ +import { getArticleComment } from '@/api/articleApi'; +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import ProfileImg from '@/public/images/icons/ic_profile.svg'; +import { TimestampCal } from '@/components/TimestampCal' +import BackIcon from '@/public/images/icons/ic_back.svg'; +import Link from 'next/link'; +import SeeMoreIcon from '@/public/images/icons/ic_kebab.svg'; + +const SeeMoreButton = styled.button` + position: absolute; + right: 0; +`; + +interface Comment { + writer: { + image: string; + nickname: string; + id: number; + }; + updatedAt: Date; + createdAt: Date; + content: string; + id: number; +} + +interface CommentData { + nextCursor: number; + list: Comment[]; +} + +interface ArticleCommentThreadProps { + articleId: number; +} + +const ArticleCommentThread: React.FC = ({ articleId }) => { + const [comment, setComment] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchComment = async() => { + try { + const data: CommentData = await getArticleComment({ articleId }); + setComment(data.list); + setError(null); + } catch (e) { + setError(`${e}`); + } + }; + + fetchComment(); + }, [articleId]); + + if (error) console.log('error'); + + return ( + + {comment.map((item) => ( + + + + + + {item.content} + + + + + {item.writer.nickname} + {TimestampCal(item.updatedAt)} + + + + + + ))} + + + 목록으로 돌아가기 + + + + ); +} + +const Container = styled.div` + margin-bottom: 40px; +`; + +const CommentContainer = styled.div` + padding: 24px 0; + position: relative; +`; + +const CommentContent = styled.p` + font-size: 16px; + line-height: 140%; + margin-bottom: 24px; +`; + +const InfoSection = styled.div` + display: flex; + align-items: center; + gap: 8px; + +`; + +const UserDetails = styled.div` + display: flex; + flex-direction: column; +`; + +const Username = styled.span` + font-size: 14px; + color: var(--gray-400); +`; + +const Timestamp = styled.span` + font-size: 14px; + color: var(--gray-400); +`; + +const Line = styled.hr` + width: 100%; + border: none; + height: 1px; + background-color: var(--gray-200); + margin: 24px 0; +`; + +const BackToMarketPageLink = styled(Link)` + background-color: ${({ theme }) => theme.colors.blue.primary}; + color: ${({ theme }) => theme.colors.white}; + padding: 12px 23px; + border-radius: 999px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + max-width: 215px; + + &:hover { + background-color: ${({ theme }) => theme.colors.blue.primary}; + } + + &:focus { + background-color: ${({ theme }) => theme.colors.blue.primary}; + } + + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 600; + margin: 0 auto; +`; + +export default ArticleCommentThread; \ No newline at end of file diff --git a/pages/boards/ArticleCommentSection.tsx b/pages/boards/ArticleCommentSection.tsx new file mode 100644 index 000000000..26fde61e5 --- /dev/null +++ b/pages/boards/ArticleCommentSection.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent, useState } from 'react'; +import styled from 'styled-components'; +import AritcleCommentThread from './AritcleCommentThread'; + +interface ArticleCommentSectionProps { + articleId: number; +} + +const ArticleCommentSection: React.FC = ({ articleId }) => { + const [comment, setComment] = useState(''); + + const handleInputChange = (e: ChangeEvent) => { + setComment(e.target.value); + }; + + return ( + <> + + 댓글달기 + +