Skip to content

[Feature] 검색 기능 구현 #40

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b67ec69
style: 화면이 좁을때는 안보이게
ShipFriend0516 Dec 22, 2024
d9cae98
refactor: BlogForm으로 전해주던 props 통합
ShipFriend0516 Dec 22, 2024
78164bb
feat: 글 수정 API 구현
ShipFriend0516 Dec 22, 2024
20cf076
refactor: 글 수정 페이지로 이동할때, searchparams postId가 아닌 slug를 사용하도록 수정
ShipFriend0516 Dec 22, 2024
c1df542
feat: 글 수정시에 썸네일이 바뀌면 수정하도록 구현
ShipFriend0516 Dec 22, 2024
bfbe0f5
chore: 콘솔로그 제거
ShipFriend0516 Dec 22, 2024
10f52b3
chore: next third parties 라이브러리 설치
ShipFriend0516 Dec 22, 2024
df16fcb
feat: Google Analytics 스크립트 추가
ShipFriend0516 Dec 22, 2024
dcb96ab
feat: 검색 오버레이 컴포넌트 구현
ShipFriend0516 Dec 22, 2024
677f99c
feat: 다크모드를 고려한 배경색 설정
ShipFriend0516 Dec 22, 2024
de47ff8
feat: overlay 외부 클릭시 닫히게 구현
ShipFriend0516 Dec 22, 2024
5a64f09
fix: use client 제거
ShipFriend0516 Dec 22, 2024
e4520af
feat: query를 통해 검색할 수 있는 API 구현
ShipFriend0516 Dec 22, 2024
f20a969
feat: debounce로 검색 API 최적화
ShipFriend0516 Dec 22, 2024
225bb3f
refactor: 검색 오버레이 안에 들어가는 내용 컴포넌트 분리, 태그 컴포넌트 분리
ShipFriend0516 Dec 22, 2024
71714cf
feat: 검색 결과 없을 경우 notfound 컴포넌트
ShipFriend0516 Dec 22, 2024
c523110
feat: 검색어가 무엇인지 확인가능하게 구현
ShipFriend0516 Dec 22, 2024
9626de6
feat: 검색어 지우는 버튼 추가
ShipFriend0516 Dec 22, 2024
66cdfe6
feat: Tag에 onClick 핸들러 있을 경우 커서 포인터로
ShipFriend0516 Dec 22, 2024
6fb08f8
feat: 서치 오버레이 애니메이션 적용
ShipFriend0516 Dec 22, 2024
ff1b5a8
feat: 최근 검색어를 위한 전역 상태 스토어 구현
ShipFriend0516 Dec 22, 2024
b20a5cc
fix: debounce가 적용되지 않던 문제 해결
ShipFriend0516 Dec 22, 2024
f03819c
feat: 최근 검색어를 볼 수 있도록 구현
ShipFriend0516 Dec 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/admin/posts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -68,7 +68,7 @@ const AdminPostListPage = () => {
<PostListItem
key={post._id}
post={post}
handleEdit={() => handleEdit(post._id)}
handleEdit={() => handleEdit(post.slug)}
handleDelete={() => handleDeleteClick(post._id)}
/>
))}
Expand Down
27 changes: 1 addition & 26 deletions app/admin/write/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className={'pt-4'}>
<h1 className={'text-3xl text-center mb-4'}>글 작성</h1>
<BlogForm postBlog={postBlog} postId={postId || null} />
<BlogForm />
</section>
);
};
Expand Down
13 changes: 9 additions & 4 deletions app/api/posts/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 17 additions & 2 deletions app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions app/entities/common/Animation/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={'w-full mx-auto flex justify-center'}>
<div className={className + ' flex flex-col items-center'}>
<LottiePlayer
animationData={NotFoundAnimation}
play={true}
loop={true}
style={{ width: 200 }}
/>
<div
className={
'text-medium-m text-gray-500 text-center flex flex-col gap-0.5'
}
>
{message?.split('\n').map((text) => <p key={text}>{text}</p>)}
</div>
{redirect && (
<Link
className={
'bg-green-100 hover:bg-green-100/80 text-white text-semibold-r rounded-xl px-4 py-1 mt-2'
}
href={redirect.path}
>
{redirect.buttonText}
</Link>
)}
</div>
</div>
);
};

export default NotFound;
31 changes: 31 additions & 0 deletions app/entities/common/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div ref={overlayRef} className="fixed inset-0 bg-black bg-opacity-50 z-50">
<div className="animate-popUp container bg-overlay bg-opacity-90 text-overlay rounded-lg mx-auto mt-[24%] max-w-2xl">
{children}
</div>
</div>
);
};

export default Overlay;
55 changes: 55 additions & 0 deletions app/entities/common/Overlay/Search/SearchOverlayContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className=" px-5 p-4">
<div className="flex mb-4">
<div className={'flex flex-grow items-center space-x-4 relative'}>
<FaSearch size={20} className="text-gray-400" />
<input
type="text"
placeholder="검색어를 입력하세요..."
className="w-full p-2 outline-none"
autoFocus
onChange={(e) => props.setQuery(e.target.value)}
value={props.value}
/>
<button
className={`${props.value ? 'block' : 'hidden'} p-2 text-gray-400 absolute right-2`}
onClick={emptyInput}
>
<FaX />
</button>
</div>
<button
onClick={props.onCancel}
className="text-gray-500 hover:text-gray-700 p-2"
>
ESC
</button>
</div>

<div className="space-y-4">
<div className="text-sm text-gray-500">최근 검색어</div>
<div className="flex flex-wrap gap-2">
{props.tags.map((tag) => (
<Tag key={tag} content={tag} onClick={() => props.setQuery(tag)} />
))}
</div>
</div>
</div>
);
};

export default SearchOverlayContainer;
16 changes: 16 additions & 0 deletions app/entities/common/Tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface TagProps {
content: string;
onClick?: () => void;
}
const Tag = ({ content, onClick }: TagProps) => {
return (
<span
onClick={onClick}
className={`${onClick ? 'cursor-pointer' : ''} px-3 py-1 bg-gray-100 rounded-full text-sm`}
>
{content}
</span>
);
};

export default Tag;
2 changes: 1 addition & 1 deletion app/entities/post/detail/PostTOC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const PostTOC = ({ postContent }: { postContent: string }) => {
};

return (
<div className="fixed post-toc hidden lg:block w-[280px] top-1/2 -translate-y-1/2 left-[calc(50%+524px)] transition-all text-sm bg-gray-100/80 rounded-md p-4 text-black">
<div className="fixed post-toc hidden 2xl:block w-[280px] top-[calc(50%+100px)] -translate-y-1/2 left-[calc(50%+524px)] transition-all text-sm bg-gray-100/80 rounded-md p-4 text-black">
<h4 className={'text-xl font-bold mb-2'}>📌 Table of Contents</h4>
<ul className={'list-none'}>
{parseHeadings(postContent).map((heading) => {
Expand Down
16 changes: 13 additions & 3 deletions app/entities/post/list/PostList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ul
className={
Expand All @@ -14,8 +19,7 @@ const PostList = (props: { loading: boolean; posts: Post[] | undefined }) => {
<div className={'mx-auto col-span-3 w-1/3 h-full pt-20'}>
<LoadingIndicator message={'발행된 글을 로딩 중입니다..'} />
</div>
) : (
props.posts &&
) : props.posts && props.posts.length > 0 ? (
props.posts.map(
(post) =>
post._id && (
Expand All @@ -34,6 +38,12 @@ const PostList = (props: { loading: boolean; posts: Post[] | undefined }) => {
</li>
)
)
) : (
<div className={'col-span-3'}>
<NotFound
message={`${props.query || '검색어'}에 대한 검색 결과가 없습니다.`}
/>
</div>
)}
</ul>
);
Expand Down
84 changes: 84 additions & 0 deletions app/entities/post/list/SearchSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-full max-w-6xl mx-auto">
<nav className="flex items-center justify-between py-4 px-6">
<div className="flex items-center space-x-6">
{/* 시리즈 드롭다운 */}
<div className="relative">
<button
onClick={() => setSeriesOpen(!seriesOpen)}
className="flex items-center space-x-2 hover:text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-100"
>
<FaBook size={20} />
<span>시리즈</span>
<BiChevronDown
size={16}
className={`transform transition-transform ${seriesOpen ? 'rotate-180' : ''}`}
/>
</button>

{/* 시리즈 드롭다운 메뉴 */}
{seriesOpen && (
<div className="bg-overlay absolute left-0 mt-2 w-64 z-50 text-overlay">
<div className="py-2">
<SeriesDropdownItem
setSeriesOpen={setSeriesOpen}
seriesTitle={'Next.js 최적화'}
seriesCount={3}
/>
<SeriesDropdownItem
setSeriesOpen={setSeriesOpen}
seriesTitle={'블로그 개발기'}
seriesCount={5}
/>
</div>
</div>
)}
</div>
</div>

{/* 검색 버튼 및 검색창 */}
<div className="relative">
<button
onClick={() => setSearchOpen(!searchOpen)}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<FaSearch size={20} />
</button>

{/* 검색 오버레이 */}
{searchOpen && (
<Overlay setOverlayOpen={setSearchOpen}>
<SearchOverlayContainer
setQuery={setQuery}
value={query}
onCancel={() => setSearchOpen(false)}
tags={latest || []}
/>
</Overlay>
)}
</div>
</nav>
</div>
);
};
export default SearchSection;
Loading
Loading