diff --git a/app/admin/posts/page.tsx b/app/admin/posts/page.tsx index f96efbd..5b84e04 100644 --- a/app/admin/posts/page.tsx +++ b/app/admin/posts/page.tsx @@ -28,9 +28,9 @@ const AdminPostListPage = () => { setLoading(false); }; - const handleEdit = (postId: string) => { + const handleEdit = (slug: string) => { toast.success('글 수정 페이지로 이동합니다.'); - router.push(`/admin/write?postId=${postId}`); + router.push(`/admin/write?slug=${slug}`); }; const handleDeleteClick = (postId: string) => { @@ -68,7 +68,7 @@ const AdminPostListPage = () => { handleEdit(post._id)} + handleEdit={() => handleEdit(post.slug)} handleDelete={() => handleDeleteClick(post._id)} /> ))} diff --git a/app/admin/write/page.tsx b/app/admin/write/page.tsx index 8bbb55a..e5f09b6 100644 --- a/app/admin/write/page.tsx +++ b/app/admin/write/page.tsx @@ -1,35 +1,10 @@ -'use client'; import BlogForm from '@/app/entities/post/write/BlogForm'; -import axios from 'axios'; -import { PostBody } from '@/app/types/Post'; -import { useRouter, useSearchParams } from 'next/navigation'; -import useToast from '@/app/hooks/useToast'; const BlogWritePage = () => { - const router = useRouter(); - const params = useSearchParams(); - const postId = params.get('postId'); - const toast = useToast(); - - const postBlog = async (post: PostBody) => { - try { - const response = await axios.post('/api/posts', post); - const data = await response.data; - console.log('글 발행 결과', data); - if (response.status === 201) { - toast.success('글이 성공적으로 발행되었습니다.'); - router.push('/posts'); - } - } catch (e) { - toast.error('글 발행 중 오류 발생했습니다.'); - console.error('글 발행 중 오류 발생', e); - } - }; - return (

글 작성

- +
); }; diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts index 7fcaf25..d9cc02f 100644 --- a/app/api/posts/[slug]/route.ts +++ b/app/api/posts/[slug]/route.ts @@ -2,6 +2,7 @@ import dbConnect from '@/app/lib/dbConnect'; import Post from '@/app/models/Post'; import { NextRequest } from 'next/server'; +import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; export async function GET( req: NextRequest, @@ -36,10 +37,14 @@ export async function PUT( await dbConnect(); const body = await req.json(); - const updatedPost = await Post.findByIdAndUpdate(params.slug, body, { - new: true, - runValidators: true, - }).lean(); + const updatedPost = await Post.findOneAndUpdate( + { slug: params.slug }, + { ...body, thumbnailImage: getThumbnailInMarkdown(body.content) }, + { + new: true, + runValidators: true, + } + ).lean(); if (!updatedPost) { return Response.json( diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index d638196..c004279 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -5,10 +5,25 @@ import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; import { generateUniqueSlug } from '@/app/lib/utils/post'; // GET /api/posts - 모든 글 조회 -export async function GET() { +export async function GET(req: Request) { try { await dbConnect(); - const posts = await Post.find({}).sort({ createdAt: -1 }).lean(); + const { searchParams } = new URL(req.url); + + const query = searchParams.get('query') || ''; + + // 검색 조건 구성 + const searchConditions = { + $or: [ + { title: { $regex: query, $options: 'i' } }, + { content: { $regex: query, $options: 'i' } }, + { subTitle: { $regex: query, $options: 'i' } }, + ], + }; + + const posts = await Post.find(searchConditions) + .sort({ date: -1 }) + .limit(10); return Response.json({ success: true, posts: posts }); } catch (error) { diff --git a/app/entities/common/Animation/NotFound.tsx b/app/entities/common/Animation/NotFound.tsx new file mode 100644 index 0000000..db86149 --- /dev/null +++ b/app/entities/common/Animation/NotFound.tsx @@ -0,0 +1,45 @@ +import LottiePlayer from '@/app/entities/common/Animation/LottiePlayer'; +import NotFoundAnimation from '@/app/public/assets/notfound2.json'; +import Link from 'next/link'; + +interface NotFoundProps { + message?: string; + className?: string; + redirect?: { + path: string; + buttonText?: string; + }; +} +const NotFound = ({ message, className, redirect }: NotFoundProps) => { + return ( +
+
+ +
+ {message?.split('\n').map((text) =>

{text}

)} +
+ {redirect && ( + + {redirect.buttonText} + + )} +
+
+ ); +}; + +export default NotFound; diff --git a/app/entities/common/Overlay/Overlay.tsx b/app/entities/common/Overlay/Overlay.tsx new file mode 100644 index 0000000..2bce718 --- /dev/null +++ b/app/entities/common/Overlay/Overlay.tsx @@ -0,0 +1,31 @@ +import { ReactNode, useEffect, useRef } from 'react'; + +interface OverlayProps { + setOverlayOpen: (open: boolean) => void; + children: ReactNode; +} +const Overlay = ({ setOverlayOpen, children }: OverlayProps) => { + const overlayRef = useRef(null); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (overlayRef.current && overlayRef.current === (event.target as Node)) { + setOverlayOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
+ {children} +
+
+ ); +}; + +export default Overlay; diff --git a/app/entities/common/Overlay/Search/SearchOverlayContainer.tsx b/app/entities/common/Overlay/Search/SearchOverlayContainer.tsx new file mode 100644 index 0000000..1f86d43 --- /dev/null +++ b/app/entities/common/Overlay/Search/SearchOverlayContainer.tsx @@ -0,0 +1,55 @@ +import { FaSearch } from 'react-icons/fa'; +import Tag from '@/app/entities/common/Tag'; +import { FaX } from 'react-icons/fa6'; + +const SearchOverlayContainer = (props: { + setQuery: (query: string) => void; + value: string; + onCancel: () => void; + tags: string[]; +}) => { + const emptyInput = () => { + props.setQuery(''); + }; + + return ( +
+
+
+ + props.setQuery(e.target.value)} + value={props.value} + /> + +
+ +
+ +
+
최근 검색어
+
+ {props.tags.map((tag) => ( + props.setQuery(tag)} /> + ))} +
+
+
+ ); +}; + +export default SearchOverlayContainer; diff --git a/app/entities/common/Tag.tsx b/app/entities/common/Tag.tsx new file mode 100644 index 0000000..9b695b5 --- /dev/null +++ b/app/entities/common/Tag.tsx @@ -0,0 +1,16 @@ +interface TagProps { + content: string; + onClick?: () => void; +} +const Tag = ({ content, onClick }: TagProps) => { + return ( + + {content} + + ); +}; + +export default Tag; diff --git a/app/entities/post/detail/PostTOC.tsx b/app/entities/post/detail/PostTOC.tsx index 8039f90..a36d814 100644 --- a/app/entities/post/detail/PostTOC.tsx +++ b/app/entities/post/detail/PostTOC.tsx @@ -12,7 +12,7 @@ const PostTOC = ({ postContent }: { postContent: string }) => { }; return ( -
+

📌 Table of Contents

    {parseHeadings(postContent).map((heading) => { diff --git a/app/entities/post/list/PostList.tsx b/app/entities/post/list/PostList.tsx index 1b5c1a4..1b9635f 100644 --- a/app/entities/post/list/PostList.tsx +++ b/app/entities/post/list/PostList.tsx @@ -2,8 +2,13 @@ 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'; -const PostList = (props: { loading: boolean; posts: Post[] | undefined }) => { +const PostList = (props: { + query: string; + loading: boolean; + posts: Post[] | undefined; +}) => { return (
      {
      - ) : ( - props.posts && + ) : props.posts && props.posts.length > 0 ? ( props.posts.map( (post) => post._id && ( @@ -34,6 +38,12 @@ const PostList = (props: { loading: boolean; posts: Post[] | undefined }) => { ) ) + ) : ( +
      + +
      )}
    ); diff --git a/app/entities/post/list/SearchSection.tsx b/app/entities/post/list/SearchSection.tsx new file mode 100644 index 0000000..e7bb38b --- /dev/null +++ b/app/entities/post/list/SearchSection.tsx @@ -0,0 +1,84 @@ +'use client'; +import { useState } from 'react'; +import { FaBook, FaSearch } from 'react-icons/fa'; +import { BiChevronDown } 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 SearchOverlayContainer from '@/app/entities/common/Overlay/Search/SearchOverlayContainer'; +import useSearchQueryStore from '@/app/stores/useSearchQueryStore'; + +interface SearchSectionProps { + query: string; + setQuery: (query: string) => void; +} + +const SearchSection = ({ query, setQuery }: SearchSectionProps) => { + const [searchOpen, setSearchOpen] = useState(false); + const [seriesOpen, setSeriesOpen] = useState(false); + const latest = useSearchQueryStore((state) => state.latestSearchQueries); + + return ( +
    + +
    + ); +}; +export default SearchSection; diff --git a/app/entities/post/series/SeriesDropdownItem.tsx b/app/entities/post/series/SeriesDropdownItem.tsx new file mode 100644 index 0000000..285439f --- /dev/null +++ b/app/entities/post/series/SeriesDropdownItem.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; + +interface SeriesDropdownItemProps { + setSeriesOpen: (open: boolean) => void; + seriesTitle: string; + seriesCount: number; +} +const SeriesDropdownItem = ({ + seriesTitle, + seriesCount, + setSeriesOpen, +}: SeriesDropdownItemProps) => { + return ( + setSeriesOpen(false)} + > +
    {seriesTitle}
    +
    {seriesCount}개의 포스트
    + + ); +}; +export default SeriesDropdownItem; diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index a7ad1b5..0907920 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -2,26 +2,21 @@ import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; import { useEffect, useState } from 'react'; - -import * as commands from '@uiw/react-md-editor/commands'; import dynamic from 'next/dynamic'; import Link from 'next/link'; -import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator'; 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'; const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); -interface BlogFormProps { - postBlog: (post: PostBody) => Promise; - postId: string | null; -} - -const BlogForm = ({ postBlog, postId }: BlogFormProps) => { +const BlogForm = () => { + const params = useSearchParams(); + const slug = params.get('slug'); const [submitLoading, setSubmitLoading] = useState(false); const [title, setTitle] = useState(''); const [subTitle, setSubTitle] = useState(''); @@ -30,16 +25,17 @@ const BlogForm = ({ postBlog, postId }: BlogFormProps) => { const [thumbnailImage, setThumbnailImage] = useState(); 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 = '개발자 서정우'; useBlockNavigate({ title, content: content || '' }); useEffect(() => { - if (postId) { + if (slug) { getPostDetail(); } - }, [postId]); + }, [slug]); const postBody: PostBody = { title, @@ -50,6 +46,32 @@ const BlogForm = ({ postBlog, postId }: BlogFormProps) => { thumbnailImage, }; + 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 validatePost = ( post: PostBody ): { isValid: boolean; errors: string[] } => { @@ -101,7 +123,11 @@ const BlogForm = ({ postBlog, postId }: BlogFormProps) => { return; } - postBlog(post); + if (slug) { + updatePost(post); + } else { + postBlog(post); + } } catch (e) { console.error('글 발행 중 오류 발생', e); setSubmitLoading(false); @@ -110,7 +136,7 @@ const BlogForm = ({ postBlog, postId }: BlogFormProps) => { const getPostDetail = async () => { try { - const response = await axios.get(`/api/posts/${postId}`); + const response = await axios.get(`/api/posts/${slug}`); const data = await response.data; setTitle(data.post.title || 'dd'); setSubTitle(data.post.subTitle); @@ -169,7 +195,7 @@ const BlogForm = ({ postBlog, postId }: BlogFormProps) => { submitHandler(postBody); }} > - {submitLoading ? : postId ? '글 수정' : '글 발행'} + {submitLoading ? : slug ? '글 수정' : '글 발행'}
diff --git a/app/globals.css b/app/globals.css index 27062a8..98fc87d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,15 +5,31 @@ :root { /*--background: #ffffff;*/ /*--foreground: #171717;*/ + --background: #ededed; + --foreground: #1e201e; + --disabled: #a0a0a0; + --text-default: #000000; + --text-overlay: #FFFFFF; + --bg-overlay: #1e201e; +} +.dark { --background: #1e201e; --foreground: #ededed; --disabled: #a0a0a0; + --text-default: #FFFFFF; + --text-overlay: #000000; + --bg-overlay: #ededed; } + @media (prefers-color-scheme: dark) { :root { --background: #1e201e; --foreground: #ededed; + --disabled: #a0a0a0; + --text-default: #FFFFFF; + --text-overlay: #000000; + --bg-overlay: #ededed; } } diff --git a/app/layout.tsx b/app/layout.tsx index 3347165..c3d8eed 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import Footer from '@/app/entities/common/Footer'; import React from 'react'; import NavBar from '@/app/entities/common/NavBar'; import ToastProvider from '@/app/entities/common/Toast/ToastProvider'; +import { GoogleAnalytics } from '@next/third-parties/google'; export const metadata: Metadata = { title: 'ShipFriend TechBlog', @@ -79,6 +80,7 @@ export default function RootLayout({
{children}