diff --git a/.eslintrc.json b/.eslintrc.json index bd2c4cf..b9be207 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,6 @@ "rules": { "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/no-explicit-any": "warn", - + "@typescript-eslint/no-explicit-any": "warn" } } diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index db583b8..3e0c1ac 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -1,189 +1,53 @@ 'use client'; import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import dynamic from 'next/dynamic'; -import { PostBody } from '@/app/types/Post'; -import { StaticImport } from 'next/dist/shared/lib/get-img-props'; -import axios from 'axios'; -import useToast from '@/app/hooks/useToast'; import { useBlockNavigate } from '@/app/hooks/common/useBlockNavigate'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import PostWriteButtons from '@/app/entities/post/write/PostWriteButtons'; -import { validatePost } from '@/app/lib/utils/validate/validate'; -import { Series } from '@/app/types/Series'; import Overlay from '@/app/entities/common/Overlay/Overlay'; import CreateSeriesOverlayContainer from '@/app/entities/series/CreateSeriesOverlayContainer'; -import { getAllSeriesData } from '@/app/entities/series/api/series'; import UploadImageContainer from '@/app/entities/post/write/UploadImageContainer'; -import useDraft from '@/app/hooks/post/useDraft'; import PostMetadataForm from '@/app/entities/post/write/PostMetadataForm'; +import usePost from '@/app/hooks/post/usePost'; const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); const BlogForm = () => { const params = useSearchParams(); const slug = params.get('slug'); - const [submitLoading, setSubmitLoading] = useState(false); - const [title, setTitle] = useState(''); - const [subTitle, setSubTitle] = useState(''); - const [content, setContent] = useState(''); - const [profileImage, setProfileImage] = useState(); - const [thumbnailImage, setThumbnailImage] = useState(); - const [seriesList, setSeriesList] = useState([]); - const [seriesId, setSeriesId] = useState(); - const [seriesLoading, setSeriesLoading] = useState(true); - const [errors, setErrors] = useState([]); - const [tags, setTags] = useState([]); - const [isPrivate, setIsPrivate] = useState(false); - const toast = useToast(); - const router = useRouter(); - const NICKNAME = '개발자 서정우'; - const [createSeriesOpen, setCreateSeriesOpen] = useState(false); - // 임시저장 상태 - const { draft, draftImages, updateDraft, clearDraft } = useDraft(); - // 이미지 상태 - const [uploadedImages, setUploadedImages] = useState([]); - - const postBody: PostBody = { + const { title, subTitle, - author: NICKNAME, - content: content || '', - profileImage, - thumbnailImage, - seriesId: seriesId || '', - tags: tags, - isPrivate: isPrivate, - }; + submitLoading, + seriesLoading, + seriesId, + seriesList, + content, + setTitle, + setSubTitle, + setContent, + setSeriesId, + setIsPrivate, + isPrivate, + tags, + setTags, + uploadedImages, + setUploadedImages, + overwriteDraft, + saveToDraft, + clearDraftInStore, + submitHandler, + postBody, + errors, + handleLinkCopy, + } = usePost(slug || ''); + const [createSeriesOpen, setCreateSeriesOpen] = useState(false); useBlockNavigate({ title, content: content || '' }); - useEffect(() => { - getSeries(); - }, []); - - useEffect(() => { - if (slug) { - getPostDetail(); - } - }, [slug]); - - // 시리즈 - const getSeries = async () => { - try { - const data = await getAllSeriesData(); - setSeriesList(data); - setSeriesId(data[0]._id); - setSeriesLoading(false); - } catch (e) { - console.error('시리즈 조회 중 오류 발생', e); - } - }; - - // 블로그 - const postBlog = async (post: PostBody) => { - try { - const response = await axios.post('/api/posts', post); - if (response.status === 201) { - toast.success('글이 성공적으로 발행되었습니다.'); - router.push('/posts'); - } - } catch (e) { - toast.error('글 발행 중 오류 발생했습니다.'); - console.error('글 발행 중 오류 발생', e); - } - }; - - const updatePost = async (post: PostBody) => { - try { - const response = await axios.put(`/api/posts/${slug}`, post); - if (response.status === 200) { - toast.success('글이 성공적으로 수정되었습니다.'); - router.push('/posts'); - } - } catch (e) { - toast.error('글 수정 중 오류 발생했습니다.'); - console.error('글 수정 중 오류 발생', e); - } - }; - - // 임시저장 관련 함수 - const saveToDraft = () => { - const { success } = updateDraft(postBody, uploadedImages); - if (success) { - toast.success('임시 저장되었습니다.'); - } else { - toast.error('임시 저장 실패'); - } - }; - - const overwriteDraft = () => { - if (draft !== null) { - if (confirm('임시 저장된 글이 있습니다. 덮어쓰시겠습니까?')) { - const { title, content, subTitle, seriesId, isPrivate } = draft; - setTitle(title || ''); - setContent(content); - setSubTitle(subTitle || ''); - setSeriesId(seriesId); - setUploadedImages(draftImages || []); - setIsPrivate(isPrivate || false); - } - } else { - toast.error('임시 저장된 글이 없습니다.'); - } - }; - - const clearDraftInStore = () => { - clearDraft(); - toast.success('임시 저장이 삭제되었습니다.'); - }; - - const submitHandler = (post: PostBody) => { - try { - setSubmitLoading(true); - const { isValid, errors } = validatePost(post); - setErrors(errors); - if (!isValid) { - toast.error('유효성 검사 실패'); - console.error('유효성 검사 실패', errors); - setSubmitLoading(false); - return; - } - - if (slug) { - updatePost(post); - } else { - postBlog(post); - } - clearDraft(); - } catch (e) { - console.error('글 발행 중 오류 발생', e); - setSubmitLoading(false); - } - }; - - const getPostDetail = async () => { - try { - const response = await axios.get(`/api/posts/${slug}`); - const data = await response.data; - setTitle(data.post.title || ''); - setSubTitle(data.post.subTitle); - setContent(data.post.content); - setSeriesId(data.post.seriesId || ''); - setTags(data.post.tags || []); - setIsPrivate(data.post.isPrivate || false); - } catch (e) { - console.error('글 조회 중 오류 발생', e); - } - }; - - const handleLinkCopy = (image: string) => { - navigator.clipboard.writeText(image); - toast.success('이미지 링크가 복사되었습니다.'); - }; - return (
{ setUploadedImages={setUploadedImages} onClick={handleLinkCopy} /> - {errors && ( -
- {errors.slice(0, 3).map((error, index) => ( -

- {error} -

- ))} -
- )} + {
); }; + +const ErrorBox = ({ errors }: { errors: string[] | null }) => { + if (!errors) return null; + + return ( +
+ {errors.slice(0, 3).map((error, index) => ( +

+ {error} +

+ ))} +
+ ); +}; export default BlogForm; diff --git a/app/entities/post/write/UploadImageContainer.tsx b/app/entities/post/write/UploadImageContainer.tsx index 9266d42..088d41f 100644 --- a/app/entities/post/write/UploadImageContainer.tsx +++ b/app/entities/post/write/UploadImageContainer.tsx @@ -2,12 +2,12 @@ import UploadedImage from '@/app/entities/post/write/UploadedImage'; import { FaImage } from 'react-icons/fa'; import { upload } from '@vercel/blob/client'; -import { ChangeEvent, useState } from 'react'; +import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react'; interface UploadImageContainerProps { onClick: (link: string) => void; uploadedImages: string[]; - setUploadedImages: (images: string[]) => void; + setUploadedImages: Dispatch>; } const UploadImageContainer = ({ onClick, @@ -22,16 +22,28 @@ const UploadImageContainer = ({ throw new Error('이미지가 선택되지 않았습니다.'); } - const file = target.files[0]; + const files = target.files; - const timestamp = new Date().getTime(); - const pathname = `/images/${timestamp}-${file.name}`; - const newBlob = await upload(pathname, file, { - access: 'public', - handleUploadUrl: '/api/upload', - }); + if (files.length === 0) { + throw new Error('업로드할 파일이 없습니다.'); + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.type.startsWith('image/')) { + throw new Error('이미지 파일만 업로드할 수 있습니다.'); + } + + const timestamp = new Date().getTime(); + const pathname = `/images/${timestamp}-${file.name}`; + const newBlob = await upload(pathname, file, { + access: 'public', + handleUploadUrl: '/api/upload', + }); + + setUploadedImages((prev) => [...prev, newBlob.url]); + } - setUploadedImages([...uploadedImages, newBlob.url]); return; } catch (error) { console.error('업로드 실패:', error); diff --git a/app/hooks/common/useURLSync.ts b/app/hooks/common/useURLSync.ts new file mode 100644 index 0000000..e358553 --- /dev/null +++ b/app/hooks/common/useURLSync.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +interface useURLSyncConfig { + baseURL: string; + params: Record | Record; +} + +/** + * 서치파라미터를 쉽게 설정할 수 있는 훅을 만들어봅시다. + * example /posts?page=1&series=seriesSlug&query=query + * 필요한 파라미터는 baseURL, currentPage, seriesSlugParam, query입니다. + * @param baseURL + * @param params + */ + +const useURLSync = ({ baseURL, params }: useURLSyncConfig) => { + const router = useRouter(); + + useEffect(() => { + const searchParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.set(key, String(value)); + } + }); + } + const finalUrl = `/${baseURL}?${searchParams.toString()}`; + router.push(finalUrl); + }, [...Object.values(params)]); + + return {}; +}; + +export default useURLSync; diff --git a/app/hooks/post/usePost.ts b/app/hooks/post/usePost.ts new file mode 100644 index 0000000..3c5b1f4 --- /dev/null +++ b/app/hooks/post/usePost.ts @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; +import { StaticImport } from 'next/dist/shared/lib/get-img-props'; +import { Series } from '@/app/types/Series'; +import { PostBody } from '@/app/types/Post'; +import useToast from '@/app/hooks/useToast'; +import { getPostDetail } from '@/app/entities/post/api/postAPI'; +import { getAllSeriesData } from '@/app/entities/series/api/series'; +import axios from 'axios'; +import { useRouter } from 'next/navigation'; +import useDraft from '@/app/hooks/post/useDraft'; +import { validatePost } from '@/app/lib/utils/validate/validate'; + +const usePost = (slug = '') => { + const [submitLoading, setSubmitLoading] = useState(false); + const [title, setTitle] = useState(''); + const [subTitle, setSubTitle] = useState(''); + const [content, setContent] = useState(''); + const [profileImage, setProfileImage] = useState(); + const [thumbnailImage, setThumbnailImage] = useState(); + const [seriesList, setSeriesList] = useState([]); + const [seriesId, setSeriesId] = useState(); + const [seriesLoading, setSeriesLoading] = useState(true); + const [errors, setErrors] = useState([]); + const [tags, setTags] = useState([]); + const [isPrivate, setIsPrivate] = useState(false); + const [uploadedImages, setUploadedImages] = useState([]); + + const NICKNAME = '개발자 서정우'; + const toast = useToast(); + const router = useRouter(); + const { draft, draftImages, updateDraft, clearDraft } = useDraft(); + + const postBody: PostBody = { + title, + subTitle, + author: NICKNAME, + content: content || '', + profileImage, + thumbnailImage, + seriesId: seriesId || '', + tags: tags, + isPrivate: isPrivate, + }; + + useEffect(() => { + getSeries(); + }, []); + + useEffect(() => { + if (slug) { + getPostDetail(); + } + }, [slug]); + + // 시리즈 + const getSeries = async () => { + try { + const data = await getAllSeriesData(); + setSeriesList(data); + setSeriesId(data[0]._id); + setSeriesLoading(false); + } catch (e) { + console.error('시리즈 조회 중 오류 발생', e); + } + }; + + // Method + const postBlog = async (post: PostBody) => { + try { + const response = await axios.post('/api/posts', post); + if (response.status === 201) { + toast.success('글이 성공적으로 발행되었습니다.'); + router.push('/posts'); + } + } catch (e) { + toast.error('글 발행 중 오류 발생했습니다.'); + console.error('글 발행 중 오류 발생', e); + } + }; + + const updatePost = async (post: PostBody) => { + try { + const response = await axios.put(`/api/posts/${slug}`, post); + if (response.status === 200) { + toast.success('글이 성공적으로 수정되었습니다.'); + router.push('/posts'); + } + } catch (e) { + toast.error('글 수정 중 오류 발생했습니다.'); + console.error('글 수정 중 오류 발생', e); + } + }; + + // 임시저장 관련 함수 + const saveToDraft = () => { + const { success } = updateDraft(postBody, uploadedImages); + if (success) { + toast.success('임시 저장되었습니다.'); + } else { + toast.error('임시 저장 실패'); + } + }; + + const overwriteDraft = () => { + if (draft !== null) { + if (confirm('임시 저장된 글이 있습니다. 덮어쓰시겠습니까?')) { + const { title, content, subTitle, seriesId, isPrivate, tags } = draft; + setTitle(title || ''); + setContent(content); + setSubTitle(subTitle || ''); + setSeriesId(seriesId); + setUploadedImages(draftImages || []); + setIsPrivate(isPrivate || false); + setTags(tags || []); + } + } else { + toast.error('임시 저장된 글이 없습니다.'); + } + }; + + const clearDraftInStore = () => { + clearDraft(); + toast.success('임시 저장이 삭제되었습니다.'); + }; + + const submitHandler = (post: PostBody) => { + try { + setSubmitLoading(true); + const { isValid, errors } = validatePost(post); + setErrors(errors); + if (!isValid) { + toast.error('유효성 검사 실패'); + console.error('유효성 검사 실패', errors); + setSubmitLoading(false); + return; + } + + if (slug) { + updatePost(post); + } else { + postBlog(post); + } + clearDraft(); + } catch (e) { + console.error('글 발행 중 오류 발생', e); + setSubmitLoading(false); + } + }; + + const getPostDetail = async () => { + try { + const response = await axios.get(`/api/posts/${slug}`); + const data = await response.data; + setTitle(data.post.title || ''); + setSubTitle(data.post.subTitle); + setContent(data.post.content); + setSeriesId(data.post.seriesId || ''); + setTags(data.post.tags || []); + setIsPrivate(data.post.isPrivate || false); + } catch (e) { + console.error('글 조회 중 오류 발생', e); + } + }; + + const handleLinkCopy = (image: string) => { + navigator.clipboard.writeText(image); + toast.success('이미지 링크가 복사되었습니다.'); + }; + + return { + title, + subTitle, + content, + seriesId, + isPrivate, + tags, + submitLoading, + postBody, + seriesLoading, + seriesList, + uploadedImages, + setUploadedImages, + setTitle, + setSubTitle, + setContent, + setSeriesId, + setIsPrivate, + setTags, + overwriteDraft, + saveToDraft, + clearDraftInStore, + submitHandler, + handleLinkCopy, + errors, + }; +}; + +export default usePost; diff --git a/app/hooks/post/usePostSearch.ts b/app/hooks/post/usePostSearch.ts new file mode 100644 index 0000000..3077d7a --- /dev/null +++ b/app/hooks/post/usePostSearch.ts @@ -0,0 +1,12 @@ +import { useState } from 'react'; +import useSearchQueryStore from '@/app/stores/useSearchQueryStore'; +import useDebounce from '@/app/hooks/optimize/useDebounce'; + +const usePostSearch = () => { + const [query, setQuery] = useState(''); + const addLatestQuery = useSearchQueryStore((state) => state.addSearchQuery); + const debouncedQuery = useDebounce(query, 300); + + return { query, setQuery, addLatestQuery, debouncedQuery }; +}; +export default usePostSearch; diff --git a/app/posts/page.tsx b/app/posts/page.tsx index 1b7bfb3..88f1187 100644 --- a/app/posts/page.tsx +++ b/app/posts/page.tsx @@ -1,31 +1,32 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Post } from '@/app/types/Post'; import PostList from '@/app/entities/post/list/PostList'; import SearchSection from '@/app/entities/post/list/SearchSection'; -import useSearchQueryStore from '@/app/stores/useSearchQueryStore'; import { useRouter, useSearchParams } from 'next/navigation'; import Pagination from '@/app/entities/common/Pagination'; import useDataFetch, { useDataFetchConfig, } from '@/app/hooks/common/useDataFetch'; -import useDebounce from '@/app/hooks/optimize/useDebounce'; import ErrorBox from '../entities/common/Error/ErrorBox'; +import useURLSync from '@/app/hooks/common/useURLSync'; +import usePostSearch from '@/app/hooks/post/usePostSearch'; interface PaginationData { totalPosts: number; } const BlogList = () => { - const [query, setQuery] = useState(''); - const addLatestQuery = useSearchQueryStore((state) => state.addSearchQuery); + const { query, debouncedQuery, setQuery, addLatestQuery } = usePostSearch(); + const router = useRouter(); const searchParams = useSearchParams(); const seriesSlugParam = searchParams.get('series'); const currentPage = Number(searchParams.get('page')) || 1; + const [totalPosts, setTotalPosts] = useState(0); const ITEMS_PER_PAGE = 12; - const debouncedQuery = useDebounce(query, 300); + const config = useMemo((): useDataFetchConfig => { return { url: `/api/posts`, @@ -57,11 +58,14 @@ const BlogList = () => { const posts = data?.posts || []; - useEffect(() => { - router.push( - `/posts?page=${currentPage}${seriesSlugParam ? `&series=${seriesSlugParam}` : ''}${query ? `&query=${query}` : ''}` - ); - }, [currentPage, seriesSlugParam, query]); + useURLSync({ + baseURL: 'posts', + params: { + page: currentPage, + series: seriesSlugParam, + query: query, + }, + }); if (error) { console.error('Error fetching posts:', error);