diff --git a/.eslintrc.json b/.eslintrc.json index b07d60f..b9be207 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,6 +2,7 @@ "extends": ["next/core-web-vitals", "next/typescript"], "rules": { "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "warn" + "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-explicit-any": "warn" } } diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts index d9cc02f..92f3faa 100644 --- a/app/api/posts/[slug]/route.ts +++ b/app/api/posts/[slug]/route.ts @@ -1,8 +1,9 @@ // app/api/posts/[slug]/route.ts import dbConnect from '@/app/lib/dbConnect'; import Post from '@/app/models/Post'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; +import Series from '@/app/models/Series'; export async function GET( req: NextRequest, @@ -36,6 +37,7 @@ export async function PUT( try { await dbConnect(); const body = await req.json(); + const post = await Post.findOne({ slug: params.slug }); const updatedPost = await Post.findOneAndUpdate( { slug: params.slug }, @@ -53,6 +55,23 @@ export async function PUT( ); } + // 시리즈 변경 처리 + if (post.seriesId?.toString() !== body.seriesId) { + if (post.seriesId) { + await Series.findByIdAndUpdate(post.seriesId, { + $pull: { posts: post._id }, + $inc: { postCount: -1 }, + }); + } + + if (body.seriesId) { + await Series.findByIdAndUpdate(body.seriesId, { + $push: { posts: post._id }, + $inc: { postCount: 1 }, + }); + } + } + return Response.json({ success: true, post: updatedPost }); } catch (error) { console.error(error); @@ -64,28 +83,35 @@ export async function PUT( } export async function DELETE( - req: NextRequest, + request: Request, { params }: { params: { slug: string } } ) { try { await dbConnect(); - const deletedPost = await Post.findByIdAndDelete(params.slug).lean(); + const post = await Post.findById(params.slug); - if (!deletedPost) { - return Response.json( - { success: false, error: '삭제할 글을 찾을 수 없습니다.' }, + if (!post) { + return NextResponse.json( + { error: '삭제할 포스트를 찾을 수 없습니다.' }, { status: 404 } ); } - return Response.json({ - success: true, - message: '글이 삭제되었습니다.', + if (post.seriesId) { + await Series.findByIdAndUpdate(post.seriesId, { + $pull: { posts: post._id }, + $inc: { postCount: -1 }, + }); + } + + await Post.deleteOne({ _id: params.slug }); + + return NextResponse.json({ + message: '포스트가 성공적으로 삭제되었습니다.', }); - } catch (error) { - console.error(error); - return Response.json( - { success: false, error: 'Server Error' }, + } catch (error: any) { + return NextResponse.json( + { error: error.message || '포스트 삭제에 실패했습니다.' }, { status: 500 } ); } diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index c004279..0ac0418 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -3,6 +3,8 @@ import dbConnect from '@/app/lib/dbConnect'; import { getServerSession } from 'next-auth/next'; import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; import { generateUniqueSlug } from '@/app/lib/utils/post'; +import Series from '@/app/models/Series'; +import { QuerySelector } from 'mongoose'; // GET /api/posts - 모든 글 조회 export async function GET(req: Request) { @@ -11,6 +13,11 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url); const query = searchParams.get('query') || ''; + const seriesSlug = searchParams.get('series') || ''; + + const seriesId = seriesSlug + ? await Series.findOne({ slug: seriesSlug }, '_id') + : null; // 검색 조건 구성 const searchConditions = { @@ -19,9 +26,17 @@ export async function GET(req: Request) { { content: { $regex: query, $options: 'i' } }, { subTitle: { $regex: query, $options: 'i' } }, ], + $and: [], }; + if (seriesId) { + (searchConditions.$and as QuerySelector[]).push({ + seriesId: seriesId, + } as QuerySelector); + } + const posts = await Post.find(searchConditions) + .sort({ date: -1 }) .limit(10); @@ -45,8 +60,15 @@ export async function POST(req: Request) { } await dbConnect(); - const { title, subTitle, author, content, profileImage, thumbnailImage } = - await req.json(); + const { + title, + subTitle, + author, + content, + profileImage, + thumbnailImage, + seriesId, + } = await req.json(); if (!title || !content || !author || !content) { return Response.json( @@ -64,6 +86,7 @@ export async function POST(req: Request) { timeToRead: Math.ceil(content.length / 500), profileImage, thumbnailImage: thumbnailImage || getThumbnailInMarkdown(content), + seriesId: seriesId || null, }; const newPost = await Post.create(post); @@ -74,6 +97,13 @@ export async function POST(req: Request) { ); } + if (post.seriesId) { + await Series.findByIdAndUpdate(post.seriesId, { + $push: { posts: newPost._id }, + $inc: { postCount: 1 }, // postCount 1 증가 + }); + } + return Response.json( { success: true, diff --git a/app/api/series/[slug]/route.ts b/app/api/series/[slug]/route.ts new file mode 100644 index 0000000..6035643 --- /dev/null +++ b/app/api/series/[slug]/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import Series from '@/app/models/Series'; + +export async function GET( + request: Request, + { params }: { params: { slug: string } } +) { + try { + await dbConnect(); + const series = await Series.findOne({ slug: params.slug }).populate( + 'posts' + ); + + if (!series) { + return NextResponse.json( + { error: '해당 시리즈를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + return NextResponse.json(series); + } catch (error: any) { + return NextResponse.json( + { error: error.message || '시리즈 조회에 실패했습니다.' }, + { status: 500 } + ); + } +} + +export async function PUT( + request: Request, + { params }: { params: { slug: string } } +) { + try { + await dbConnect(); + const body = await request.json(); + + const updatedSeries = await Series.findOneAndUpdate( + { slug: params.slug }, + { + title: body.title, + description: body.description, + thumbnailImage: body.thumbnailImage, + order: body.order, + posts: body.posts, + }, + { new: true } + ).populate('posts'); + + if (!updatedSeries) { + return NextResponse.json( + { error: '수정할 시리즈를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + return NextResponse.json(updatedSeries); + } catch (error: any) { + return NextResponse.json( + { error: error.message || '시리즈 수정에 실패했습니다.' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { slug: string } } +) { + try { + await dbConnect(); + const deletedSeries = await Series.findOneAndDelete({ slug: params.slug }); + + if (!deletedSeries) { + return NextResponse.json( + { error: '삭제할 시리즈를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + return NextResponse.json({ + message: '시리즈가 성공적으로 삭제되었습니다.', + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || '시리즈 삭제에 실패했습니다.' }, + { status: 500 } + ); + } +} diff --git a/app/api/series/route.ts b/app/api/series/route.ts new file mode 100644 index 0000000..f668200 --- /dev/null +++ b/app/api/series/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import Series from '@/app/models/Series'; +import { createPostSlug } from '@/app/lib/utils/post'; + +export async function POST(request: Request) { + try { + await dbConnect(); + const body = await request.json(); + if (!body.title) { + return NextResponse.json( + { error: '시리즈 제목을 입력해주세요.' }, + { status: 400 } + ); + } + + const series = await Series.create({ + slug: createPostSlug(body.title), + title: body.title, + description: body.description, + thumbnailImage: body.thumbnailImage || '', + order: body.order || [], + posts: body.posts || [], + }); + + return NextResponse.json(series, { status: 201 }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || '시리즈 생성에 실패했습니다.' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + try { + await dbConnect(); + const { searchParams } = new URL(request.url); + const populate = searchParams.get('populate') === 'true'; + + const query = Series.find({}); + if (populate) { + query.populate('posts'); + } + + const series = await query; + return NextResponse.json(series); + } catch (error: any) { + return NextResponse.json( + { error: error.message || '시리즈 목록을 불러오는데 실패했습니다.' }, + { status: 500 } + ); + } +} diff --git a/app/entities/common/Loading/SVGLoadingSpinner.tsx b/app/entities/common/Loading/SVGLoadingSpinner.tsx new file mode 100644 index 0000000..292fb45 --- /dev/null +++ b/app/entities/common/Loading/SVGLoadingSpinner.tsx @@ -0,0 +1,19 @@ +import { ImSpinner2 } from 'react-icons/im'; + +interface SVGLoadingSpinnerProps { + message?: string; +} +const SVGLoadingSpinner = ({ message }: SVGLoadingSpinnerProps) => { + return ( +
+ + {message && {message}} +
+ ); +}; + +export default SVGLoadingSpinner; diff --git a/app/entities/common/NavBar.tsx b/app/entities/common/NavBar.tsx index 62815e2..41dcae8 100644 --- a/app/entities/common/NavBar.tsx +++ b/app/entities/common/NavBar.tsx @@ -36,11 +36,14 @@ const NavBar = () => { -
    -
  • +
      +
    • Blog
    • -
    • +
    • + Series +
    • +
    • Portfolio
    diff --git a/app/entities/common/Overlay/Overlay.tsx b/app/entities/common/Overlay/Overlay.tsx index 2bce718..038f794 100644 --- a/app/entities/common/Overlay/Overlay.tsx +++ b/app/entities/common/Overlay/Overlay.tsx @@ -1,10 +1,15 @@ import { ReactNode, useEffect, useRef } from 'react'; interface OverlayProps { + overlayOpen: boolean; setOverlayOpen: (open: boolean) => void; children: ReactNode; } -const Overlay = ({ setOverlayOpen, children }: OverlayProps) => { +const Overlay = ({ + overlayOpen = false, + setOverlayOpen, + children, +}: OverlayProps) => { const overlayRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -20,11 +25,16 @@ const Overlay = ({ setOverlayOpen, children }: OverlayProps) => { }, []); return ( -
    -
    - {children} + overlayOpen && ( +
    +
    + {children} +
    -
    + ) ); }; diff --git a/app/entities/common/Select.tsx b/app/entities/common/Select.tsx new file mode 100644 index 0000000..aa596f7 --- /dev/null +++ b/app/entities/common/Select.tsx @@ -0,0 +1,29 @@ +interface SelectOption { + value: T; + label: string; +} +interface SelectProps { + options: SelectOption[]; + defaultValue: T; + setValue: (value: T) => void; +} +const Select = ({ + options, + defaultValue, + setValue, +}: SelectProps) => { + return ( + + ); +}; +export default Select; diff --git a/app/entities/post/detail/PostHeader.tsx b/app/entities/post/detail/PostHeader.tsx index ee3c4d6..2c0e157 100644 --- a/app/entities/post/detail/PostHeader.tsx +++ b/app/entities/post/detail/PostHeader.tsx @@ -50,7 +50,7 @@ const PostHeader = ({ 'post-header h-[220px] md:h-[292px] relative overflow-hidden w-full text-center bg-gray-400/50' } > -

    +

    {displayTitle} {!isTypingComplete && ( diff --git a/app/entities/post/list/PostList.tsx b/app/entities/post/list/PostList.tsx index 1b9635f..bdc2784 100644 --- a/app/entities/post/list/PostList.tsx +++ b/app/entities/post/list/PostList.tsx @@ -1,29 +1,28 @@ import { Post } from '@/app/types/Post'; -import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator'; import PostPreview from '@/app/entities/post/list/PostPreview'; import profile from '@/app/public/profile.jpg'; import NotFound from '@/app/entities/common/Animation/NotFound'; +import SVGLoadingSpinner from '@/app/entities/common/Loading/SVGLoadingSpinner'; const PostList = (props: { query: string; loading: boolean; posts: Post[] | undefined; + resetSearchCondition: () => void; }) => { return (
      {props.loading ? ( -
      - -
      + ) : props.posts && props.posts.length > 0 ? ( props.posts.map( (post) => post._id && ( -
    • +
    • +
      +
      )}
    diff --git a/app/entities/post/list/PostPreview.tsx b/app/entities/post/list/PostPreview.tsx index 0c5ae86..49b6d5d 100644 --- a/app/entities/post/list/PostPreview.tsx +++ b/app/entities/post/list/PostPreview.tsx @@ -21,15 +21,15 @@ const PostPreview = ({ const [isLoading, setIsLoading] = useState(true); return ( - +
    {isLoading && ( @@ -49,10 +49,12 @@ const PostPreview = ({ onLoad={() => setIsLoading(false)} />
    -
    -

    {title}

    -

    {subTitle ? subTitle.slice(0, 80) + '...' : ''}

    -
    +
    +
    +

    {title}

    +

    {subTitle ? subTitle.slice(0, 80) + '...' : ''}

    +
    +
    diff --git a/app/entities/post/list/SearchSection.tsx b/app/entities/post/list/SearchSection.tsx index e7bb38b..a7ca5a5 100644 --- a/app/entities/post/list/SearchSection.tsx +++ b/app/entities/post/list/SearchSection.tsx @@ -1,22 +1,41 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { FaBook, FaSearch } from 'react-icons/fa'; -import { BiChevronDown } from 'react-icons/bi'; +import { BiChevronDown, BiReset } from 'react-icons/bi'; import Overlay from '@/app/entities/common/Overlay/Overlay'; -import SeriesDropdownItem from '@/app/entities/post/series/SeriesDropdownItem'; -import Tag from '@/app/entities/common/Tag'; +import SeriesDropdownItem from '@/app/entities/series/SeriesDropdownItem'; import SearchOverlayContainer from '@/app/entities/common/Overlay/Search/SearchOverlayContainer'; import useSearchQueryStore from '@/app/stores/useSearchQueryStore'; +import { getAllSeriesData } from '@/app/entities/series/api/series'; +import { Series } from '@/app/types/Series'; +import { LuTimerReset } from 'react-icons/lu'; +import { MdLockReset } from 'react-icons/md'; +import { RiResetRightLine } from 'react-icons/ri'; interface SearchSectionProps { query: string; setQuery: (query: string) => void; + resetSearchCondition: () => void; } -const SearchSection = ({ query, setQuery }: SearchSectionProps) => { +const SearchSection = ({ + query, + setQuery, + resetSearchCondition, +}: SearchSectionProps) => { const [searchOpen, setSearchOpen] = useState(false); const [seriesOpen, setSeriesOpen] = useState(false); const latest = useSearchQueryStore((state) => state.latestSearchQueries); + const [series, setSeries] = useState([]); + + const getSeries = async () => { + const data = await getAllSeriesData(); + setSeries(data); + }; + + useEffect(() => { + getSeries(); + }, []); return (
    @@ -40,16 +59,18 @@ const SearchSection = ({ query, setQuery }: SearchSectionProps) => { {seriesOpen && (
    - - + {series?.map((s) => ( + + ))} + {series?.length === 0 && ( +
    시리즈가 없습니다.
    + )}
    )} @@ -57,25 +78,29 @@ const SearchSection = ({ query, setQuery }: SearchSectionProps) => {
    {/* 검색 버튼 및 검색창 */} -
    +
    + {/* 검색 오버레이 */} - {searchOpen && ( - - setSearchOpen(false)} - tags={latest || []} - /> - - )} + + setSearchOpen(false)} + tags={latest || []} + /> +
    diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index 0907920..715a764 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -1,16 +1,23 @@ 'use client'; import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; -import Link from 'next/link'; import { PostBody } from '@/app/types/Post'; import { StaticImport } from 'next/dist/shared/lib/get-img-props'; -import LoadingSpinner from '@/app/entities/common/Loading/LoadingSpinner'; import axios from 'axios'; import useToast from '@/app/hooks/useToast'; import { useBlockNavigate } from '@/app/hooks/useBlockNavigate'; import { useRouter, useSearchParams } from 'next/navigation'; +import PostWriteButtons from '@/app/entities/post/write/PostWriteButtons'; +import { validatePost } from '@/app/lib/utils/validate/validate'; +import Select from '@/app/entities/common/Select'; +import { Series } from '@/app/types/Series'; +import Overlay from '@/app/entities/common/Overlay/Overlay'; +import { FaPlus } from 'react-icons/fa6'; +import CreateSeriesOverlayContainer from '@/app/entities/series/CreateSeriesOverlayContainer'; +import { getAllSeriesData } from '@/app/entities/series/api/series'; +import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator'; const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); @@ -23,14 +30,21 @@ const BlogForm = () => { 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 toast = useToast(); const router = useRouter(); - const buttonStyle = `font-bold py-2 px-4 rounded mr-2 disabled:bg-opacity-75 `; const NICKNAME = '개발자 서정우'; + const [createSeriesOpen, setCreateSeriesOpen] = useState(false); useBlockNavigate({ title, content: content || '' }); + useEffect(() => { + getSeries(); + }, []); + useEffect(() => { if (slug) { getPostDetail(); @@ -44,8 +58,22 @@ const BlogForm = () => { content: content || '', profileImage, thumbnailImage, + seriesId: seriesId || '', }; + // 시리즈 + 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); @@ -72,50 +100,11 @@ const BlogForm = () => { } }; - const validatePost = ( - post: PostBody - ): { isValid: boolean; errors: string[] } => { - const errors: string[] = []; - - // 필수 필드 검사 - if (!post.title?.trim()) { - errors.push('제목은 필수입니다'); - } - if (!post.content?.trim()) { - errors.push('내용은 필수입니다'); - } - - // 길이 제한 검사 - if (post.title && post.title.length > 100) { - errors.push('제목은 100자를 초과할 수 없습니다'); - } - if (post.subTitle && post.subTitle.length > 200) { - errors.push('부제목은 200자를 초과할 수 없습니다'); - } - if (post.content && post.content.length > 50000) { - errors.push('내용은 20000자를 초과할 수 없습니다'); - } - - // 최소 길이 검사 - if (post.title && post.title.length < 2) { - errors.push('제목은 최소 2자 이상이어야 합니다'); - } - - if (post.content && post.content.length < 10) { - errors.push('내용은 최소 10자 이상이어야 합니다'); - } - - setErrors(errors); - return { - isValid: errors.length === 0, - errors, - }; - }; - const submitHandler = (post: PostBody) => { try { setSubmitLoading(true); const { isValid, errors } = validatePost(post); + setErrors(errors); if (!isValid) { toast.error('유효성 검사 실패'); console.error('유효성 검사 실패', errors); @@ -162,6 +151,42 @@ const BlogForm = () => { onChange={(e) => setSubTitle(e.target.value)} value={subTitle} /> +
    + +