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 }), +}));