'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Command } from 'cmdk'; import { FiSearch, FiHome, FiFileText, FiTag, FiBook } from 'react-icons/fi'; import { cn } from '@/lib/utils'; interface PagefindResult { url: string; meta: { title?: string }; excerpt?: string; } interface QuickAction { id: string; title: string; url: string; icon: React.ReactNode; } interface SearchModalProps { isOpen: boolean; onClose: () => void; recentPosts?: { title: string; url: string }[]; } export function SearchModal({ isOpen, onClose, recentPosts = [] }: SearchModalProps) { const router = useRouter(); const [search, setSearch] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [pagefindReady, setPagefindReady] = useState(false); const pagefindRef = useRef<{ init: () => void; options: (opts: { bundlePath: string }) => Promise; preload: (query: string) => void; debouncedSearch: ( query: string, opts: object, debounceMs: number ) => Promise<{ results: { data: () => Promise }[] } | null>; } | null>(null); // Initialize Pagefind when modal opens useEffect(() => { if (!isOpen) return; const loadPagefind = async () => { try { const pagefindUrl = `${window.location.origin}/_pagefind/pagefind.js`; const pagefind = await import(/* webpackIgnore: true */ pagefindUrl); await pagefind.options({ bundlePath: '/_pagefind/' }); pagefind.init(); pagefindRef.current = pagefind; setPagefindReady(true); } catch (error) { console.error('Failed to load Pagefind:', error); } }; loadPagefind(); return () => { pagefindRef.current = null; setPagefindReady(false); setSearch(''); setResults([]); }; }, [isOpen]); // Debounced search when user types useEffect(() => { const query = search.trim(); if (!query || !pagefindRef.current) { setResults([]); setLoading(false); return; } setLoading(true); pagefindRef.current.preload(query); const timer = setTimeout(async () => { const pagefind = pagefindRef.current; if (!pagefind) return; const searchResult = await pagefind.debouncedSearch(query, {}, 300); if (searchResult === null) return; // Superseded by newer search const dataPromises = searchResult.results.slice(0, 10).map((r) => r.data()); const items = await Promise.all(dataPromises); setResults(items); setLoading(false); }, 300); return () => clearTimeout(timer); }, [search, pagefindReady]); const handleSelect = useCallback( (url: string) => { onClose(); router.push(url); }, [onClose, router] ); const navActions: QuickAction[] = [ { id: 'home', title: '首頁', url: '/', icon: }, { id: 'blog', title: '部落格', url: '/blog', icon: }, { id: 'tags', title: '標籤', url: '/tags', icon: } ]; const recentPostActions: QuickAction[] = recentPosts.map((p) => ({ id: `post-${p.url}`, title: p.title, url: p.url, icon: })); return ( !open && onClose()} label="全站搜尋" shouldFilter={false} className="fixed left-1/2 top-[20%] z-[9999] w-full max-w-2xl -translate-x-1/2 rounded-2xl border border-white/40 bg-white/95 shadow-2xl backdrop-blur-md dark:border-white/10 dark:bg-slate-900/95" >
{loading && ( 搜尋中… )} {!loading && !search.trim() && ( <> {navActions.map((action) => ( handleSelect(action.url)} className={cn( 'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors', 'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900', 'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100' )} > {action.icon} {action.title} ))} {recentPostActions.length > 0 && ( {recentPostActions.map((action) => ( handleSelect(action.url)} className={cn( 'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors', 'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900', 'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100' )} > {action.icon} {action.title} ))} )} )} {!loading && search.trim() && results.length > 0 && ( {results.map((result, i) => ( handleSelect(result.url)} className={cn( 'flex cursor-pointer flex-col gap-0.5 rounded-lg px-3 py-2.5 outline-none transition-colors', 'data-[selected=true]:bg-slate-100 dark:data-[selected=true]:bg-slate-800' )} > {result.meta?.title ?? result.url} {result.excerpt && ( )} ))} )} 找不到結果
ESC 關閉 ⌘K 開啟
); } export function SearchButton({ onClick }: { onClick: () => void }) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); onClick(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [onClick]); return ( ); }