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;
|
keyword?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
sort?: string; // 🆕 정렬 옵션 추가 (예: 'viewCount,desc')
|
sort?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', {
|
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', {
|
||||||
params: {
|
params: {
|
||||||
@@ -20,12 +20,13 @@ export const getPosts = async (params?: {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. 카테고리별 게시글 조회
|
// 2. 카테고리별 게시글 조회 (🛠️ keyword 파라미터 추가)
|
||||||
export const getPostsByCategory = async (categoryName: string, page = 0, size = 10) => {
|
export const getPostsByCategory = async (categoryName: string, page = 0, size = 10, keyword?: string) => {
|
||||||
return getPosts({
|
return getPosts({
|
||||||
page,
|
page,
|
||||||
size,
|
size,
|
||||||
category: categoryName
|
category: categoryName,
|
||||||
|
keyword // 🆕 검색어 전달
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { use, useState, useEffect } from 'react';
|
import { use, useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPostsByCategory } from '@/api/posts';
|
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용
|
||||||
import PostCard from '@/components/post/PostCard';
|
import PostCard from '@/components/post/PostCard';
|
||||||
import PostListItem from '@/components/post/PostListItem';
|
import PostListItem from '@/components/post/PostListItem';
|
||||||
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List } from 'lucide-react';
|
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
|
||||||
import { notFound } from 'next/navigation';
|
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
|
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 categoryName = decodeURIComponent(id);
|
||||||
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
||||||
|
|
||||||
// 📢 공지 카테고리인지 판별
|
|
||||||
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
|
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
|
||||||
|
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태
|
||||||
// 공지사항이면 기본값을 'list'로 설정
|
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 📢 공지 카테고리는 강제로 리스트 뷰 적용
|
|
||||||
if (isNoticeCategory) {
|
if (isNoticeCategory) {
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그 외에는 로컬 스토리지 설정 따름
|
|
||||||
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
||||||
if (savedMode) setViewMode(savedMode);
|
if (savedMode) setViewMode(savedMode);
|
||||||
}, [isNoticeCategory]);
|
}, [isNoticeCategory]);
|
||||||
|
|
||||||
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
// 공지 카테고리가 아닐 때만 사용자 설정을 저장 (공지는 뷰 강제이므로 저장 안 함)
|
|
||||||
if (!isNoticeCategory) {
|
if (!isNoticeCategory) {
|
||||||
localStorage.setItem('postViewMode', mode);
|
localStorage.setItem('postViewMode', mode);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 검색어가 변경되면 페이지를 0으로 초기화
|
||||||
|
const handleSearch = (newKeyword: string) => {
|
||||||
|
setKeyword(newKeyword);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
|
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
|
||||||
queryKey: ['posts', 'category', apiCategoryName, page],
|
queryKey: ['posts', 'category', apiCategoryName, page, keyword], // 🔑 쿼리 키에 keyword 추가
|
||||||
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE),
|
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword), // 🆕 검색어 전달
|
||||||
placeholderData: (previousData) => previousData,
|
// 🛠️ 수정됨: 검색어가 바뀌면 이전 데이터를 보여주지 않고 로딩 상태로 전환
|
||||||
|
// (페이지 이동 시에는 부드럽게 보여주기 위해 유지)
|
||||||
|
placeholderData: (previousData, previousQuery) => {
|
||||||
|
const prevKeyword = previousQuery?.queryKey[4];
|
||||||
|
if (prevKeyword !== keyword) return undefined;
|
||||||
|
return previousData;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || (postsData === undefined && !error)) {
|
if (isLoading || (postsData === undefined && !error)) {
|
||||||
@@ -68,23 +74,41 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
|||||||
|
|
||||||
const posts = postsData?.content || [];
|
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 handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
|
||||||
const handleNextPage = () => {
|
const handleNextPage = () => {
|
||||||
if (!postsData?.last) {
|
if (!isLast) {
|
||||||
setPage((old) => old + 1);
|
setPage((old) => old + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
{/* 헤더 영역 수정 */}
|
{/* 헤더 영역 */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<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">
|
<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>
|
{apiCategoryName} <span className="text-gray-400 text-lg font-normal">글 목록</span>
|
||||||
</h1>
|
</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
|
<button
|
||||||
onClick={() => handleViewModeChange('grid')}
|
onClick={() => handleViewModeChange('grid')}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -107,10 +131,23 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{posts.length === 0 ? (
|
||||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -139,12 +176,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-gray-600">
|
<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>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleNextPage}
|
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"
|
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||||
aria-label="다음 페이지"
|
aria-label="다음 페이지"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, Suspense } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { getPosts } from '@/api/posts';
|
import { getPosts } from '@/api/posts';
|
||||||
import PostCard from '@/components/post/PostCard';
|
import PostCard from '@/components/post/PostCard';
|
||||||
import PostListItem from '@/components/post/PostListItem';
|
import PostListItem from '@/components/post/PostListItem';
|
||||||
|
// PostSearch 컴포넌트 제거 (사이드바로 이동)
|
||||||
import { Post } from '@/types';
|
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';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function Home() {
|
function HomeContent() {
|
||||||
// 1. 공지사항 조회 (최대 3개, 최신순)
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// URL 쿼리 스트링에서 검색어 가져오기
|
||||||
|
const keyword = searchParams.get('keyword') || '';
|
||||||
|
|
||||||
|
// 1. 공지사항 조회
|
||||||
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
|
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
|
||||||
queryKey: ['posts', 'notices'],
|
queryKey: ['posts', 'notices'],
|
||||||
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
|
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 최신 게시글 조회 (넉넉히 10개 가져와서 공지 제외하고 3개만 사용)
|
// 2. 최신 게시글 조회
|
||||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||||
queryKey: ['posts', 'latest'],
|
queryKey: ['posts', 'latest'],
|
||||||
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
|
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 인기 게시글 조회 (조회수순, 넉넉히 10개 가져와서 공지 제외하고 3개만 사용)
|
// 3. 인기 게시글 조회
|
||||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||||
queryKey: ['posts', 'popular'],
|
queryKey: ['posts', 'popular'],
|
||||||
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
|
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) {
|
if (isNoticesLoading || isLatestLoading || isPopularLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-screen">
|
<div className="flex justify-center items-center h-screen">
|
||||||
@@ -36,7 +51,6 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 필터링 헬퍼 함수
|
|
||||||
const filterPosts = (posts: Post[] | undefined, limit: number) => {
|
const filterPosts = (posts: Post[] | undefined, limit: number) => {
|
||||||
if (!posts) return [];
|
if (!posts) return [];
|
||||||
return posts
|
return posts
|
||||||
@@ -45,16 +59,46 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const notices = noticesData?.content || [];
|
const notices = noticesData?.content || [];
|
||||||
// 5개 -> 3개로 수정
|
|
||||||
const latestPosts = filterPosts(latestData?.content, 3);
|
const latestPosts = filterPosts(latestData?.content, 3);
|
||||||
const popularPosts = filterPosts(popularData?.content, 3);
|
const popularPosts = filterPosts(popularData?.content, 3);
|
||||||
|
|
||||||
return (
|
const searchResults = searchData?.content || [];
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8 space-y-16">
|
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 && (
|
{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">
|
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
|
||||||
<Megaphone className="text-red-500" size={20} />
|
<Megaphone className="text-red-500" size={20} />
|
||||||
<h2 className="text-xl font-bold text-gray-800">공지사항</h2>
|
<h2 className="text-xl font-bold text-gray-800">공지사항</h2>
|
||||||
@@ -67,8 +111,8 @@ export default function Home() {
|
|||||||
</section>
|
</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 justify-between mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="text-blue-500" size={20} />
|
<Clock className="text-blue-500" size={20} />
|
||||||
@@ -81,7 +125,6 @@ export default function Home() {
|
|||||||
|
|
||||||
{latestPosts.length > 0 ? (
|
{latestPosts.length > 0 ? (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{/* 첫 번째 글은 강조를 위해 크게 보여줄 수도 있지만, 여기선 균일하게 배치 */}
|
|
||||||
{latestPosts.map((post) => (
|
{latestPosts.map((post) => (
|
||||||
<div key={post.id} className="h-full">
|
<div key={post.id} className="h-full">
|
||||||
<PostCard post={post} />
|
<PostCard post={post} />
|
||||||
@@ -95,8 +138,8 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<Flame className="text-orange-500" size={20} />
|
<Flame className="text-orange-500" size={20} />
|
||||||
<h2 className="text-xl font-bold text-gray-800">인기 포스트</h2>
|
<h2 className="text-xl font-bold text-gray-800">인기 포스트</h2>
|
||||||
@@ -117,7 +160,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 하단 여백 및 아카이브 링크 배너 */}
|
{/* 하단 아카이브 링크 */}
|
||||||
<div className="pt-8 pb-4 text-center">
|
<div className="pt-8 pb-4 text-center">
|
||||||
<Link
|
<Link
|
||||||
href="/archive"
|
href="/archive"
|
||||||
@@ -126,7 +169,16 @@ export default function Home() {
|
|||||||
모든 글 보러가기 <ChevronRight size={16} />
|
모든 글 보러가기 <ChevronRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</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';
|
'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 Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
// 🎨 이미지 최적화를 위해 next/image 사용
|
// 🎨 이미지 최적화를 위해 next/image 사용
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -18,6 +18,7 @@ import { clsx } from 'clsx';
|
|||||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가
|
||||||
|
|
||||||
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
@@ -43,7 +44,6 @@ interface CategoryItemProps {
|
|||||||
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
||||||
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||||
|
|
||||||
// 🆕 1. 하위 항목 중에 현재 활성화된 페이지가 있는지 확인 (재귀 체크)
|
|
||||||
const hasActiveChild = useMemo(() => {
|
const hasActiveChild = useMemo(() => {
|
||||||
const check = (cats: Category[] | undefined): boolean => {
|
const check = (cats: Category[] | undefined): boolean => {
|
||||||
if (!cats) return false;
|
if (!cats) return false;
|
||||||
@@ -54,10 +54,8 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
|||||||
return check(category.children);
|
return check(category.children);
|
||||||
}, [category.children, pathname]);
|
}, [category.children, pathname]);
|
||||||
|
|
||||||
// 🆕 2. 펼침 상태 관리 (자신이 활성화됐거나 하위가 활성화됐으면 기본값 true)
|
|
||||||
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
||||||
|
|
||||||
// 🆕 3. 페이지 이동으로 활성화 상태가 바뀌면 자동으로 펼치기
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive || hasActiveChild) {
|
if (isActive || hasActiveChild) {
|
||||||
setIsExpanded(true);
|
setIsExpanded(true);
|
||||||
@@ -157,7 +155,7 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
|||||||
{!isEditMode && category.children && category.children.length > 0 && (
|
{!isEditMode && category.children && category.children.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault(); // Link 이동 막기
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsExpanded(!isExpanded);
|
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 [isOpen, setIsOpen] = useState(true);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { role, _hasHydrated } = useAuthStore();
|
const { role, _hasHydrated } = useAuthStore();
|
||||||
const queryClient = useQueryClient();
|
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 [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
|
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
|
||||||
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
|
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
|
||||||
@@ -396,8 +407,18 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
|
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
|
||||||
|
|
||||||
{/* 🆕 2. 아카이브 링크 (페이지 이동) - 위로 이동 */}
|
{/* 🆕 전체 검색바 (Archives 위) - 수정됨: 그림자 제거 */}
|
||||||
<div className="mb-4 mt-2">
|
<div className="px-1 mb-6 mt-2">
|
||||||
|
<PostSearch
|
||||||
|
onSearch={handleSearch}
|
||||||
|
placeholder="검색..."
|
||||||
|
initialKeyword={keyword}
|
||||||
|
// className="shadow-sm" // 🎨 제거됨: 배경 박스처럼 보이는 문제 해결
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 아카이브 링크 */}
|
||||||
|
<div className="mb-4">
|
||||||
<Link
|
<Link
|
||||||
href="/archive"
|
href="/archive"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -412,7 +433,7 @@ export default function Sidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 추가 */}
|
{/* 구분선 */}
|
||||||
<div className="border-t border-gray-100 mb-4" />
|
<div className="border-t border-gray-100 mb-4" />
|
||||||
|
|
||||||
{/* 1. 카테고리 섹션 */}
|
{/* 1. 카테고리 섹션 */}
|
||||||
@@ -484,7 +505,6 @@ export default function Sidebar() {
|
|||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
<div className="flex flex-col items-center mb-6">
|
<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()}>
|
<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 />
|
<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 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>
|
</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