feat: 검색 기능 구현
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m52s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m52s
This commit is contained in:
@@ -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 // 🆕 검색어 전달
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,23 +74,41 @@ 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-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">
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg shrink-0">
|
||||
<button
|
||||
onClick={() => handleViewModeChange('grid')}
|
||||
className={clsx(
|
||||
@@ -107,10 +131,23 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</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="다음 페이지"
|
||||
>
|
||||
|
||||
@@ -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,16 +59,46 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const notices = noticesData?.content || [];
|
||||
// 5개 -> 3개로 수정
|
||||
const latestPosts = filterPosts(latestData?.content, 3);
|
||||
const popularPosts = filterPosts(popularData?.content, 3);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-8 space-y-16">
|
||||
const searchResults = searchData?.content || [];
|
||||
const searchMeta = (searchData as any)?.page || searchData;
|
||||
const searchTotalElements = searchMeta?.totalElements ?? 0;
|
||||
|
||||
{/* 📢 1. 공지사항 섹션 (리스트 형태) */}
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12">
|
||||
|
||||
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */}
|
||||
{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 className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<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>
|
||||
@@ -67,8 +111,8 @@ export default function Home() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 🕒 2. 최신 게시글 섹션 (카드 형태) */}
|
||||
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-100">
|
||||
{/* 최신 포스트 섹션 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-blue-500" size={20} />
|
||||
@@ -81,7 +125,6 @@ export default function Home() {
|
||||
|
||||
{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} />
|
||||
@@ -95,8 +138,8 @@ export default function Home() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 🔥 3. 인기 게시글 섹션 (카드 형태) */}
|
||||
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-200">
|
||||
{/* 인기 포스트 섹션 */}
|
||||
<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>
|
||||
@@ -117,7 +160,7 @@ export default function Home() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 하단 여백 및 아카이브 링크 배너 */}
|
||||
{/* 하단 아카이브 링크 */}
|
||||
<div className="pt-8 pb-4 text-center">
|
||||
<Link
|
||||
href="/archive"
|
||||
@@ -126,7 +169,16 @@ export default function Home() {
|
||||
모든 글 보러가기 <ChevronRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
69
src/components/post/PostSearch.tsx
Normal file
69
src/components/post/PostSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user