diff --git a/components/features/home/search/HomeSearchOverlay.tsx b/components/features/home/search/HomeSearchOverlay.tsx index e71775b..2cb028d 100644 --- a/components/features/home/search/HomeSearchOverlay.tsx +++ b/components/features/home/search/HomeSearchOverlay.tsx @@ -5,12 +5,14 @@ import { createPortal } from "react-dom"; import { Search, X } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fetchHomeTrend, type TrendRange } from "@/lib/mock/home-search-trend"; +import { searchMockResults } from "@/lib/mock/home-search-results"; import { useAuthStore } from "@/store/auth.store"; import { HomeRangeTabs } from "./HomeRangeTabs"; import { HomeTopPostsSection } from "./HomeTopPostsSection"; import { HomeTopPostsSummarySection } from "./HomeTopPostsSummarySection"; import { HomeCollectionSummarySection } from "./HomeCollectionSummarySection"; import { HomeTrendingKeywordsSection } from "./HomeTrendingKeywordsSection"; +import { HomeSearchResultsSection } from "./HomeSearchResultsSection"; const normalizeTag = (value: string) => value.toLowerCase().replace(/\s+/g, "").replace(/[.#]/g, ""); @@ -23,6 +25,8 @@ interface HomeSearchOverlayProps { export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { const [range, setRange] = useState("week"); const [inputValue, setInputValue] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [activeItemId, setActiveItemId] = useState(null); const inputRef = useRef(null); const user = useAuthStore((s) => s.user); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -37,6 +41,8 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { clearTimeout(t); document.body.style.overflow = ""; setInputValue(""); + setDebouncedQuery(""); + setActiveItemId(null); }; }, [isOpen]); @@ -63,9 +69,32 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { const handleKeywordClick = useCallback((keyword: string) => { setInputValue(keyword); + setActiveItemId(null); inputRef.current?.focus(); }, []); + // debounce: 2글자 미만이면 즉시 초기화, 이상이면 300ms 후 반영 + // setActiveItemId(null)도 함께 실행해 검색어 변경 시 열린 아코디언 초기화 + useEffect(() => { + const delay = inputValue.length >= 2 ? 300 : 0; + const timer = setTimeout(() => { + setDebouncedQuery(inputValue.length >= 2 ? inputValue : ""); + setActiveItemId(null); + }, delay); + return () => clearTimeout(timer); + }, [inputValue]); + + const searchResults = useMemo( + () => (debouncedQuery ? searchMockResults(debouncedQuery) : []), + [debouncedQuery], + ); + + const isSearching = debouncedQuery.length > 0; + + const handleToggleItem = useCallback((id: string) => { + setActiveItemId((prev) => (prev === id ? null : id)); + }, []); + const keywordsWithInterest = useMemo(() => { const keywords = data?.trendingKeywords ?? []; const userTags = user?.tags; @@ -118,6 +147,18 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { placeholder="관심 주제나 기술을 검색해 보세요..." className="flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground md:text-lg" /> + {inputValue && ( + + )} @@ -133,34 +174,44 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { {/* 스크롤 영역 — 전체 너비로 확장해 여백 포함 스크롤 가능 */}
-
- - - + ) : ( +
+ - {showCollectionSummary && ( - - )} - -
+ {showCollectionSummary && ( + + )} + + +
+ )}
, diff --git a/components/features/home/search/HomeSearchResultAccordionItem.tsx b/components/features/home/search/HomeSearchResultAccordionItem.tsx new file mode 100644 index 0000000..48e95b7 --- /dev/null +++ b/components/features/home/search/HomeSearchResultAccordionItem.tsx @@ -0,0 +1,78 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import type { SearchResultItem } from "@/types/search"; + +interface HomeSearchResultAccordionItemProps { + item: SearchResultItem; + isOpen: boolean; + onToggle: () => void; +} + +export function HomeSearchResultAccordionItem({ + item, + isOpen, + onToggle, +}: HomeSearchResultAccordionItemProps) { + return ( +
+ {/* 닫힘 상태 — 한 줄 */} + + + {/* 열림 상태 — 인라인 확장 */} + {isOpen && ( +
+ {item.thumbnailUrl && ( +
+ {item.title} +
+ )} +
+

+ {item.summary} +

+
+ {item.tags.map((tag) => ( + + {tag} + + ))} +
+ + 전체 글 보기 → + +
+
+ )} +
+ ); +} diff --git a/components/features/home/search/HomeSearchResultsSection.tsx b/components/features/home/search/HomeSearchResultsSection.tsx new file mode 100644 index 0000000..8473f57 --- /dev/null +++ b/components/features/home/search/HomeSearchResultsSection.tsx @@ -0,0 +1,78 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import type { SearchResultItem } from "@/types/search"; +import { HomeSearchResultAccordionItem } from "./HomeSearchResultAccordionItem"; + +interface HomeSearchResultsSectionProps { + results: SearchResultItem[]; + activeItemId: string | null; + onToggle: (id: string) => void; + isLoading?: boolean; + isError?: boolean; +} + +function SearchResultSkeleton() { + return ( +
+
+ + + + +
+
+ ); +} + +export function HomeSearchResultsSection({ + results, + activeItemId, + onToggle, + isLoading = false, + isError = false, +}: HomeSearchResultsSectionProps) { + if (isLoading) { + return ( +
+ + {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (isError) { + return ( +
+

+ 검색 결과 +

+

+ 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요. +

+
+ ); + } + + return ( +
+

+ 검색 결과 ({results.length}) +

+ {results.length === 0 ? ( +

검색 결과가 없습니다.

+ ) : ( +
+ {results.map((item) => ( + onToggle(item.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/lib/mock/home-search-results.ts b/lib/mock/home-search-results.ts new file mode 100644 index 0000000..03f5fe4 --- /dev/null +++ b/lib/mock/home-search-results.ts @@ -0,0 +1,180 @@ +import type { SearchResultItem } from "@/types/search"; + +const MOCK_SEARCH_RESULTS: SearchResultItem[] = [ + { + id: "sr-001", + title: "React 19 새로운 기능 완전 정복 — use, Server Actions, 그리고 더", + sourceName: "velog", + publishedAt: "2026-04-20", + thumbnailUrl: "https://picsum.photos/seed/react19/400/225", + summary: + "React 19에서 도입된 use 훅, Server Actions, 향상된 에러 처리 등 주요 변경 사항을 코드 예제와 함께 정리했습니다. 기존 코드베이스에서의 마이그레이션 포인트도 함께 안내합니다.", + tags: ["React", "Frontend", "JavaScript"], + url: "/home/sr-001", + }, + { + id: "sr-002", + title: "TypeScript 제네릭 완벽 가이드 — 실무에서 쓰는 패턴 10가지", + sourceName: "toss_tech", + publishedAt: "2026-04-18", + thumbnailUrl: null, + summary: + "TypeScript 제네릭을 실무에서 어떻게 활용하는지 10가지 패턴으로 정리했습니다. 조건부 타입, 매핑 타입, infer 키워드 등을 예제 중심으로 설명합니다.", + tags: ["TypeScript", "Frontend"], + url: "/home/sr-002", + }, + { + id: "sr-003", + title: "Next.js App Router 완전 정복 — 서버 컴포넌트부터 캐싱 전략까지", + sourceName: "naver_d2", + publishedAt: "2026-04-15", + thumbnailUrl: "https://picsum.photos/seed/nextjs-app/400/225", + summary: + "Next.js 13 이후 도입된 App Router의 핵심 개념을 정리합니다. 서버·클라이언트 컴포넌트 경계, fetch 캐싱, revalidate 전략을 실전 예제로 살펴봅니다.", + tags: ["Next.js", "React", "Frontend"], + url: "/home/sr-003", + }, + { + id: "sr-004", + title: "Docker Compose로 로컬 개발 환경 완전 자동화하기", + sourceName: "kakao_tech", + publishedAt: "2026-04-13", + thumbnailUrl: "https://picsum.photos/seed/docker-compose/400/225", + summary: + "Docker Compose를 활용해 DB, 캐시, 백엔드 서버를 포함한 로컬 개발 환경을 한 번에 구성하는 방법을 안내합니다. 볼륨 마운트, 네트워크 설정, healthcheck 활용법도 포함합니다.", + tags: ["Docker", "DevOps", "Backend"], + url: "/home/sr-004", + }, + { + id: "sr-005", + title: "AWS S3 + CloudFront로 정적 사이트 배포 자동화 완전 가이드", + sourceName: "velog", + publishedAt: "2026-04-12", + thumbnailUrl: null, + summary: + "AWS S3와 CloudFront를 이용한 정적 사이트 배포 파이프라인 구성 방법을 설명합니다. GitHub Actions와 연동한 CI/CD 자동화까지 단계별로 설명합니다.", + tags: ["AWS", "DevOps", "CI/CD"], + url: "/home/sr-005", + }, + { + id: "sr-006", + title: "Spring Boot + JPA 성능 최적화 — N+1 문제부터 쿼리 튜닝까지", + sourceName: "우아한형제들", + publishedAt: "2026-04-10", + thumbnailUrl: "https://picsum.photos/seed/spring-jpa/400/225", + summary: + "Spring Boot와 JPA를 사용할 때 자주 마주치는 N+1 문제와 쿼리 성능 이슈를 실제 사례와 함께 분석하고 해결책을 제시합니다. Fetch Join, EntityGraph, 배치 사이즈 설정 등 다양한 최적화 기법을 다룹니다.", + tags: ["Spring", "JPA", "Backend", "Java"], + url: "/home/sr-006", + }, + { + id: "sr-007", + title: "Kubernetes 입문 — Pod부터 Deployment, Service까지 한 번에 이해하기", + sourceName: "naver_d2", + publishedAt: "2026-04-09", + thumbnailUrl: null, + summary: + "Kubernetes의 핵심 개념인 Pod, Deployment, Service, Ingress를 예제 YAML과 함께 설명합니다. 로컬 minikube 환경에서 직접 따라하며 배울 수 있도록 구성했습니다.", + tags: ["Kubernetes", "DevOps", "Docker"], + url: "/home/sr-007", + }, + { + id: "sr-008", + title: "Python asyncio 실전 — 비동기 크롤러 직접 만들어보기", + sourceName: "velog", + publishedAt: "2026-04-08", + thumbnailUrl: "https://picsum.photos/seed/python-async/400/225", + summary: + "Python asyncio를 활용해 비동기 웹 크롤러를 직접 구현해보는 튜토리얼입니다. aiohttp, asyncio.gather, 세마포어를 활용한 동시성 제어까지 실전 코드로 설명합니다.", + tags: ["Python", "Async", "Backend"], + url: "/home/sr-008", + }, + { + id: "sr-009", + title: "Redis 캐싱 전략 총정리 — Cache-Aside, Write-Through, TTL 설계", + sourceName: "kakao_tech", + publishedAt: "2026-04-07", + thumbnailUrl: null, + summary: + "실무에서 Redis를 활용한 캐싱 전략을 패턴별로 정리합니다. Cache-Aside, Write-Through, Read-Through의 장단점과 TTL 설계 원칙, 캐시 스탬피드 방지 기법을 다룹니다.", + tags: ["Redis", "Backend", "Architecture"], + url: "/home/sr-009", + }, + { + id: "sr-010", + title: "GraphQL vs REST — 실무 프로젝트에서 선택 기준과 트레이드오프", + sourceName: "toss_tech", + publishedAt: "2026-04-06", + thumbnailUrl: "https://picsum.photos/seed/graphql-rest/400/225", + summary: + "GraphQL과 REST API의 실무 적용 사례를 바탕으로 각각의 장단점과 선택 기준을 정리합니다. 오버페칭, 언더페칭, 타입 안전성, 클라이언트 복잡도 등을 비교합니다.", + tags: ["GraphQL", "REST", "API", "Backend"], + url: "/home/sr-010", + }, + { + id: "sr-011", + title: "Tailwind CSS v4 마이그레이션 실전 — 변경점과 주의사항 정리", + sourceName: "velog", + publishedAt: "2026-04-05", + thumbnailUrl: null, + summary: + "Tailwind CSS v3에서 v4로 마이그레이션할 때 반드시 확인해야 할 변경 사항을 정리했습니다. @theme 블록, CSS 변수 방식, 플러그인 API 변화 등을 실제 마이그레이션 경험을 바탕으로 설명합니다.", + tags: ["Tailwind", "CSS", "Frontend"], + url: "/home/sr-011", + }, + { + id: "sr-012", + title: "LLM 기반 RAG 시스템 구축 — LangChain + Pinecone 실전 튜토리얼", + sourceName: "oliveyoung_tech", + publishedAt: "2026-04-04", + thumbnailUrl: "https://picsum.photos/seed/llm-rag/400/225", + summary: + "LangChain과 Pinecone 벡터 DB를 활용해 사내 문서 기반 RAG 시스템을 구축하는 과정을 단계별로 설명합니다. 임베딩, 청크 전략, 리트리버 설정까지 실전 코드로 구현합니다.", + tags: ["LLM", "AI", "Python", "RAG"], + url: "/home/sr-012", + }, + { + id: "sr-013", + title: "Kotlin 코루틴 완전 정복 — 실무에서 자주 쓰는 패턴 모음", + sourceName: "우아한형제들", + publishedAt: "2026-04-03", + thumbnailUrl: null, + summary: + "Kotlin 코루틴의 핵심 개념인 suspend, CoroutineScope, Dispatcher를 정리하고 실무에서 자주 사용하는 패턴을 코드 예제와 함께 소개합니다. 에러 처리, 취소, 타임아웃 처리도 포함합니다.", + tags: ["Kotlin", "Coroutine", "Backend", "Android"], + url: "/home/sr-013", + }, + { + id: "sr-014", + title: "PostgreSQL 인덱스 전략 — 실행 계획 분석으로 쿼리 10배 빠르게", + sourceName: "kakao_tech", + publishedAt: "2026-04-02", + thumbnailUrl: "https://picsum.photos/seed/postgres-index/400/225", + summary: + "PostgreSQL EXPLAIN ANALYZE를 활용해 느린 쿼리의 원인을 찾고 인덱스로 개선하는 방법을 실제 사례 중심으로 설명합니다. 복합 인덱스, 부분 인덱스, 커버링 인덱스의 활용법도 다룹니다.", + tags: ["PostgreSQL", "DB", "Backend"], + url: "/home/sr-014", + }, + { + id: "sr-015", + title: "Zustand로 React 전역 상태 관리하기 — Context API 대비 장단점", + sourceName: "velog", + publishedAt: "2026-04-01", + thumbnailUrl: null, + summary: + "Zustand를 사용해 React 애플리케이션의 전역 상태를 관리하는 방법을 소개합니다. Context API, Redux와의 비교를 통해 Zustand의 장단점을 파악하고, 실무 패턴을 코드와 함께 설명합니다.", + tags: ["React", "Zustand", "Frontend", "State"], + url: "/home/sr-015", + }, +]; + +export function searchMockResults(query: string): SearchResultItem[] { + const q = query.toLowerCase().trim(); + + return MOCK_SEARCH_RESULTS.filter((item) => { + const target = + `${item.title} ${item.summary} ${item.tags.join(" ")}`.toLowerCase(); + + return target.includes(q); + }).sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); +} diff --git a/types/search.ts b/types/search.ts index 2c795c9..fabb3e9 100644 --- a/types/search.ts +++ b/types/search.ts @@ -24,6 +24,17 @@ export interface TrendKeywordItem { isMyInterest?: boolean; } +export interface SearchResultItem { + id: string; + title: string; + sourceName: string; + publishedAt: string; + thumbnailUrl: string | null; + summary: string; + tags: string[]; + url: string; +} + export interface HomeTrendData { dateLabel: string; topPosts: TrendTopPost[];