-
Notifications
You must be signed in to change notification settings - Fork 46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[이주훈]sprint10 #329
The head ref may contain hidden characters: "Next-\uC774\uC8FC\uD6C8-sprint10"
[이주훈]sprint10 #329
Changes from all commits
99fe8e1
c02a183
9f2881b
7bdb002
a2912f2
449851a
cd0aad7
131ab90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. query가 string으로 변환되기 때문에 String으로 변환하지 않으셔도 될 것 같은데, 에러가 발생하지 않는다면 String은 빼주셔도 될 것 같습니다. |
||
|
||
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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { format, differenceInDays, differenceInHours, differenceInMinutes, differenceInSeconds, } from "date-fns"; | ||
|
||
export const TimestampCal = (dateString: Date) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 컴포넌트는 문자열만 반환해주는데, 이럴때는 컴포넌트 보다는 함수로 사용하셔도 좋습니다. |
||
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"); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ImgUploadProps> = ({ title }) => { | ||
const [imgPreviewUrl, setImgPreviewUrl] = useState(''); | ||
|
||
const handleImgChange = (e: ChangeEvent<HTMLInputElement>) => { | ||
const file= e.target.files?.[0]; | ||
|
||
if(file) { | ||
const imgUrl = URL.createObjectURL(file); | ||
setImgPreviewUrl(imgUrl); | ||
} | ||
} | ||
|
||
const handleDelete = () => { | ||
setImgPreviewUrl(''); // 미리보기 URL 리셋 | ||
}; | ||
|
||
return ( | ||
<div> | ||
{title && <Label>{title}</Label>} | ||
<ImgUploadContainer> | ||
<UploadLabel htmlFor='img-upload'> | ||
<AddIcon /> | ||
이미지 등록 | ||
</UploadLabel> | ||
|
||
<HiddenFileInput | ||
id='img-upload' | ||
type='file' | ||
onChange={handleImgChange} | ||
accept='image/*' | ||
/> | ||
|
||
{imgPreviewUrl && ( | ||
<ImgPreview src={imgPreviewUrl}> | ||
<DeleteBtnSection> | ||
<DeleteBtn onClick={handleDelete}> | ||
<DelIcon /> | ||
</DeleteBtn> | ||
</DeleteBtnSection> | ||
</ImgPreview> | ||
)} | ||
</ImgUploadContainer> | ||
|
||
|
||
</div> | ||
); | ||
} | ||
|
||
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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLInputElement | HTMLTextAreaElement>) => void; | ||
placeholder: string; | ||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void; | ||
isTextArea?: boolean; | ||
errorMessage?: string; | ||
type?: string; | ||
} | ||
|
||
const InputItem: React.FC<InputItemProps> = ({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 내부에 textarea와 input 컴포넌트가 각각 존재하기 때문에 이런 경우에는 두 컴포넌트로 분리해주시는 것도 좋습니다. |
||
id, | ||
label, | ||
value, | ||
onChange, | ||
placeholder, | ||
onKeyDown, | ||
isTextArea, | ||
type = "text", | ||
}) => { | ||
return ( | ||
<div> | ||
{label && <Label htmlFor={id}>{label}</Label>} | ||
|
||
{isTextArea ? ( | ||
<TextSection | ||
id={id} | ||
value={value} | ||
onChange={onChange} | ||
placeholder={placeholder} | ||
/> | ||
) : ( | ||
<InputSection | ||
id={id} | ||
value={value} | ||
onChange={onChange} | ||
onKeyDown={onKeyDown} | ||
placeholder={placeholder} | ||
type={type} | ||
/> | ||
)} | ||
</div> | ||
); | ||
} | ||
|
||
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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<Container> | ||
<form> | ||
<TitleSection> | ||
<Title>게시글 쓰기</Title> | ||
<Button type="submit" disabled={isSubmitDisabled}> | ||
등록 | ||
</Button> | ||
</TitleSection> | ||
|
||
<InputSection> | ||
<InputItem | ||
id="title" | ||
label="*제목" | ||
value={title} | ||
onChange={(e) => setTitle(e.target.value)} | ||
placeholder="제목을 입력해 주세요" | ||
/> | ||
|
||
<InputItem | ||
id="content" | ||
label="*내용" | ||
value={content} | ||
onChange={(e) => setContent(e.target.value)} | ||
placeholder="내용을 입력해 주세요" | ||
isTextArea | ||
/> | ||
|
||
<ImgUpload title="이미지" /> | ||
</InputSection> | ||
</form> | ||
</Container> | ||
) | ||
}; | ||
|
||
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
도메인 주소는 .env로 따로 관리해주셔도 좋을 것 같습니다.