返回 CodeWhale
faq-search.tsx
根目录 / web / components / faq-search.tsx
1 "use client";
2
3 import { useState, useMemo, useRef, useCallback, useEffect } from "react";
4 import { faqSourceHref } from "@/lib/faq-source";
5 import { extractText } from "@/lib/react-text";
6 import { highlightSpan } from "@/lib/search-utils";
7
8 export interface FaqSearchItem {
9 q: string;
10 a: React.ReactNode;
11 sources?: string[];
12 }
13
14 /* ------------------------------------------------------------------ */
15 /* Highlight helper */
16 /* ------------------------------------------------------------------ */
17
18 function highlight(text: string, query: string): React.ReactNode {
19 // Index arithmetic lives in search-utils: lowercasing can change a
20 // string's length, so `text` cannot be sliced with indices taken from
21 // its lowercased copy.
22 const span = highlightSpan(text, query);
23 if (!span) return text;
24 return (
25 <>
26 {span.before}
27 <mark className="search-highlight">{span.match}</mark>
28 {span.after}
29 </>
30 );
31 }
32
33 /* ------------------------------------------------------------------ */
34 /* Component */
35 /* ------------------------------------------------------------------ */
36
37 export function FaqSearch({
38 items,
39 locale,
40 }: {
41 items: FaqSearchItem[];
42 locale: string;
43 }) {
44 const isZh = locale === "zh";
45 const [query, setQuery] = useState("");
46 const inputRef = useRef<HTMLInputElement>(null);
47
48 // Precompute haystack for each item (question + answer text + sources).
49 const haystacks = useMemo(
50 () =>
51 items.map((item) => {
52 const parts = [
53 item.q,
54 extractText(item.a),
55 ...(item.sources ?? []),
56 ];
57 return parts.join(" ").toLowerCase();
58 }),
59 [items],
60 );
61
62 const filtered = useMemo(() => {
63 const q = query.trim().toLowerCase();
64 if (!q) return items.map((item, i) => ({ item, i }));
65 return items
66 .map((item, i) => ({ item, i }))
67 .filter(({ i }) => haystacks[i].includes(q));
68 }, [query, haystacks, items]);
69
70 // Keyboard shortcut: focus search on "/".
71 const handleKeyDown = useCallback((e: KeyboardEvent) => {
72 if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
73 e.preventDefault();
74 inputRef.current?.focus();
75 }
76 }, []);
77
78 useEffect(() => {
79 window.addEventListener("keydown", handleKeyDown);
80 return () => window.removeEventListener("keydown", handleKeyDown);
81 }, [handleKeyDown]);
82
83 const total = items.length;
84 const matched = filtered.length;
85 const hasQuery = query.trim().length > 0;
86
87 return (
88 <>
89 {/* Search bar */}
90 <div className="mb-6">
91 <div className="relative">
92 <input
93 ref={inputRef}
94 type="text"
95 value={query}
96 onChange={(e) => setQuery(e.target.value)}
97 placeholder={
98 isZh
99 ? "搜索常见问题…(按 / 快速聚焦)"
100 : "Search FAQ… (press / to focus)"
101 }
102 className="search-input w-full"
103 aria-label={isZh ? "搜索常见问题" : "Search FAQ"}
104 />
105 {hasQuery && (
106 <button
107 onClick={() => setQuery("")}
108 className="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-sm text-ink-mute hover:text-indigo transition-colors"
109 aria-label={isZh ? "清除" : "Clear"}
110 >
111
112 </button>
113 )}
114 </div>
115 {hasQuery && (
116 <div className="mt-2 font-mono text-[0.7rem] text-ink-mute" aria-live="polite">
117 {matched > 0
118 ? isZh
119 ? `${matched} / ${total} 个问题匹配 "${query.trim()}"`
120 : `${matched} of ${total} questions match "${query.trim()}"`
121 : isZh
122 ? `未找到匹配 "${query.trim()}" 的问题`
123 : `No questions match "${query.trim()}"`}
124 </div>
125 )}
126 </div>
127
128 {/* FAQ list */}
129 {matched > 0 ? (
130 <div className="space-y-0 hairline-t hairline-b">
131 {filtered.map(({ item, i }) => (
132 <details key={i} className="group hairline-b last:border-b-0">
133 <summary className="px-0 py-5 cursor-pointer flex items-start gap-4 hover:text-indigo transition-colors">
134 <span className="font-mono text-indigo tabular text-sm pt-0.5 shrink-0">
135 {String(i + 1).padStart(2, "0")}
136 </span>
137 <span className="font-display text-lg leading-snug flex-1">
138 {highlight(item.q, query)}
139 </span>
140 <span className="font-mono text-ink-mute text-sm group-open:rotate-45 transition-transform shrink-0">+</span>
141 </summary>
142 <div className="pb-5 pl-10 pr-4">
143 <div className={`text-ink-soft leading-relaxed ${isZh ? "leading-[1.9] tracking-wide" : ""}`}>
144 {item.a}
145 </div>
146 {item.sources && item.sources.length > 0 && (
147 <div className="mt-3 flex items-center gap-2 flex-wrap">
148 <span className="font-mono text-[0.66rem] text-ink-mute uppercase tracking-wider">
149 {isZh ? "来源" : "Sources"}:
150 </span>
151 {item.sources.map((s) => {
152 const href = faqSourceHref(s);
153 return href ? (
154 <a
155 key={s}
156 href={href}
157 target="_blank"
158 rel="noreferrer"
159 className="font-mono text-[0.7rem] text-indigo hover:underline"
160 >
161 {s}
162 </a>
163 ) : (
164 <span key={s} className="font-mono text-[0.7rem] text-indigo">{s}</span>
165 );
166 })}
167 </div>
168 )}
169 </div>
170 </details>
171 ))}
172 </div>
173 ) : (
174 <div className="text-center py-16 hairline-t hairline-b">
175 <p className="font-display text-lg text-ink-mute mb-2">
176 {isZh ? "未找到结果" : "No results found"}
177 </p>
178 <p className="text-sm text-ink-mute">
179 {isZh
180 ? "尝试使用不同的关键字。"
181 : "Try a different keyword."}
182 </p>
183 </div>
184 )}
185 </>
186 );
187 }
188
188 lines Plain Text