diff --git a/src/app/(board)/article/[articleid]/edit-article/page.tsx b/src/app/(board)/article/[articleid]/edit-article/page.tsx
index 64f6b29f..5b2ac7c2 100644
--- a/src/app/(board)/article/[articleid]/edit-article/page.tsx
+++ b/src/app/(board)/article/[articleid]/edit-article/page.tsx
@@ -1,3 +1,95 @@
-export default function EditArticlePage() {
- return
EditArticlePage
;
-}
+'use client';
+import React, { useState, useEffect } from 'react';
+import ArticleForm from '@/app/(board)/article/_components/ArticleForm';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { getArticleByIdForClient } from '@/lib/apis/article';
+import { ArticleResponse } from '@/lib/apis/article/type';
+import { useArticleStore } from '@/store/useArticleStore';
+import { toast } from 'react-toastify';
+
+export default function EditArticlePage({
+ params,
+}: {
+ params: { articleid?: string };
+}) {
+ const router = useRouter();
+ const { setArticle } = useArticleStore();
+ const [article, setArticleState] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchArticle = async () => {
+ setLoading(true);
+ try {
+ if (!params?.articleid) {
+ setError('게시글 ID가 제공되지 않았습니다.');
+ return;
+ }
+
+ const articleId = parseInt(params.articleid);
+ if (isNaN(articleId) || articleId <= 0) {
+ setError('유효하지 않은 게시글 ID입니다.');
+ return;
+ }
+
+ const response = await getArticleByIdForClient({ articleId });
+ if (response) {
+ setArticleState(response);
+ } else {
+ setError('게시글을 불러오는데 실패했습니다.');
+ }
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('401')) {
+ setError('인증 정보가 유효하지 않습니다. 다시 로그인해 주세요.');
+ toast.error('인증 정보가 유효하지 않습니다. 다시 로그인해 주세요.');
+ } else {
+ setError('게시글을 불러오는데 실패했습니다.');
+ toast.error('게시글을 불러오는데 실패했습니다.');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchArticle();
+ }, [params]);
+
+ if (loading) {
+ return 로딩 중...
;
+ }
+
+ if (error || !article) {
+ return {error || '게시글을 찾을 수 없습니다.'}
;
+ }
+
+ const handleSubmit = async (
+ success: boolean,
+ message?: string,
+ articleId?: number
+ ) => {
+ if (success && articleId) {
+ const updatedArticle = await getArticleByIdForClient({ articleId });
+ if (updatedArticle) {
+ setArticle(updatedArticle);
+ }
+ router.push(ROUTES.ARTICLE(articleId));
+ router.refresh();
+ } else {
+ toast.error(message || '게시글 수정에 실패했습니다.');
+ router.push(ROUTES.HOME);
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(board)/article/_components/ArticleForm.tsx b/src/app/(board)/article/_components/ArticleForm.tsx
new file mode 100644
index 00000000..a33e95b3
--- /dev/null
+++ b/src/app/(board)/article/_components/ArticleForm.tsx
@@ -0,0 +1,123 @@
+'use client';
+import React, { useState } from 'react';
+import Button from '@/components/common/Button';
+import InputBase from '@/components/common/Input/InputBase';
+import InputTextarea from '@/components/common/Input/InputTextarea';
+import FileInput from '@/app/(board)/article/_components/FileInput';
+import { postArticle, patchArticleById } from '@/lib/apis/article';
+import { ArticleBody, ArticleResponse } from '@/lib/apis/article/type';
+import { toast } from 'react-toastify';
+
+interface ArticleFormProps {
+ title: string;
+ initialTitle?: string;
+ initialContents?: string;
+ initialImageUrl?: string | null;
+ articleId?: number;
+ onSubmit: (success: boolean, message?: string, articleId?: number) => void;
+}
+
+const ArticleForm = ({
+ title,
+ initialTitle = '',
+ initialContents = '',
+ initialImageUrl = null,
+ articleId,
+ onSubmit,
+}: ArticleFormProps) => {
+ const [formTitle, setTitle] = useState(initialTitle);
+ const [formContents, setContents] = useState(initialContents);
+ const [imageUrl, setImageUrl] = useState(initialImageUrl);
+
+ const handleSubmit = async () => {
+ if (!formTitle.trim()) {
+ toast.error('제목은 필수로 작성해주세요.');
+ return;
+ }
+
+ if (!formContents.trim()) {
+ toast.error('내용은 필수로 작성해주세요.');
+ return;
+ }
+
+ const body: ArticleBody = {
+ title: formTitle.trim(),
+ content: formContents.trim(),
+ image: imageUrl,
+ };
+
+ try {
+ const response: ArticleResponse | null = articleId
+ ? await patchArticleById({ articleId, body })
+ : await postArticle({ body });
+
+ if (response && response.id) {
+ onSubmit(true, '게시글이 등록되었습니다.', response.id);
+ toast.success('게시글이 등록되었습니다.');
+ } else {
+ onSubmit(false, '게시글 처리에 실패했습니다.');
+ toast.error('게시글 처리에 실패했습니다.');
+ }
+ } catch {
+ onSubmit(false, '게시글 처리 중 오류가 발생했습니다.');
+ toast.error('게시글 처리 중 오류가 발생했습니다.');
+ }
+ };
+
+ return (
+
+
+
+
{title}
+
+
+
+ setTitle(e.target.value)}
+ className="w-full"
+ containerClassName="mt-3 h-[48px] bg-slate-800 mb-10"
+ />
+ setContents(e.target.value)}
+ className="h-[240px] w-full overflow-y-auto whitespace-normal"
+ inputClassName="mt-3 bg-slate-800 mb-10 h-[240px] px-6 py-4"
+ />
+ setImageUrl(url)}
+ containerClassName="mb-10"
+ />
+
+
+
+ );
+};
+
+export default ArticleForm;
\ No newline at end of file
diff --git a/src/app/(board)/article/_components/FileInput.tsx b/src/app/(board)/article/_components/FileInput.tsx
new file mode 100644
index 00000000..80e831a6
--- /dev/null
+++ b/src/app/(board)/article/_components/FileInput.tsx
@@ -0,0 +1,124 @@
+'use client';
+import React, { useState } from 'react';
+import Image from 'next/image';
+import { useMutation } from '@tanstack/react-query';
+import { toast } from 'react-toastify';
+import IconRenderer from '@/components/common/Icons/IconRenderer';
+import Spinner from '@/components/common/Loading/Spinner';
+import postImage from '@/lib/apis/uploadImage';
+
+interface FileInputProps {
+ title?: string;
+ initialUrl?: string | null;
+ onChange?: (url: string | null) => void;
+ containerClassName?: string;
+ inputClassName?: string;
+ iconClassName?: string;
+}
+
+const FileInput = ({
+ title,
+ initialUrl = null,
+ onChange,
+ containerClassName = '',
+ inputClassName = '',
+ iconClassName = '',
+}: FileInputProps) => {
+ const [imgUrl, setImgUrl] = useState(initialUrl);
+
+ const MAX_IMAGE_SIZE = 4.2 * 1024 * 1024;
+ const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/jpg'];
+
+ const { mutate, isPending } = useMutation({
+ mutationFn: (file: File) => postImage(file),
+ onSuccess: (url) => {
+ setImgUrl(url);
+ if (onChange) onChange(url);
+ },
+ onError: () => {
+ setImgUrl(null);
+ if (onChange) onChange(null);
+ toast.error('이미지 업로드 중 오류가 발생했습니다.');
+ },
+ });
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files ? e.target.files[0] : null;
+
+ if (!file) return;
+
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ setImgUrl(null);
+ if (onChange) onChange(null);
+ e.target.value = '';
+ toast.error('이미지 파일만 업로드할 수 있습니다. (.png, .jpg, .jpeg)');
+ return;
+ }
+
+ if (file.size > MAX_IMAGE_SIZE) {
+ setImgUrl(null);
+ if (onChange) onChange(null);
+ e.target.value = '';
+ toast.error('이미지는 4.2MB 이하여야 합니다.');
+ return;
+ }
+
+ mutate(file);
+ };
+
+ const handleRemoveImage = () => {
+ setImgUrl(null);
+ if (onChange) onChange(null);
+ toast.success('이미지가 삭제되었습니다.');
+ const input = document.getElementById('file-input') as HTMLInputElement;
+ if (input) input.value = '';
+ };
+
+ return (
+
+ {title && (
+
+ )}
+
+ {isPending ? (
+
+ ) : imgUrl ? (
+
+
+
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default FileInput;
\ No newline at end of file
diff --git a/src/app/(board)/article/add-article/page.tsx b/src/app/(board)/article/add-article/page.tsx
index 3843186f..fcce8cfc 100644
--- a/src/app/(board)/article/add-article/page.tsx
+++ b/src/app/(board)/article/add-article/page.tsx
@@ -1,3 +1,23 @@
+'use client';
+import ArticleForm from '@/app/(board)/article/_components/ArticleForm';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { toast } from 'react-toastify';
+
export default function AddArticlePage() {
- return AddArticlePage
;
-}
+ const router = useRouter();
+
+ const handleSubmit = (
+ success: boolean,
+ message?: string,
+ articleId?: number
+ ) => {
+ if (success && articleId) {
+ router.push(ROUTES.ARTICLE(articleId));
+ } else {
+ toast.error(message || '게시글 등록에 실패했습니다.');
+ }
+ };
+
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/(user)/mypage/_components/AccountUpdateButton.tsx b/src/app/(user)/mypage/_components/AccountUpdateButton.tsx
index 47b36e65..1e6d0aeb 100644
--- a/src/app/(user)/mypage/_components/AccountUpdateButton.tsx
+++ b/src/app/(user)/mypage/_components/AccountUpdateButton.tsx
@@ -35,10 +35,10 @@ const AccountUpdateButton = ({ name, image }: AccountUpdateButtonProps) => {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] });
- toast.success('계정 정보가 성공적으로 업데이트되었습니다.');
+ toast.success('개인 정보가 수정되었습니다.');
},
onError: () => {
- toast.error('계정 정보 업데이트에 실패했습니다.');
+ toast.error('개인 정보 수정에 실패했습니다.');
},
});
diff --git a/src/app/(user)/mypage/_components/ProfileImageUploader.tsx b/src/app/(user)/mypage/_components/ProfileImageUploader.tsx
index 110d9770..8899fd79 100644
--- a/src/app/(user)/mypage/_components/ProfileImageUploader.tsx
+++ b/src/app/(user)/mypage/_components/ProfileImageUploader.tsx
@@ -33,10 +33,10 @@ const ProfileImageUploader = ({
mutationFn: (file: File) => postImage(file),
onSuccess: (url) => {
setImage(url);
- toast.success('프로필 이미지가 성공적으로 업로드되었습니다.');
+ toast.success('프로필 이미지가 업로드되었습니다.');
},
onError: () => {
- toast.error('이미지 파일의 최대용량은 10MB를 넘길 수 없습니다.');
+ toast.error('이미지는 4.2MB 이하여야 합니다.');
},
});
diff --git a/src/lib/apis/article/index.ts b/src/lib/apis/article/index.ts
index 2d2290b0..b0fbe227 100644
--- a/src/lib/apis/article/index.ts
+++ b/src/lib/apis/article/index.ts
@@ -63,6 +63,18 @@ export async function getArticleById({
});
}
+// 게시글 상세 조회 (GET /articles/:articleId) - 클라이언트 전용 함수
+export async function getArticleByIdForClient({
+ articleId,
+}: {
+ articleId: number;
+}): Promise {
+ return clientFetcher({
+ url: `/articles/${articleId}`,
+ method: 'GET',
+ });
+}
+
// 게시글 수정 (PATCH /articles/:articleId)
export async function patchArticleById({
articleId,
@@ -74,7 +86,7 @@ export async function patchArticleById({
const payload = {
content: body.content,
title: body.title,
- ...(body.image ? { image: body.image } : {}),
+ image: body.image === null ? null : body.image || undefined,
};
return clientFetcher({
diff --git a/src/store/useArticleStore.ts b/src/store/useArticleStore.ts
new file mode 100644
index 00000000..1a579396
--- /dev/null
+++ b/src/store/useArticleStore.ts
@@ -0,0 +1,12 @@
+import { create } from 'zustand';
+import { ArticleResponse } from '../lib/apis/article/type';
+
+interface ArticleState {
+ article: ArticleResponse | null;
+ setArticle: (article: ArticleResponse | null) => void;
+}
+
+export const useArticleStore = create((set) => ({
+ article: null,
+ setArticle: (article) => set({ article }),
+}));