diff --git a/src/components/ArticleItemCard.module.css b/src/components/ArticleItemCard.module.css index 2957fa4db..338bfbda4 100644 --- a/src/components/ArticleItemCard.module.css +++ b/src/components/ArticleItemCard.module.css @@ -8,6 +8,10 @@ border-bottom: 1px solid #e5e7eb; } +.link { + text-decoration: none; +} + .content_wrapper { display: flex; justify-content: space-between; diff --git a/src/components/ArticleItemCard.tsx b/src/components/ArticleItemCard.tsx index eb8784553..20d562961 100644 --- a/src/components/ArticleItemCard.tsx +++ b/src/components/ArticleItemCard.tsx @@ -4,29 +4,32 @@ import profileImg from "@/assets/images/profile.svg"; import heartIcon from "@/assets/icons/heart.svg"; import Image from "next/image"; import { formatDate } from "@/utils/formattedDate"; +import Link from "next/link"; -export function ArticleItemCard({ title, content, image, likeCount, writer, updatedAt }: Article) { +export function ArticleItemCard({ id, title, content, image, likeCount, writer, updatedAt }: Article) { const formattedDate = formatDate(updatedAt); return (
-
-

{content}

-
- {title} + +
+

{content}

+
+ {title} +
-
-
-
- 프로필 -

{writer.nickname}

-

{formattedDate}

+
+
+ 프로필 +

{writer.nickname}

+

{formattedDate}

+
+
+ 좋아요 +

{likeCount}

+
-
- 좋아요 -

{likeCount}

-
-
+
); } diff --git a/src/components/BestItemCard.module.css b/src/components/BestItemCard.module.css index 36488dc27..79869e6f8 100644 --- a/src/components/BestItemCard.module.css +++ b/src/components/BestItemCard.module.css @@ -8,6 +8,10 @@ background-color: #f9fafb; } +.link { + text-decoration: none; +} + .content_wrapper { display: flex; gap: 20px; diff --git a/src/components/BestItemCard.tsx b/src/components/BestItemCard.tsx index f2db61ad7..bb49829e9 100644 --- a/src/components/BestItemCard.tsx +++ b/src/components/BestItemCard.tsx @@ -4,29 +4,32 @@ import style from "./BestItemCard.module.css"; import badge from "@/assets/images/img_badge.svg"; import heart from "@/assets/icons/heart.svg"; import { formatDate } from "@/utils/formattedDate"; +import Link from "next/link"; -export default function BestItemCard({ content, updatedAt, likeCount, writer, image, title }: Article) { +export default function BestItemCard({ id, content, updatedAt, likeCount, writer, image, title }: Article) { const formattedDate = formatDate(updatedAt); return ( -
- Best -
-

{content}

-
- {title} -
-
-
-
-

{writer.nickname}

+ +
+ Best +
+

{content}

- 좋아요 -

{likeCount}

+ {title} +
+
+
+
+

{writer.nickname}

+
+ 좋아요 +

{likeCount}

+
+

{formattedDate}

-

{formattedDate}

-
+ ); } diff --git a/src/components/BoardHeader.tsx b/src/components/BoardHeader.tsx index f434c68e9..6c65c82ca 100644 --- a/src/components/BoardHeader.tsx +++ b/src/components/BoardHeader.tsx @@ -5,7 +5,7 @@ export default function BoardHeader() { const router = useRouter(); const handleClick = () => { - router.push("/boards/write"); + router.push("/addboard"); }; return ( diff --git a/src/components/CommentCard.module.css b/src/components/CommentCard.module.css new file mode 100644 index 000000000..a62f7abf7 --- /dev/null +++ b/src/components/CommentCard.module.css @@ -0,0 +1,43 @@ +.container { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 12px; + background-color: #fcfcfc; + border-bottom: 1px solid #e5e7eb; +} + +.wrapper { + display: flex; + flex-direction: column; + gap: 24px; +} + +.content { + font-size: 14px; + line-height: 24px; + color: #1f2937; +} + +.info { + display: flex; + gap: 8px; +} + +.info div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.nickname { + font-size: 12px; + line-height: 18px; + color: #4b5563; +} + +.date { + font-size: 12px; + line-height: 18px; + color: #9ca3af; +} \ No newline at end of file diff --git a/src/components/CommentCard.tsx b/src/components/CommentCard.tsx new file mode 100644 index 000000000..5167d4c6e --- /dev/null +++ b/src/components/CommentCard.tsx @@ -0,0 +1,28 @@ +import style from "./CommentCard.module.css"; +import ProfileImg from "@/assets/images/profile.svg"; +import { Comment } from "@/types"; +import { formatRelativeTime } from "@/utils/formatRelativeTime"; +import Image from "next/image"; + +interface CommentProps { + comment: Comment; +} + +export default function CommentCard({ comment }: CommentProps) { + const formatDate = formatRelativeTime(comment.updatedAt); + + return ( +
+
+

{comment.content}

+
+ 프로필이미지 +
+

{comment.writer.nickname}

+

{formatDate}

+
+
+
+
+ ); +} diff --git a/src/components/LoginForm.module.css b/src/components/LoginForm.module.css new file mode 100644 index 000000000..d4bbf5a81 --- /dev/null +++ b/src/components/LoginForm.module.css @@ -0,0 +1,118 @@ +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.label { + font-size: 0.875rem; + font-weight: 500; +} + +.inputContainer { + position: relative; +} + +.inputField { + width: 100%; + padding: 0.75rem 2.5rem 0.75rem 0.75rem; + background-color: #f9fafb; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 1rem; +} + +.inputField:focus { + outline: 2px solid #3692ff; + outline-offset: -2px; +} + +.toggleButton { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + color: #6b7280; +} + +.button { + width: 100%; + padding: 0.75rem; + background-color: #9ca3af; + color: #ffffff; + border: none; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + margin-top: 1rem; +} + +.button:hover:not(:disabled) { + background-color: #6b7280; +} + +.button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +.divider { + position: relative; + width: 100%; + height: 1px; + background-color: #d1d5db; + margin: 2rem 0; +} + +.dividerText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #ffffff; + padding: 0 0.5rem; + font-size: 0.875rem; + color: #6b7280; +} + +.socialButtons { + display: flex; + gap: 1rem; + justify-content: center; + margin-bottom: 1rem; +} + +.socialButton { + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 9999px; + cursor: pointer; + transition: background-color 0.2s; +} + +.socialButton:hover { + background-color: #f9fafb; +} + +.socialImg { + width: 1.5rem; + height: 1.5rem; +} + +.prompt { + text-align: center; + font-size: 0.875rem; + color: #4b5563; +} + +.prompt a { + color: #3692ff; + text-decoration: underline; + cursor: pointer; +} \ No newline at end of file diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 000000000..607c4ef03 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,79 @@ +import styles from "./LoginForm.module.css"; +import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; + +interface LoginFormProps { + onSubmit: (formData: { email: string; password: string }) => Promise; +} + +export default function LoginForm({ onSubmit }: LoginFormProps) { + const [showPassword, setShowPassword] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit({ email, password }); + }; + + return ( +
+
+ +
+ setEmail(e.target.value)} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + /> + +
+
+ + + +
+ 간편 로그인하기 +
+ +
+ + +
+ +
+ 판다마켓이 처음이신가요? 회원가입 +
+
+ ); +} diff --git a/src/components/SignUpForm.module.css b/src/components/SignUpForm.module.css new file mode 100644 index 000000000..d4bbf5a81 --- /dev/null +++ b/src/components/SignUpForm.module.css @@ -0,0 +1,118 @@ +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.label { + font-size: 0.875rem; + font-weight: 500; +} + +.inputContainer { + position: relative; +} + +.inputField { + width: 100%; + padding: 0.75rem 2.5rem 0.75rem 0.75rem; + background-color: #f9fafb; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 1rem; +} + +.inputField:focus { + outline: 2px solid #3692ff; + outline-offset: -2px; +} + +.toggleButton { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + color: #6b7280; +} + +.button { + width: 100%; + padding: 0.75rem; + background-color: #9ca3af; + color: #ffffff; + border: none; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + margin-top: 1rem; +} + +.button:hover:not(:disabled) { + background-color: #6b7280; +} + +.button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +.divider { + position: relative; + width: 100%; + height: 1px; + background-color: #d1d5db; + margin: 2rem 0; +} + +.dividerText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #ffffff; + padding: 0 0.5rem; + font-size: 0.875rem; + color: #6b7280; +} + +.socialButtons { + display: flex; + gap: 1rem; + justify-content: center; + margin-bottom: 1rem; +} + +.socialButton { + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 9999px; + cursor: pointer; + transition: background-color 0.2s; +} + +.socialButton:hover { + background-color: #f9fafb; +} + +.socialImg { + width: 1.5rem; + height: 1.5rem; +} + +.prompt { + text-align: center; + font-size: 0.875rem; + color: #4b5563; +} + +.prompt a { + color: #3692ff; + text-decoration: underline; + cursor: pointer; +} \ No newline at end of file diff --git a/src/components/SignUpForm.tsx b/src/components/SignUpForm.tsx new file mode 100644 index 000000000..c60bd9498 --- /dev/null +++ b/src/components/SignUpForm.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import styles from "./SignUpForm.module.css"; +import Image from "next/image"; +import Link from "next/link"; + +interface SignUpFormProps { + onSubmit: (formData: { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; + }) => Promise; +} + +export default function SignUpForm({ onSubmit }: SignUpFormProps) { + const [email, setEmail] = useState(""); + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + await onSubmit({ + email, + nickname, + password, + passwordConfirmation, + }); + }; + + return ( +
+
+ +
+ setEmail(e.target.value)} + /> +
+
+ +
+ +
+ setNickname(e.target.value)} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + /> + +
+
+ +
+ +
+ setPasswordConfirmation(e.target.value)} + /> + +
+
+ + + +
+ 간편 로그인하기 +
+ +
+ + +
+ +
+ 이미 회원이신가요? + 로그인 +
+
+ ); +} diff --git a/src/lib/fetchArticleById.ts b/src/lib/fetchArticleById.ts new file mode 100644 index 000000000..944511617 --- /dev/null +++ b/src/lib/fetchArticleById.ts @@ -0,0 +1,23 @@ +import { ArticleDetailResponse } from "@/types"; + +export async function fetchArticleById(id: number): Promise { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; + if (!baseUrl) { + throw new Error("BASE_URL 환경변수가 설정되지 않았습니다."); + } + + const url = new URL(`${baseUrl}/articles/${id}`); + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(); + } + + return await response.json(); + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/lib/fetchCommentsByArticleId.ts b/src/lib/fetchCommentsByArticleId.ts new file mode 100644 index 000000000..cdae89790 --- /dev/null +++ b/src/lib/fetchCommentsByArticleId.ts @@ -0,0 +1,28 @@ +import { FetchCommentsResponse } from "@/types"; + +export async function fetchCommentsByArticleId( + articleId: number, + limit: number +): Promise { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; + + if (!baseUrl) { + throw new Error("BASE_URL 환경변수가 설정되지 않았습니다."); + } + + const url = new URL(`${baseUrl}/articles/${articleId}/comments`); + url.searchParams.append("limit", String(limit)); + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(); + } + + return await response.json(); + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/pages/addboard.module.css b/src/pages/addboard.module.css new file mode 100644 index 000000000..2f08862df --- /dev/null +++ b/src/pages/addboard.module.css @@ -0,0 +1,115 @@ +.container { + display: flex; + flex-direction: column; + gap: 32px; +} + +.header { + display: flex; + justify-content: space-between; +} + +.header h2 { + font-size: 20px; + line-height: 32px; + font-weight: 700; + color: #1f2937; +} + +.header button { + display: flex; + align-items: center; + height: 42px; + padding: 12px 23px; + border: none; + border-radius: 8px; + background-color: #9ca3af; + color: #f3f4f6; + font-size: 16px; + font-weight: 600; + line-height: 26px; +} + +.main { + display: flex; + flex-direction: column; + gap: 24px; +} + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.label { + font-size: 18px; + line-height: 26px; + font-weight: 700; + color: #1f2937; +} + +.input { + padding: 16px 24px; + border: none; + border-radius: 12px; + background-color: #f3f4f6; + height: 56px; +} + +.textarea { + padding: 16px 24px; + border: none; + border-radius: 12px; + background-color: #f3f4f6; + height: 282px; + resize: none; +} + +.imageUploadContainer { + display: flex; + gap: 16px; +} + +.uploadBox { + width: 200px; + height: 200px; + border: 2px dashed #ddd; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + background-color: #f8f8f8; + transition: all 0.2s ease; +} + +.uploadBox:hover { + border-color: #999; + background-color: #f0f0f0; +} + +.plusIcon { + font-size: 32px; + color: #999; + margin-bottom: 8px; +} + +.hiddenInput { + display: none; +} + +.previewBox { + width: 200px; + height: 200px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #ddd; +} + +.previewImage { + width: 100%; + height: 100%; + object-fit: cover; +} \ No newline at end of file diff --git a/src/pages/addboard.tsx b/src/pages/addboard.tsx new file mode 100644 index 000000000..82a1fdad5 --- /dev/null +++ b/src/pages/addboard.tsx @@ -0,0 +1,116 @@ +import { createPost } from "@/services/postService"; +import styles from "./addboard.module.css"; +import { useState, useRef } from "react"; +import { useRouter } from "next/router"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +export default function Page() { + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [image, setImage] = useState(null); + const [preview, setPreview] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + const router = useRouter(); + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (file) { + if (file.size > MAX_FILE_SIZE) { + alert("이미지 크기가 너무 큽니다. 최대 5MB 이하의 이미지만 업로드 가능합니다."); + return; + } + + const reader = new FileReader(); + reader.onloadend = () => { + setPreview(reader.result as string); + setImage(file); + }; + reader.readAsDataURL(file); + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleSubmit = async () => { + if (isSubmitting) return; + + if (!title || !content) { + alert("제목과 내용을 입력해주세요."); + return; + } + + setIsSubmitting(true); + + try { + await createPost({ title, content, image }); + alert("게시물이 등록되었습니다."); + router.push("/boards"); + } catch (err: unknown) { + if (err instanceof Error) { + alert(err.message || "게시물 등록에 실패했습니다."); + } else { + alert("게시물 등록에 실패했습니다."); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

게시글 쓰기

+ +
+ +
+
+

*제목

+ setTitle(e.target.value)} + /> +
+
+

*내용

+