feat: 검색 기능 구현
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m52s

This commit is contained in:
ParkWonYeop
2025-12-27 22:17:44 +09:00
parent bf8c548b6a
commit cfecb3d834
5 changed files with 327 additions and 140 deletions

View File

@@ -8,7 +8,7 @@ export const getPosts = async (params?: {
keyword?: string;
category?: string;
tag?: string;
sort?: string; // 🆕 정렬 옵션 추가 (예: 'viewCount,desc')
sort?: string;
}) => {
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', {
params: {
@@ -20,12 +20,13 @@ export const getPosts = async (params?: {
return response.data.data;
};
// 2. 카테고리별 게시글 조회
export const getPostsByCategory = async (categoryName: string, page = 0, size = 10) => {
// 2. 카테고리별 게시글 조회 (🛠️ keyword 파라미터 추가)
export const getPostsByCategory = async (categoryName: string, page = 0, size = 10, keyword?: string) => {
return getPosts({
page,
size,
category: categoryName
category: categoryName,
keyword // 🆕 검색어 전달
});
};

View File

@@ -2,11 +2,11 @@
import { use, useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getPostsByCategory } from '@/api/posts';
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용
import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem';
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List } from 'lucide-react';
import { notFound } from 'next/navigation';
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
import { clsx } from 'clsx';
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
@@ -14,39 +14,45 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
const categoryName = decodeURIComponent(id);
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
// 📢 공지 카테고리인지 판별
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
const [page, setPage] = useState(0);
// 공지사항이면 기본값을 'list'로 설정
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const PAGE_SIZE = 10;
useEffect(() => {
// 📢 공지 카테고리는 강제로 리스트 뷰 적용
if (isNoticeCategory) {
setViewMode('list');
return;
}
// 그 외에는 로컬 스토리지 설정 따름
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
if (savedMode) setViewMode(savedMode);
}, [isNoticeCategory]);
const handleViewModeChange = (mode: 'grid' | 'list') => {
setViewMode(mode);
// 공지 카테고리가 아닐 때만 사용자 설정을 저장 (공지는 뷰 강제이므로 저장 안 함)
if (!isNoticeCategory) {
localStorage.setItem('postViewMode', mode);
}
};
// 🆕 검색어가 변경되면 페이지를 0으로 초기화
const handleSearch = (newKeyword: string) => {
setKeyword(newKeyword);
setPage(0);
};
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
queryKey: ['posts', 'category', apiCategoryName, page],
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE),
placeholderData: (previousData) => previousData,
queryKey: ['posts', 'category', apiCategoryName, page, keyword], // 🔑 쿼리 키에 keyword 추가
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword), // 🆕 검색어 전달
// 🛠️ 수정됨: 검색어가 바뀌면 이전 데이터를 보여주지 않고 로딩 상태로 전환
// (페이지 이동 시에는 부드럽게 보여주기 위해 유지)
placeholderData: (previousData, previousQuery) => {
const prevKeyword = previousQuery?.queryKey[4];
if (prevKeyword !== keyword) return undefined;
return previousData;
},
});
if (isLoading || (postsData === undefined && !error)) {
@@ -68,49 +74,80 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
const posts = postsData?.content || [];
// 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음)
// postsData가 any로 캐스팅되어 안전하게 접근
const pagingData = (postsData as any)?.page || postsData;
const totalElements = pagingData?.totalElements ?? 0;
const totalPages = pagingData?.totalPages ?? 0;
// page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용
const isLast = pagingData?.number !== undefined
? (pagingData.number + 1 >= pagingData.totalPages)
: (postsData?.last ?? true);
const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
const handleNextPage = () => {
if (!postsData?.last) {
if (!isLast) {
setPage((old) => old + 1);
}
};
return (
<div className="max-w-4xl mx-auto px-4 py-8">
{/* 헤더 영역 수정 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2">
{/* 헤더 영역 */}
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2 shrink-0">
{apiCategoryName} <span className="text-gray-400 text-lg font-normal"> </span>
</h1>
{/* 뷰 모드 버튼 */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => handleViewModeChange('grid')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
)}
title="카드형 보기"
>
<LayoutGrid size={18} />
</button>
<button
onClick={() => handleViewModeChange('list')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
)}
title="리스트형 보기"
>
<List size={18} />
</button>
<div className="flex items-center gap-3 w-full md:w-auto">
{/* 🔍 카테고리 내 검색바 */}
<PostSearch
onSearch={handleSearch}
placeholder={`'${apiCategoryName}' 내 검색`}
className="w-full md:w-64"
/>
{/* 뷰 모드 버튼 */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg shrink-0">
<button
onClick={() => handleViewModeChange('grid')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
)}
title="카드형 보기"
>
<LayoutGrid size={18} />
</button>
<button
onClick={() => handleViewModeChange('list')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
)}
title="리스트형 보기"
>
<List size={18} />
</button>
</div>
</div>
</div>
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
{keyword && (
<div className="mb-6 flex items-center gap-2 text-sm text-gray-600 bg-blue-50 px-4 py-3 rounded-lg border border-blue-100">
<SearchIcon size={16} className="text-blue-500" />
<span>
"{keyword}" : <strong>{totalElements}</strong>
</span>
</div>
)}
{posts.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-gray-400 mb-2"> .</p>
<p className="text-gray-400 mb-2">
{keyword ? `"${keyword}"에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
</p>
</div>
) : (
<>
@@ -139,12 +176,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
</button>
<span className="text-sm font-medium text-gray-600">
Page <span className="text-gray-900 font-bold">{page + 1}</span> {postsData && postsData.totalPages > 0 && `/ ${postsData.totalPages}`}
Page <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
</span>
<button
onClick={handleNextPage}
disabled={postsData?.last || isPlaceholderData}
disabled={isLast || isPlaceholderData}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
aria-label="다음 페이지"
>

View File

@@ -1,33 +1,48 @@
'use client';
import { useState, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { getPosts } from '@/api/posts';
import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem';
// PostSearch 컴포넌트 제거 (사이드바로 이동)
import { Post } from '@/types';
import { Loader2, Megaphone, Flame, Clock, ChevronRight } from 'lucide-react';
import { Loader2, Megaphone, Flame, Clock, ChevronRight, Search as SearchIcon } from 'lucide-react';
import Link from 'next/link';
export default function Home() {
// 1. 공지사항 조회 (최대 3개, 최신순)
function HomeContent() {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리 스트링에서 검색어 가져오기
const keyword = searchParams.get('keyword') || '';
// 1. 공지사항 조회
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
queryKey: ['posts', 'notices'],
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
});
// 2. 최신 게시글 조회 (넉넉히 10개 가져와서 공지 제외하고 3개만 사용)
// 2. 최신 게시글 조회
const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'latest'],
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
});
// 3. 인기 게시글 조회 (조회수순, 넉넉히 10개 가져와서 공지 제외하고 3개만 사용)
// 3. 인기 게시글 조회
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'popular'],
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
});
// 로딩 상태 처리
// 4. 검색 결과 조회
const { data: searchData, isLoading: isSearchLoading } = useQuery({
queryKey: ['posts', 'search', keyword],
queryFn: () => getPosts({ keyword, size: 20 }),
enabled: !!keyword,
});
if (isNoticesLoading || isLatestLoading || isPopularLoading) {
return (
<div className="flex justify-center items-center h-screen">
@@ -36,7 +51,6 @@ export default function Home() {
);
}
// 데이터 필터링 헬퍼 함수
const filterPosts = (posts: Post[] | undefined, limit: number) => {
if (!posts) return [];
return posts
@@ -45,88 +59,126 @@ export default function Home() {
};
const notices = noticesData?.content || [];
// 5개 -> 3개로 수정
const latestPosts = filterPosts(latestData?.content, 3);
const popularPosts = filterPosts(popularData?.content, 3);
const searchResults = searchData?.content || [];
const searchMeta = (searchData as any)?.page || searchData;
const searchTotalElements = searchMeta?.totalElements ?? 0;
return (
<main className="max-w-4xl mx-auto px-4 py-8 space-y-16">
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12">
{/* 📢 1. 공지사항 섹션 (리스트 형태) */}
{notices.length > 0 && (
<section className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
<Megaphone className="text-red-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"></h2>
</div>
<div className="flex flex-col gap-2">
{notices.map((post) => (
<PostListItem key={post.id} post={post} />
))}
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */}
{keyword ? (
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="flex items-center gap-2 mb-6 border-b border-gray-100 pb-4">
<SearchIcon className="text-blue-500" size={24} />
<h2 className="text-xl font-bold text-gray-800">
"{keyword}" <span className="text-blue-600 text-lg ml-1">{searchTotalElements}</span>
</h2>
</div>
{isSearchLoading ? (
<div className="py-20 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>
) : searchResults.length > 0 ? (
<div className="flex flex-col gap-0 border-t border-gray-100">
{searchResults.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</div>
) : (
<div className="text-center py-20 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
) : (
/* 🅱️ 대시보드 모드: 검색어가 없을 때 기존 화면 표시 */
<div className="space-y-16 animate-in fade-in duration-500">
{/* 공지사항 섹션 */}
{notices.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
<Megaphone className="text-red-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"></h2>
</div>
<div className="flex flex-col gap-2">
{notices.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</div>
</section>
)}
{/* 최신 포스트 섹션 */}
<section>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Clock className="text-blue-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
<Link href="/archive" className="text-sm text-gray-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<ChevronRight size={14} />
</Link>
</div>
{latestPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{latestPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 인기 포스트 섹션 */}
<section>
<div className="flex items-center gap-2 mb-6">
<Flame className="text-orange-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
{popularPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{popularPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 하단 아카이브 링크 */}
<div className="pt-8 pb-4 text-center">
<Link
href="/archive"
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200 transition-colors font-medium text-sm"
>
<ChevronRight size={16} />
</Link>
</div>
</div>
)}
{/* 🕒 2. 최신 게시글 섹션 (카드 형태) */}
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-100">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Clock className="text-blue-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
<Link href="/archive" className="text-sm text-gray-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<ChevronRight size={14} />
</Link>
</div>
{latestPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 첫 번째 글은 강조를 위해 크게 보여줄 수도 있지만, 여기선 균일하게 배치 */}
{latestPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 🔥 3. 인기 게시글 섹션 (카드 형태) */}
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-200">
<div className="flex items-center gap-2 mb-6">
<Flame className="text-orange-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
{popularPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{popularPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 하단 여백 및 아카이브 링크 배너 */}
<div className="pt-8 pb-4 text-center">
<Link
href="/archive"
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200 transition-colors font-medium text-sm"
>
<ChevronRight size={16} />
</Link>
</div>
</main>
);
}
export default function Home() {
return (
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" size={40} /></div>}>
<HomeContent />
</Suspense>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useState, useRef, useCallback, useEffect, useMemo, Suspense } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
// 🎨 이미지 최적화를 위해 next/image 사용
import Image from 'next/image';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -18,6 +18,7 @@ import { clsx } from 'clsx';
import { Profile, ProfileUpdateRequest, Category } from '@/types';
import { useAuthStore } from '@/store/authStore';
import toast from 'react-hot-toast';
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
for (const cat of categories) {
@@ -43,7 +44,6 @@ interface CategoryItemProps {
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
// 🆕 1. 하위 항목 중에 현재 활성화된 페이지가 있는지 확인 (재귀 체크)
const hasActiveChild = useMemo(() => {
const check = (cats: Category[] | undefined): boolean => {
if (!cats) return false;
@@ -54,10 +54,8 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
return check(category.children);
}, [category.children, pathname]);
// 🆕 2. 펼침 상태 관리 (자신이 활성화됐거나 하위가 활성화됐으면 기본값 true)
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
// 🆕 3. 페이지 이동으로 활성화 상태가 바뀌면 자동으로 펼치기
useEffect(() => {
if (isActive || hasActiveChild) {
setIsExpanded(true);
@@ -157,7 +155,7 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
{!isEditMode && category.children && category.children.length > 0 && (
<button
onClick={(e) => {
e.preventDefault(); // Link 이동 막기
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
@@ -194,12 +192,25 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
);
}
export default function Sidebar() {
function SidebarContent() {
const [isOpen, setIsOpen] = useState(true);
const pathname = usePathname();
const { role, _hasHydrated } = useAuthStore();
const queryClient = useQueryClient();
// 🆕 검색 로직 추가
const router = useRouter();
const searchParams = useSearchParams();
const keyword = searchParams.get('keyword') || '';
const handleSearch = (newKeyword: string) => {
if (newKeyword.trim()) {
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
} else {
router.push('/');
}
};
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
@@ -396,8 +407,18 @@ export default function Sidebar() {
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
{/* 🆕 2. 아카이브 링크 (페이지 이동) - 위로 이동 */}
<div className="mb-4 mt-2">
{/* 🆕 전체 검색바 (Archives 위) - 수정됨: 그림자 제거 */}
<div className="px-1 mb-6 mt-2">
<PostSearch
onSearch={handleSearch}
placeholder="검색..."
initialKeyword={keyword}
// className="shadow-sm" // 🎨 제거됨: 배경 박스처럼 보이는 문제 해결
/>
</div>
{/* 2. 아카이브 링크 */}
<div className="mb-4">
<Link
href="/archive"
className={clsx(
@@ -412,7 +433,7 @@ export default function Sidebar() {
</Link>
</div>
{/* 구분선 추가 */}
{/* 구분선 */}
<div className="border-t border-gray-100 mb-4" />
{/* 1. 카테고리 섹션 */}
@@ -484,7 +505,6 @@ export default function Sidebar() {
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="flex flex-col items-center mb-6">
<div className="relative w-24 h-24 mb-3 group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
{/* 🎨 모달 내부 프리뷰 이미지에도 적용 */}
<Image src={editForm.imageUrl || defaultProfile.imageUrl!} alt="Preview" fill className="rounded-full object-cover border-2 border-gray-100 group-hover:border-blue-300 transition-colors" unoptimized />
<div className="absolute inset-0 bg-black/30 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"><Camera className="text-white" size={24} /></div>
</div>
@@ -508,3 +528,11 @@ export default function Sidebar() {
</>
);
}
export default function Sidebar() {
return (
<Suspense fallback={<div className="w-72 h-screen bg-white border-r border-gray-100" />}>
<SidebarContent />
</Suspense>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { useState, useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { clsx } from 'clsx';
interface PostSearchProps {
onSearch: (keyword: string) => void;
placeholder?: string;
className?: string;
initialKeyword?: string; // 🆕 URL 동기화를 위한 초기값 prop 추가
}
export default function PostSearch({
onSearch,
placeholder = "검색어를 입력하세요...",
className,
initialKeyword = ''
}: PostSearchProps) {
const [keyword, setKeyword] = useState(initialKeyword);
// 🆕 부모(URL)에서 검색어가 변경되면(예: 메인으로 이동) 입력창도 동기화
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
const handleSearch = () => {
onSearch(keyword);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleClear = () => {
setKeyword('');
onSearch('');
};
return (
<div className={clsx("relative w-full max-w-md", className)}>
<div className="relative">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 bg-gray-50 border border-gray-200 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 transition-all placeholder:text-gray-400"
/>
<Search
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 cursor-pointer hover:text-blue-500 transition-colors"
size={18}
onClick={handleSearch}
/>
{keyword && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-200 transition-colors"
>
<X size={14} />
</button>
)}
</div>
</div>
);
}