-
-
{writer.nickname}
+
+
+
+
+
{content}
-
-
{likeCount}
+

+
+
+
-
{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 (
+
+ );
+}
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 (
+
+ );
+}
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)}
+ />
+
+
+
+
*이미지
+
+
+ {preview && (
+
+

+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/boards/[id].module.css b/src/pages/boards/[id].module.css
new file mode 100644
index 000000000..f6abc05f2
--- /dev/null
+++ b/src/pages/boards/[id].module.css
@@ -0,0 +1,142 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 64px;
+}
+
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.header h2 {
+ font-size: 20px;
+ line-height: 32px;
+ font-weight: 700;
+ color: #1f2937;
+}
+
+.header_wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.header_section {
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ padding-bottom: 20px;
+}
+
+.header_section div {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+}
+
+.nickname {
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 24px;
+ color: #4b5563;
+}
+
+.date {
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 24px;
+ color: #9ca3af;
+}
+
+.like_button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 4px 12px;
+ margin-left: 32px;
+ height: 40px;
+ border-radius: 35px;
+ border: 1px solid #e5e7eb;
+ background-color: #ffffff;
+
+
+ font-size: 16px;
+ line-height: 26px;
+ font-weight: 500;
+ color: #6b7280;
+}
+
+.button_wrapper {
+ height: 34px;
+ border-left: 1px solid #e5e7eb;
+}
+
+.content {
+ font-size: 18px;
+ line-height: 26px;
+ color: #1f2937;
+}
+
+.input_wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.input_wrapper p {
+ font-size: 16px;
+ line-height: 26px;
+ font-weight: 600;
+ color: #111827;
+}
+
+.input_wrapper textarea {
+ padding: 16px 24px;
+ width: 100%;
+ height: 104px;
+ border: none;
+ border-radius: 12px;
+ background-color: #f3f4f6;
+ resize: none;
+}
+
+.input_wrapper div {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.input_wrapper button {
+ display: flex;
+ align-items: center;
+ height: 42px;
+ padding: 12px 23px;
+ border: none;
+ border-radius: 8px;
+ background-color: #9ca3af;
+ color: #ffffff;
+ font-size: 16px;
+ line-height: 26px;
+ font-weight: 600;
+}
+
+.comment_wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
\ No newline at end of file
diff --git a/src/pages/boards/[id].tsx b/src/pages/boards/[id].tsx
new file mode 100644
index 000000000..af4861566
--- /dev/null
+++ b/src/pages/boards/[id].tsx
@@ -0,0 +1,127 @@
+import { fetchArticleById } from "@/lib/fetchArticleById";
+import { fetchCommentsByArticleId } from "@/lib/fetchCommentsByArticleId";
+import { ArticleDetailResponse, FetchCommentsResponse } from "@/types";
+import { GetServerSideProps, GetServerSidePropsContext } from "next";
+import { formatDate } from "@/utils/formattedDate";
+import { addComment } from "@/services/commentService";
+import { useState } from "react";
+import CommentCard from "@/components/CommentCard";
+import Image from "next/image";
+import style from "./[id].module.css";
+import ProfileImg from "@/assets/images/profile.svg";
+import HeartIc from "@/assets/icons/heart.svg";
+
+interface DetailPageProps {
+ article: ArticleDetailResponse;
+ comments: FetchCommentsResponse;
+}
+
+export default function Page({ article, comments }: DetailPageProps) {
+ const formattedDate = formatDate(article.updatedAt);
+ const [commentContent, setCommentContent] = useState("");
+
+ const handleCommentChange = (e: React.ChangeEvent) => {
+ setCommentContent(e.target.value);
+ };
+
+ const handleCommentSubmit = async () => {
+ if (!commentContent.trim()) return;
+
+ try {
+ await addComment({
+ articleId: article.id,
+ content: commentContent,
+ });
+ setCommentContent("");
+ alert("댓글이 등록되었습니다.");
+ } catch (err: unknown) {
+ if (err instanceof Error) {
+ alert(err.message || "댓글 등록 실패");
+ } else {
+ alert("댓글 등록 실패");
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
{article.content}
+
+
+
+
+
{article.writer.nickname}
+
{formattedDate}
+
+
+
+
+
+
+
+
{article.content}
+
+
+
+
+ {comments.list.map((comment) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => {
+ const { id } = context.params!;
+
+ if (typeof id !== "string") {
+ return {
+ notFound: true,
+ };
+ }
+
+ try {
+ const article = await fetchArticleById(Number(id));
+ if (!article) {
+ return {
+ notFound: true,
+ };
+ }
+
+ const comments = await fetchCommentsByArticleId(Number(id), 10);
+ if (!comments) {
+ return {
+ notFound: true,
+ };
+ }
+
+ return {
+ props: {
+ article,
+ comments,
+ },
+ };
+ } catch (error) {
+ console.error(error);
+ return {
+ notFound: true,
+ };
+ }
+};
diff --git a/src/pages/login.module.css b/src/pages/login.module.css
new file mode 100644
index 000000000..1af91d405
--- /dev/null
+++ b/src/pages/login.module.css
@@ -0,0 +1,26 @@
+.container {
+ width: 100%;
+ max-width: 384px;
+ margin: 0 auto;
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.logo {
+ border-radius: 0.75rem;
+}
+
+.title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #3692ff;
+}
\ No newline at end of file
diff --git a/src/pages/login.tsx b/src/pages/login.tsx
new file mode 100644
index 000000000..6de054f3f
--- /dev/null
+++ b/src/pages/login.tsx
@@ -0,0 +1,41 @@
+import Image from "next/image";
+import styles from "./login.module.css";
+import LogoImg from "@/assets/images/logo.svg";
+import LoginForm from "@/components/LoginForm";
+import { useRouter } from "next/router";
+import { saveTokens } from "@/utils/tokenHandler";
+import { login } from "@/services/authService";
+
+export default function LoginPage() {
+ const router = useRouter();
+
+ const handleLogin = async (formData: { email: string; password: string }) => {
+ try {
+ const { accessToken, refreshToken } = await login(formData);
+ saveTokens(accessToken, refreshToken);
+ alert("로그인 성공!");
+ router.push("/boards");
+ } catch (err: unknown) {
+ if (err instanceof Error) {
+ alert(err.message || "로그인 실패");
+ } else {
+ alert("로그인 실패");
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/pages/signup.module.css b/src/pages/signup.module.css
new file mode 100644
index 000000000..ab1f912ab
--- /dev/null
+++ b/src/pages/signup.module.css
@@ -0,0 +1,20 @@
+.container {
+ width: 100%;
+ max-width: 384px;
+ margin: 0 auto;
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.logo {
+ border-radius: 0.75rem;
+}
\ No newline at end of file
diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx
new file mode 100644
index 000000000..5f7eea79f
--- /dev/null
+++ b/src/pages/signup.tsx
@@ -0,0 +1,51 @@
+import Image from "next/image";
+import styles from "./signup.module.css";
+import LogoImg from "@/assets/images/logo.svg";
+import SignUpForm from "@/components/SignUpForm";
+import { useRouter } from "next/router";
+import { signup } from "@/services/authService";
+import { saveTokens } from "@/utils/tokenHandler";
+
+export default function SignupPage() {
+ const router = useRouter();
+
+ const handleSignup = async (formData: {
+ email: string;
+ nickname: string;
+ password: string;
+ passwordConfirmation: string;
+ }) => {
+ try {
+ // 1) 회원가입 요청
+ const { accessToken, refreshToken } = await signup(formData);
+
+ // 2) 응답 받은 토큰 저장
+ saveTokens(accessToken, refreshToken);
+
+ // 3) 이후 페이지 이동
+ alert("회원가입 성공");
+ router.push("/boards");
+ } catch (err: unknown) {
+ if (err instanceof Error) {
+ alert(err.message || "회원가입 오류");
+ } else {
+ alert("회원가입 오류");
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/services/authService.ts b/src/services/authService.ts
new file mode 100644
index 000000000..c3d7f5b1c
--- /dev/null
+++ b/src/services/authService.ts
@@ -0,0 +1,70 @@
+interface SignupPayload {
+ email: string;
+ nickname: string;
+ password: string;
+ passwordConfirmation: string;
+}
+
+interface LoginPayload {
+ email: string;
+ password: string;
+}
+
+export async function signup(payload: SignupPayload) {
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
+ if (!baseUrl) {
+ throw new Error("BASE_URL 환경변수가 설정되지 않았습니다.");
+ }
+ const url = new URL(`${baseUrl}/auth/signUp`);
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error("회원가입 실패");
+ }
+
+ const result = await response.json();
+ // result 안에 { user, accessToken, refreshToken}
+
+ return result;
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+}
+
+export async function login(payload: LoginPayload) {
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
+ if (!baseUrl) {
+ throw new Error("BASE_URL 환경변수가 설정되지 않았습니다.");
+ }
+ const url = new URL(`${baseUrl}/auth/signIn`);
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error("로그인 실패");
+ }
+
+ const result = await response.json();
+ // { accessToken, refreshToken } 등의 정보 포함
+
+ return result;
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+}
diff --git a/src/services/commentService.ts b/src/services/commentService.ts
new file mode 100644
index 000000000..a6bacc99f
--- /dev/null
+++ b/src/services/commentService.ts
@@ -0,0 +1,34 @@
+import { getAccessToken } from "@/utils/tokenHandler";
+
+interface CommentPayload {
+ articleId: number;
+ content: string;
+}
+
+export async function addComment(payload: CommentPayload) {
+ const accessToken = getAccessToken();
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
+
+ if (!baseUrl) {
+ throw new Error("BASE_URL 환경변수가 설정되지 않았습니다.");
+ }
+ if (!accessToken) {
+ throw new Error("로그인이 필요합니다");
+ }
+ const url = new URL(`${baseUrl}/articles/${payload.articleId}/comments`);
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ content: payload.content }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("댓글 등록 실패:", errorText);
+ }
+
+ return response.json();
+}
diff --git a/src/services/imageService.ts b/src/services/imageService.ts
new file mode 100644
index 000000000..15678c2b1
--- /dev/null
+++ b/src/services/imageService.ts
@@ -0,0 +1,35 @@
+import { getAccessToken } from "@/utils/tokenHandler";
+
+export async function uploadImage(image: File): Promise {
+ const accessToken = getAccessToken();
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
+ if (!baseUrl) {
+ throw new Error("BASE_URL 환경변수가 설정되지 않았습니다.");
+ }
+ const url = new URL(`${baseUrl}/images/upload`);
+
+ if (!accessToken) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const formData = new FormData();
+ formData.append("image", image);
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: "application/json",
+ },
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("이미지 업로드 실패:", errorText);
+ throw new Error("이미지 업로드에 실패했습니다.");
+ }
+
+ const result = await response.json();
+ return result.url;
+}
diff --git a/src/services/postService.ts b/src/services/postService.ts
new file mode 100644
index 000000000..e6e7e1eda
--- /dev/null
+++ b/src/services/postService.ts
@@ -0,0 +1,49 @@
+import { getAccessToken } from "@/utils/tokenHandler";
+import { uploadImage } from "./imageService";
+
+interface PostPayload {
+ title: string;
+ content: string;
+ image?: File | null;
+}
+
+export async function createPost(payload: PostPayload) {
+ const accessToken = getAccessToken();
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
+
+ if (!baseUrl) {
+ throw new Error("BASE_URL 환경변수가 설정되지 않았습니다.");
+ }
+
+ if (!accessToken) {
+ throw new Error("로그인이 필요합니다");
+ }
+ const url = new URL(`${baseUrl}/articles`);
+
+ let imageUrl = null;
+ if (payload.image) {
+ imageUrl = await uploadImage(payload.image);
+ }
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ title: payload.title,
+ content: payload.content,
+ ...(imageUrl && { image: imageUrl }),
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("게시물 등록 실패:", errorText);
+ throw new Error("게시물 등록에 실패했습니다.");
+ }
+
+ return response.json();
+}
diff --git a/src/types.ts b/src/types.ts
index 46da11807..c6520f55e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,7 @@
export interface Writer {
nickname: string;
id: number;
+ image?: string;
}
export interface Article {
@@ -18,3 +19,20 @@ export interface FetchArticlesResponse {
totalCount: number;
list: Article[];
}
+
+export interface ArticleDetailResponse extends Article {
+ isLiked: boolean;
+}
+
+export interface Comment {
+ id: number;
+ content: string;
+ createdAt: string;
+ updatedAt: string;
+ writer: Writer;
+}
+
+export interface FetchCommentsResponse {
+ nextCursor: number;
+ list: Comment[];
+}
diff --git a/src/utils/formatRelativeTime.ts b/src/utils/formatRelativeTime.ts
new file mode 100644
index 000000000..00a203400
--- /dev/null
+++ b/src/utils/formatRelativeTime.ts
@@ -0,0 +1,32 @@
+export function formatRelativeTime(dateString: string): string {
+ const now = new Date();
+ const past = new Date(dateString);
+
+ if (isNaN(past.getTime())) {
+ throw new Error("유효하지 않은 날짜 형식입니다.");
+ }
+
+ const diffInSeconds: number = Math.floor((now.getTime() - past.getTime()) / 1000);
+
+ if (diffInSeconds < 0) {
+ return "방금 전";
+ }
+
+ const intervals = [
+ { label: "년", seconds: 365 * 24 * 60 * 60 },
+ { label: "개월", seconds: 30 * 24 * 60 * 60 },
+ { label: "일", seconds: 24 * 60 * 60 },
+ { label: "시간", seconds: 60 * 60 },
+ { label: "분", seconds: 60 },
+ { label: "초", seconds: 1 },
+ ];
+
+ for (const interval of intervals) {
+ const count: number = Math.floor(diffInSeconds / interval.seconds);
+ if (count >= 1) {
+ return `${count}${interval.label} 전`;
+ }
+ }
+
+ return "방금 전";
+}
diff --git a/src/utils/tokenHandler.ts b/src/utils/tokenHandler.ts
new file mode 100644
index 000000000..55a0efec3
--- /dev/null
+++ b/src/utils/tokenHandler.ts
@@ -0,0 +1,20 @@
+const ACCESS_TOKEN_KEY = "accessToken";
+const REFRESH_TOKEN_KEY = "refreshToken";
+
+export function saveTokens(accessToken: string, refreshToken: string) {
+ localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
+ localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
+}
+
+export function getAccessToken() {
+ return typeof window !== "undefined" ? localStorage.getItem(ACCESS_TOKEN_KEY) : null;
+}
+
+export function getRefreshToken() {
+ return typeof window !== "undefined" ? localStorage.getItem(REFRESH_TOKEN_KEY) : null;
+}
+
+export function clearTokens() {
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
+}