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