- Replace Pagefind UI with cmdk + Pagefind low-level API - Quick actions when empty: nav (home, blog, tags) + recent posts - Debounced full-text search with keyboard navigation - Pass recent posts from layout to SearchModal - Extract cn utility to lib/utils.ts - Remove Pagefind UI styles, add Radix overlay styling - Align blog search bar styling with launcher Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
'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<PagefindResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [pagefindReady, setPagefindReady] = useState(false);
|
|
const pagefindRef = useRef<{
|
|
init: () => void;
|
|
options: (opts: { bundlePath: string }) => Promise<void>;
|
|
preload: (query: string) => void;
|
|
debouncedSearch: (
|
|
query: string,
|
|
opts: object,
|
|
debounceMs: number
|
|
) => Promise<{ results: { data: () => Promise<PagefindResult> }[] } | 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: <FiHome className="size-4" /> },
|
|
{
|
|
id: 'blog',
|
|
title: '部落格',
|
|
url: '/blog',
|
|
icon: <FiFileText className="size-4" />
|
|
},
|
|
{
|
|
id: 'tags',
|
|
title: '標籤',
|
|
url: '/tags',
|
|
icon: <FiTag className="size-4" />
|
|
}
|
|
];
|
|
|
|
const recentPostActions: QuickAction[] = recentPosts.map((p) => ({
|
|
id: `post-${p.url}`,
|
|
title: p.title,
|
|
url: p.url,
|
|
icon: <FiBook className="size-4" />
|
|
}));
|
|
|
|
return (
|
|
<Command.Dialog
|
|
open={isOpen}
|
|
onOpenChange={(open) => !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"
|
|
>
|
|
<div className="flex items-center border-b border-slate-200 px-4 dark:border-slate-700">
|
|
<FiSearch className="size-5 shrink-0 text-slate-400" />
|
|
<Command.Input
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
placeholder="搜尋文章或快速導航…"
|
|
className="flex h-14 w-full bg-transparent px-3 text-base text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
|
|
/>
|
|
</div>
|
|
|
|
<Command.List className="max-h-[min(60vh,400px)] overflow-y-auto p-2">
|
|
{loading && (
|
|
<Command.Loading className="flex items-center justify-center py-8 text-sm text-slate-500 dark:text-slate-400">
|
|
搜尋中…
|
|
</Command.Loading>
|
|
)}
|
|
|
|
{!loading && !search.trim() && (
|
|
<>
|
|
<Command.Group heading="導航" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group-heading]]:dark:text-slate-400">
|
|
{navActions.map((action) => (
|
|
<Command.Item
|
|
key={action.id}
|
|
value={`${action.title} ${action.url}`}
|
|
onSelect={() => 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'
|
|
)}
|
|
>
|
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
|
{action.icon}
|
|
</span>
|
|
<span className="truncate">{action.title}</span>
|
|
</Command.Item>
|
|
))}
|
|
</Command.Group>
|
|
{recentPostActions.length > 0 && (
|
|
<Command.Group heading="最近文章" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group-heading]]:dark:text-slate-400">
|
|
{recentPostActions.map((action) => (
|
|
<Command.Item
|
|
key={action.id}
|
|
value={`${action.title} ${action.url}`}
|
|
onSelect={() => 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'
|
|
)}
|
|
>
|
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
|
{action.icon}
|
|
</span>
|
|
<span className="truncate">{action.title}</span>
|
|
</Command.Item>
|
|
))}
|
|
</Command.Group>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!loading && search.trim() && results.length > 0 && (
|
|
<Command.Group heading="搜尋結果" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group-heading]]:dark:text-slate-400">
|
|
{results.map((result, i) => (
|
|
<Command.Item
|
|
key={`${result.url}-${i}`}
|
|
value={`${result.meta?.title ?? ''} ${result.url}`}
|
|
onSelect={() => 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'
|
|
)}
|
|
>
|
|
<span className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
{result.meta?.title ?? result.url}
|
|
</span>
|
|
{result.excerpt && (
|
|
<span
|
|
className="line-clamp-2 text-xs text-slate-500 dark:text-slate-400 [&_mark]:bg-yellow-200 [&_mark]:font-semibold [&_mark]:text-slate-900 dark:[&_mark]:bg-yellow-600 dark:[&_mark]:text-slate-100"
|
|
dangerouslySetInnerHTML={{ __html: result.excerpt }}
|
|
/>
|
|
)}
|
|
</Command.Item>
|
|
))}
|
|
</Command.Group>
|
|
)}
|
|
|
|
<Command.Empty className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
|
找不到結果
|
|
</Command.Empty>
|
|
</Command.List>
|
|
|
|
<div className="border-t border-slate-200 px-4 py-2 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
|
<span>ESC 關閉</span>
|
|
<span className="ml-4">⌘K 開啟</span>
|
|
</div>
|
|
</Command.Dialog>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
onClick={onClick}
|
|
className="motion-link inline-flex h-9 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
|
aria-label="搜尋 (Cmd+K)"
|
|
>
|
|
<FiSearch className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">搜尋</span>
|
|
<kbd className="hidden rounded bg-white px-1.5 py-0.5 text-xs font-semibold text-slate-500 shadow-sm dark:bg-slate-900 dark:text-slate-400 sm:inline-block">
|
|
⌘K
|
|
</kbd>
|
|
</button>
|
|
);
|
|
}
|