|
|
|
|
@@ -1,78 +1,69 @@
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
|
import { createPortal } from 'react-dom';
|
|
|
|
|
import { FiSearch, FiX } from 'react-icons/fi';
|
|
|
|
|
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 }: SearchModalProps) {
|
|
|
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
|
|
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const pagefindUIRef = useRef<any>(null);
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
let link: HTMLLinkElement | null = null;
|
|
|
|
|
let script: HTMLScriptElement | null = null;
|
|
|
|
|
|
|
|
|
|
// Load Pagefind UI dynamically when modal opens
|
|
|
|
|
const loadPagefind = async () => {
|
|
|
|
|
if (pagefindUIRef.current) {
|
|
|
|
|
// Already loaded
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Load Pagefind UI CSS
|
|
|
|
|
link = document.createElement('link');
|
|
|
|
|
link.rel = 'stylesheet';
|
|
|
|
|
link.href = '/_pagefind/pagefind-ui.css';
|
|
|
|
|
document.head.appendChild(link);
|
|
|
|
|
|
|
|
|
|
// Load Pagefind UI JS
|
|
|
|
|
script = document.createElement('script');
|
|
|
|
|
script.src = '/_pagefind/pagefind-ui.js';
|
|
|
|
|
script.onload = () => {
|
|
|
|
|
if (searchContainerRef.current && (window as any).PagefindUI) {
|
|
|
|
|
pagefindUIRef.current = new (window as any).PagefindUI({
|
|
|
|
|
element: searchContainerRef.current,
|
|
|
|
|
bundlePath: '/_pagefind/',
|
|
|
|
|
showSubResults: true,
|
|
|
|
|
showImages: false,
|
|
|
|
|
excerptLength: 15,
|
|
|
|
|
resetStyles: false,
|
|
|
|
|
autofocus: true,
|
|
|
|
|
translations: {
|
|
|
|
|
placeholder: '搜尋文章...',
|
|
|
|
|
clear_search: '清除',
|
|
|
|
|
load_more: '載入更多結果',
|
|
|
|
|
search_label: '搜尋此網站',
|
|
|
|
|
filters_label: '篩選',
|
|
|
|
|
zero_results: '找不到 [SEARCH_TERM] 的結果',
|
|
|
|
|
many_results: '找到 [COUNT] 個 [SEARCH_TERM] 的結果',
|
|
|
|
|
one_result: '找到 [COUNT] 個 [SEARCH_TERM] 的結果',
|
|
|
|
|
alt_search: '找不到 [SEARCH_TERM] 的結果。改為顯示 [DIFFERENT_TERM] 的結果',
|
|
|
|
|
search_suggestion: '找不到 [SEARCH_TERM] 的結果。請嘗試以下搜尋:',
|
|
|
|
|
searching: '搜尋中...'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setIsLoaded(true);
|
|
|
|
|
|
|
|
|
|
// Auto-focus the search input after a short delay
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const input = searchContainerRef.current?.querySelector('input[type="search"]') as HTMLInputElement;
|
|
|
|
|
if (input) {
|
|
|
|
|
input.focus();
|
|
|
|
|
}
|
|
|
|
|
}, 100);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.head.appendChild(script);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
@@ -80,102 +71,179 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|
|
|
|
|
|
|
|
|
loadPagefind();
|
|
|
|
|
|
|
|
|
|
// Cleanup function to prevent duplicate initializations
|
|
|
|
|
return () => {
|
|
|
|
|
if (link && link.parentNode) {
|
|
|
|
|
link.parentNode.removeChild(link);
|
|
|
|
|
}
|
|
|
|
|
if (script && script.parentNode) {
|
|
|
|
|
script.parentNode.removeChild(script);
|
|
|
|
|
}
|
|
|
|
|
if (pagefindUIRef.current && pagefindUIRef.current.destroy) {
|
|
|
|
|
pagefindUIRef.current.destroy();
|
|
|
|
|
pagefindUIRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
pagefindRef.current = null;
|
|
|
|
|
setPagefindReady(false);
|
|
|
|
|
setSearch('');
|
|
|
|
|
setResults([]);
|
|
|
|
|
};
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
|
|
|
|
// Debounced search when user types
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Escape' && isOpen) {
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
|
return () => document.removeEventListener('keydown', handleEscape);
|
|
|
|
|
}, [isOpen, onClose]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// Prevent body scroll when modal is open
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
|
} else {
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
const query = search.trim();
|
|
|
|
|
if (!query || !pagefindRef.current) {
|
|
|
|
|
setResults([]);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return () => {
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
};
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
|
|
|
|
if (!isOpen) return null;
|
|
|
|
|
setLoading(true);
|
|
|
|
|
pagefindRef.current.preload(query);
|
|
|
|
|
|
|
|
|
|
// Use portal to render modal at document body level to avoid z-index stacking context issues
|
|
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
const timer = setTimeout(async () => {
|
|
|
|
|
const pagefind = pagefindRef.current;
|
|
|
|
|
if (!pagefind) return;
|
|
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-[9999] flex items-start justify-center bg-black/50 backdrop-blur-sm pt-20 px-4"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
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="w-full max-w-3xl rounded-2xl border border-white/40 bg-white/95 shadow-2xl backdrop-blur-md dark:border-white/10 dark:bg-slate-900/95"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
<FiSearch className="h-5 w-5" />
|
|
|
|
|
<span className="text-sm font-medium">全站搜尋</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
|
|
|
|
aria-label="關閉搜尋"
|
|
|
|
|
>
|
|
|
|
|
<FiX className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Search Container */}
|
|
|
|
|
<div className="max-h-[60vh] overflow-y-auto p-6">
|
|
|
|
|
<div
|
|
|
|
|
ref={searchContainerRef}
|
|
|
|
|
className="pagefind-search"
|
|
|
|
|
data-pagefind-ui
|
|
|
|
|
/>
|
|
|
|
|
{!isLoaded && (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent"></div>
|
|
|
|
|
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
載入搜尋引擎...
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
<div className="border-t border-slate-200 px-6 py-3 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span>按 ESC 關閉</span>
|
|
|
|
|
<span className="text-right">支援中英文全文搜尋</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|