返回 CodeWhale
docs-search.tsx
根目录 / web / components / docs-search.tsx
1 "use client";
2
3 import { useState, useMemo, useRef, useCallback, useEffect } from "react";
4 import Link from "next/link";
5 import {
6 DOC_TOPICS,
7 docTopicHref,
8 docTopicIsExternal,
9 type DocTopic,
10 } from "@/lib/docs-map";
11 import { docTopicHaystack } from "@/lib/search-utils";
12
13 /* ------------------------------------------------------------------ */
14 /* Locale-aware strings */
15 /* ------------------------------------------------------------------ */
16
17 const CATEGORY_LABELS: Record<string, { en: string; zh: string }> = {
18 "getting-started": { en: "Getting started", zh: "入门" },
19 "core-concepts": { en: "Core concepts", zh: "核心概念" },
20 reference: { en: "Reference", zh: "参考" },
21 extending: { en: "Extending", zh: "扩展" },
22 operations: { en: "Operations & community", zh: "运维与社区" },
23 };
24
25 /* ------------------------------------------------------------------ */
26 /* Link / source helpers (mirrored from the original page.tsx) */
27 /* ------------------------------------------------------------------ */
28
29 function topicSources(topic: DocTopic): string[] {
30 return Array.isArray(topic.repoSource) ? topic.repoSource : [topic.repoSource];
31 }
32
33 /**
34 * Build a single lowercase haystack string for fuzzy matching.
35 * Delegates to the shared search-utils for testability.
36 * Includes both EN and ZH text so a user can search in either language
37 * regardless of the active locale.
38 */
39 const topicHaystack = docTopicHaystack;
40
41 /* ------------------------------------------------------------------ */
42 /* Highlight helper */
43 /* ------------------------------------------------------------------ */
44
45 function highlight(text: string, query: string): React.ReactNode {
46 const q = query.trim().toLowerCase();
47 if (!q) return text;
48 const lower = text.toLowerCase();
49 const idx = lower.indexOf(q);
50 if (idx === -1) return text;
51 return (
52 <>
53 {text.slice(0, idx)}
54 <mark className="search-highlight">{text.slice(idx, idx + q.length)}</mark>
55 {text.slice(idx + q.length)}
56 </>
57 );
58 }
59
60 /* ------------------------------------------------------------------ */
61 /* Topic row */
62 /* ------------------------------------------------------------------ */
63
64 function TopicRow({
65 topic,
66 locale,
67 query,
68 }: {
69 topic: DocTopic;
70 locale: string;
71 query: string;
72 }) {
73 const isZh = locale === "zh";
74 const href = docTopicHref(topic, locale);
75 const sources = topicSources(topic);
76 const isExternal = docTopicIsExternal(topic);
77
78 return (
79 <Link
80 href={href}
81 target={isExternal ? "_blank" : undefined}
82 rel={isExternal ? "noreferrer" : undefined}
83 className="docs-topic-row"
84 >
85 <div className="docs-topic-main">
86 <div className="docs-topic-title">
87 {highlight(isZh ? topic.label.zh : topic.label.en, query)}
88 <span>{isExternal ? (isZh ? "源文档" : "Source doc") : (isZh ? "网页" : "Web guide")}</span>
89 </div>
90 <p>
91 {highlight(isZh ? topic.description.zh : topic.description.en, query)}
92 </p>
93 </div>
94 <div className="docs-topic-source">
95 {sources.map((s, i) => (
96 <span key={s}>
97 {i > 0 && ", "}
98 {highlight(s, query)}
99 </span>
100 ))}
101 </div>
102 <span className="docs-topic-arrow" aria-hidden="true">{isExternal ? "↗" : "→"}</span>
103 </Link>
104 );
105 }
106
107 /* ------------------------------------------------------------------ */
108 /* Main component */
109 /* ------------------------------------------------------------------ */
110
111 export function DocsSearch({ locale }: { locale: string }) {
112 const isZh = locale === "zh";
113 const [query, setQuery] = useState("");
114 const inputRef = useRef<HTMLInputElement>(null);
115
116 // Precompute haystacks once.
117 const haystacks = useMemo(() => DOC_TOPICS.map(topicHaystack), []);
118
119 // Filter topics by query.
120 const filteredTopics = useMemo(() => {
121 const q = query.trim().toLowerCase();
122 if (!q) return DOC_TOPICS;
123 return DOC_TOPICS.filter((_, i) => haystacks[i].includes(q));
124 }, [query, haystacks]);
125
126 // Group filtered topics by category (preserve DOC_TOPICS order).
127 const grouped = useMemo(() => {
128 const map = new Map<string, DocTopic[]>();
129 for (const t of filteredTopics) {
130 const group = map.get(t.category) ?? [];
131 group.push(t);
132 map.set(t.category, group);
133 }
134 return map;
135 }, [filteredTopics]);
136
137 // Keyboard shortcut: focus search on "/".
138 const handleKeyDown = useCallback((e: KeyboardEvent) => {
139 if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
140 e.preventDefault();
141 inputRef.current?.focus();
142 }
143 }, []);
144
145 useEffect(() => {
146 window.addEventListener("keydown", handleKeyDown);
147 return () => window.removeEventListener("keydown", handleKeyDown);
148 }, [handleKeyDown]);
149
150 const total = DOC_TOPICS.length;
151 const matched = filteredTopics.length;
152 const hasQuery = query.trim().length > 0;
153
154 return (
155 <div className="docs-index">
156 {/* Search bar */}
157 <div className="docs-search-block">
158 <label htmlFor="docs-search" className="docs-search-label">
159 {isZh ? "搜索文档" : "Search documentation"}
160 </label>
161 <div className="relative">
162 <input
163 id="docs-search"
164 ref={inputRef}
165 type="text"
166 value={query}
167 onChange={(e) => setQuery(e.target.value)}
168 placeholder={
169 isZh
170 ? "搜索文档…(按 / 快速聚焦)"
171 : "Search docs… (press / to focus)"
172 }
173 className="search-input docs-search-input w-full"
174 aria-label={isZh ? "搜索文档" : "Search documentation"}
175 />
176 {hasQuery && (
177 <button
178 type="button"
179 onClick={() => setQuery("")}
180 className="docs-search-clear"
181 aria-label={isZh ? "清除" : "Clear"}
182 >
183
184 </button>
185 )}
186 </div>
187 {hasQuery && (
188 <div className="docs-search-count" aria-live="polite">
189 {matched > 0
190 ? isZh
191 ? `${matched} / ${total} 篇文档匹配 "${query.trim()}"`
192 : `${matched} of ${total} docs match "${query.trim()}"`
193 : isZh
194 ? `未找到匹配 "${query.trim()}" 的文档`
195 : `No docs match "${query.trim()}"`}
196 </div>
197 )}
198 </div>
199
200 {/* Results */}
201 {matched > 0 ? (
202 <div className="docs-result-groups">
203 {[...grouped.entries()].map(([cat, topics]) => (
204 <section key={cat} id={cat} className="docs-result-group">
205 <div className="docs-result-heading">
206 <h2>{isZh ? CATEGORY_LABELS[cat]?.zh ?? cat : CATEGORY_LABELS[cat]?.en ?? cat}</h2>
207 <span>{topics.length}</span>
208 </div>
209 <div className="docs-topic-list">
210 {topics.map((t) => (
211 <TopicRow key={t.id} topic={t} locale={locale} query={query} />
212 ))}
213 </div>
214 </section>
215 ))}
216 </div>
217 ) : (
218 <div className="docs-empty">
219 <p>
220 {isZh ? "未找到结果" : "No results found"}
221 </p>
222 <p>
223 {isZh
224 ? "尝试使用不同的关键字,或浏览 GitHub 上的完整文档。"
225 : "Try a different keyword, or browse the full docs on GitHub."}
226 </p>
227 <Link
228 href="https://github.com/Hmbown/CodeWhale/tree/main/docs"
229 target="_blank"
230 className="portal-button portal-button-secondary"
231 >
232 {isZh ? "GitHub 文档目录 ↗" : "GitHub docs directory ↗"}
233 </Link>
234 </div>
235 )}
236
237 {/* Source note (only when not searching) */}
238 {!hasQuery && (
239 <section className="docs-source-note">
240 <p>
241 {isZh
242 ? "“网页”条目提供站内指南;“源文档”条目直接打开 GitHub 仓库中的完整参考资料。文档索引由仓库中的 docs-map.ts 注册表维护。"
243 : "Web guides stay on codewhale.net. Source docs open the complete reference in the GitHub repository. The index is maintained from the docs-map.ts registry in the repository."}
244 </p>
245 </section>
246 )}
247 </div>
248 );
249 }
250
250 lines Plain Text