"use client" import { Search } from "lucide-react" import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type FormEvent, type KeyboardEvent, } from "react" import { LocalSearchResults } from "@/features/home/components/local-search-results" import { SearchProviderMenu } from "@/features/home/components/search-provider-menu" import { searchLocalLinks } from "@/features/home/lib/local-search" import { parseStoredProvider, SEARCH_PROVIDER_STORAGE_KEY, } from "@/features/home/lib/provider-preference" import { buildSearchUrl } from "@/features/home/lib/search-providers" import type { Category, NavLink } from "@/types/navigation" import type { SearchProvider } from "@/types/search" type SearchBoxProps = { links: NavLink[] categories: Category[] defaultProvider: SearchProvider enabledProviders: SearchProvider[] onExternalNavigate?: (url: string) => void } function subscribeHydration() { return () => undefined } function getClientHydrationSnapshot() { return true } function getServerHydrationSnapshot() { return false } function readStoredProvider( defaultProvider: SearchProvider, enabledProviders: SearchProvider[], ): SearchProvider { try { const storedProvider = parseStoredProvider( window.localStorage.getItem(SEARCH_PROVIDER_STORAGE_KEY), defaultProvider, ) return enabledProviders.includes(storedProvider) ? storedProvider : defaultProvider } catch { return defaultProvider } } export function SearchBox({ links, categories, defaultProvider, enabledProviders, onExternalNavigate, }: SearchBoxProps) { const inputRef = useRef(null) const optionRefs = useRef>([]) const listboxId = useId() const [query, setQuery] = useState("") const [selectedProvider, setSelectedProvider] = useState(null) const [activeIndex, setActiveIndex] = useState(0) const hydrated = useSyncExternalStore( subscribeHydration, getClientHydrationSnapshot, getServerHydrationSnapshot, ) // Server 与 hydration 首帧保持默认 Provider;hydration 后再容错读取浏览器偏好。 const restoredProvider = hydrated ? readStoredProvider(defaultProvider, enabledProviders) : defaultProvider const provider = selectedProvider ?? restoredProvider const results = useMemo( () => provider === "local" ? searchLocalLinks(query, links, categories, 8) : [], [categories, links, provider, query], ) const panelOpen = provider === "local" && query.trim().length > 0 const resolvedActiveIndex = results.length > 0 ? Math.min(activeIndex, results.length - 1) : 0 const activeOptionId = panelOpen && results.length > 0 ? `${listboxId}-option-${resolvedActiveIndex}` : undefined const shortcutHint = hydrated && navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K" useEffect(() => { inputRef.current?.focus() const focusSearch = (event: globalThis.KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { event.preventDefault() inputRef.current?.focus() } } window.addEventListener("keydown", focusSearch) return () => window.removeEventListener("keydown", focusSearch) }, []) const changeProvider = (nextProvider: SearchProvider) => { setSelectedProvider(nextProvider) setActiveIndex(0) try { window.localStorage.setItem(SEARCH_PROVIDER_STORAGE_KEY, nextProvider) } catch { // Provider 已保留在当前组件状态,存储受限时不影响本次交互。 } } const openActiveLocalResult = () => { if (results.length === 0) return // 复用真实 anchor 的导航语义,键盘 Enter 不另造 click-only 跳转路径。 optionRefs.current[resolvedActiveIndex]?.click() } const submitSearch = (event: FormEvent) => { event.preventDefault() // Local 只打开站内结果;外部 Provider 只构建搜索 URL,两条路径严格分离。 if (provider === "local") { openActiveLocalResult() return } const url = buildSearchUrl(provider, query) if (!url) return if (onExternalNavigate) onExternalNavigate(url) else window.location.assign(url) } const handleInputKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault() setQuery("") setActiveIndex(0) return } if (provider !== "local") return if (event.key === "Enter") { event.preventDefault() openActiveLocalResult() return } if (results.length === 0) return if (event.key === "ArrowDown") { event.preventDefault() setActiveIndex((index) => (index + 1) % results.length) } else if (event.key === "ArrowUp") { event.preventDefault() setActiveIndex((index) => (index - 1 + results.length) % results.length) } } return (
) }