diff --git a/CLAUDE.md b/CLAUDE.md index 779cb77..c853fb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,3 +125,5 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - 이미지 캐싱 TTL: 31일 (2678400초) - 원격 이미지 패턴: Twitter, Amazon 등 허용 - MDX 동적 컴파일로 SSG 최적화 + +- commit message에 claude code가 만들었다는 내용은 추가하지 않는다. \ No newline at end of file diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts new file mode 100644 index 0000000..9fa0cb8 --- /dev/null +++ b/app/api/posts/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getPosts } from '../../get-posts'; + +export async function GET() { + try { + const posts = await getPosts(); + return NextResponse.json(posts); + } catch (error) { + console.error('Error fetching posts:', error); + return NextResponse.json( + { error: 'Failed to fetch posts' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/components/SearchModal.tsx b/app/components/SearchModal.tsx new file mode 100644 index 0000000..0a2524a --- /dev/null +++ b/app/components/SearchModal.tsx @@ -0,0 +1,306 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { Command } from "cmdk"; +import DOMPurify from "isomorphic-dompurify"; +import { useSearch } from "../contexts/SearchContext"; +import { getSearchHistory, highlightText } from "../lib/search"; + +export function SearchModal() { + const { + isOpen, + closeSearch, + searchQuery, + setSearchQuery, + searchResults, + isSearching, + selectedIndex, + setSelectedIndex, + navigateToPost, + } = useSearch(); + + // 선택 가능한 항목들 생성 + const selectableItems = useMemo( + () => [ + ...(searchQuery + ? searchResults.map(result => ({ + type: "result" as const, + data: result, + })) + : []), + ], + [searchQuery, searchResults] + ); + + // 선택 핸들러 + const handleSelect = useCallback( + (item: (typeof selectableItems)[0]) => { + navigateToPost(item.data.item.slug); + }, + [navigateToPost] + ); + + // 키보드 네비게이션 핸들러 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex(prev => + Math.min(prev + 1, selectableItems.length - 1) + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (selectableItems[selectedIndex]) { + handleSelect(selectableItems[selectedIndex]); + } + break; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, selectedIndex, selectableItems, setSelectedIndex, handleSelect]); + + // selectedIndex를 선택 가능한 항목 수에 맞게 조정 + useEffect(() => { + if (selectedIndex >= selectableItems.length && selectableItems.length > 0) { + setSelectedIndex(0); + } + }, [selectableItems.length, selectedIndex, setSelectedIndex]); + + if (!isOpen) return null; + + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 모달 */} +
+ + {/* 검색 입력창 */} +
+ + + ESC 닫기 +
+ + {/* 검색 결과 */} + + {/* 검색중 표시 */} + {isSearching && ( +
+ 검색중... +
+ )} + + {/* 검색 결과 */} + {searchQuery && !isSearching && ( + <> + {searchResults.length > 0 ? ( + <> +
+ 검색 결과 ({searchResults.length}개) +
+ {searchResults.map((result, index) => { + const { item: post, matches } = result; + const year = new Date(post.date).getFullYear(); + const isSelected = selectedIndex === index; + + return ( + + handleSelect({ type: "result", data: result }) + } + className={`block px-4 py-3 cursor-pointer rounded-md transition-colors ${ + isSelected + ? "bg-blue-100 dark:bg-blue-900/50 border-l-2 border-blue-500" + : "hover:bg-gray-100 dark:hover:bg-gray-800" + }`} + data-selected={isSelected} + > +
+
+
+ +

+ + {year} + +

+ {post.description && ( +

+ )} + {post.content && ( +

+ )} + {post.tags && post.tags.length > 0 && ( +

+ {post.tags.map(tag => ( + + #{tag} + + ))} +
+ )} +
+
+
+ ); + })} + + ) : ( +
+ {`${searchQuery}에 대한 검색 결과가 없습니다`} +
+ )} + + )} + + {/* 검색어 없을 때 */} + {!searchQuery && ( +
+ 검색어를 입력하세요 +
+ )} +
+ + {/* 하단 도움말 */} +
+
+ ↑↓ 이동 + ↵ 선택 +
+ ⌘K 단축키 +
+
+
+ + ); +} + +// 아이콘 컴포넌트들 +function SearchIcon({ className = "" }) { + return ( + + + + ); +} + +function PostIcon({ className = "" }) { + return ( + + + + ); +} diff --git a/app/components/SearchProvider.tsx b/app/components/SearchProvider.tsx new file mode 100644 index 0000000..239fc7b --- /dev/null +++ b/app/components/SearchProvider.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SearchProvider as SearchContextProvider } from "../contexts/SearchContext"; +import { SearchModal } from "./SearchModal"; +import type { Post } from "../get-posts"; +import { useSearch } from "../contexts/SearchContext"; + +// 검색 초기화를 위한 내부 컴포넌트 +function SearchInitializer() { + const { initializeSearch } = useSearch(); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadPosts() { + try { + const response = await fetch("/api/posts"); + if (!response.ok) { + throw new Error(`Failed to fetch posts: ${response.status}`); + } + const posts: Post[] = await response.json(); + initializeSearch(posts); + setError(null); + } catch (error) { + const errorMessage = error instanceof Error + ? error.message + : "검색 데이터를 불러오는데 실패했습니다."; + setError(errorMessage); + console.error("Failed to load posts for search:", error); + } + } + + loadPosts(); + }, [initializeSearch]); + + // 에러가 있을 경우 에러 메시지 표시 (개발 환경에서만) + if (error && process.env.NODE_ENV === 'development') { + return ( +
+ 검색 기능 초기화 실패: {error} +
+ ); + } + + return null; +} + +// SearchProvider 래퍼 컴포넌트 +export function SearchProviderWrapper({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/app/contexts/SearchContext.tsx b/app/contexts/SearchContext.tsx new file mode 100644 index 0000000..aef3618 --- /dev/null +++ b/app/contexts/SearchContext.tsx @@ -0,0 +1,150 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from "react"; +import { useRouter } from "next/navigation"; +import Fuse from "fuse.js"; +import { Post } from "../get-posts"; +import { + createSearchIndex, + SearchResult, + addToSearchHistory, +} from "../lib/search"; + +interface SearchContextType { + isOpen: boolean; + openSearch: () => void; + closeSearch: () => void; + toggleSearch: () => void; + searchQuery: string; + setSearchQuery: React.Dispatch>; + searchResults: SearchResult[]; + isSearching: boolean; + searchIndex: Fuse | null; + initializeSearch: (posts: Post[]) => void; + selectedIndex: number; + setSelectedIndex: React.Dispatch>; + navigateToPost: (slug: string) => void; +} + +const SearchContext = createContext(undefined); + +export function useSearch() { + const context = useContext(SearchContext); + if (!context) { + throw new Error("useSearch must be used within a SearchProvider"); + } + return context; +} + +interface SearchProviderProps { + children: React.ReactNode; +} + +export function SearchProvider({ children }: SearchProviderProps) { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [searchIndex, setSearchIndex] = useState | null>(null); + const [selectedIndex, setSelectedIndex] = useState(0); + + const openSearch = useCallback(() => { + setIsOpen(true); + setSelectedIndex(0); + }, []); + + const closeSearch = useCallback(() => { + setIsOpen(false); + setSearchQuery(""); + setSearchResults([]); + setSelectedIndex(0); + }, []); + + const toggleSearch = useCallback(() => setIsOpen(prev => !prev), []); + + // 포스트로 이동하는 함수 + const navigateToPost = useCallback( + (slug: string) => { + closeSearch(); + router.push(`/${slug}`); + }, + [router, closeSearch] + ); + + // 검색 인덱스 초기화 + const initializeSearch = useCallback((posts: Post[]) => { + const index = createSearchIndex(posts); + setSearchIndex(index); + }, []); + + // 검색 실행 (debounced) + useEffect(() => { + if (!searchIndex || !searchQuery.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + const timeoutId = setTimeout(() => { + const results = searchIndex.search(searchQuery, { limit: 10 }); + setSearchResults(results); + setIsSearching(false); + + // 검색 기록에 추가 + if (searchQuery.trim().length > 2) { + addToSearchHistory(searchQuery.trim()); + } + }, 300); // 300ms debounce + + return () => { + clearTimeout(timeoutId); + setIsSearching(false); + }; + }, [searchQuery, searchIndex]); + + // 단축키 설정 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+K (Mac) or Ctrl+K (Windows/Linux) + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + toggleSearch(); + } + + // ESC 키로 닫기 + if (e.key === "Escape" && isOpen) { + closeSearch(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, toggleSearch, closeSearch]); + + const value: SearchContextType = { + isOpen, + openSearch, + closeSearch, + toggleSearch, + searchQuery, + setSearchQuery, + searchResults, + isSearching, + searchIndex, + initializeSearch, + selectedIndex, + setSelectedIndex, + navigateToPost, + }; + + return ( + {children} + ); +} diff --git a/app/header.tsx b/app/header.tsx index 96b5df5..c791a75 100644 --- a/app/header.tsx +++ b/app/header.tsx @@ -1,8 +1,13 @@ +'use client'; + import { ThemeToggle } from "./theme-toggle"; import { Logo } from "./logo"; import Link from "next/link"; +import { useSearch } from "./contexts/SearchContext"; export function Header() { + const { openSearch } = useSearch(); + return (
@@ -10,6 +15,16 @@ export function Header() {