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}
+ >
+
+
+
+ {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() {