返回 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_CATEGORY_LABELS,
7 DOC_TOPICS,
8 docTopicHref,
9 docTopicIsExternal,
10 type DocTopic,
11 } from "@/lib/docs-map";
12 import { DOC_TASKS, docTaskHaystack, type DocTask } from "@/lib/docs-tasks";
13 import { fill, getDocsShell, pickText } from "@/lib/i18n/dictionaries";
14 import { docTopicHaystack, highlightSpan } from "@/lib/search-utils";
15 import { EmptyState } from "./surface-state";
16
17 /* ------------------------------------------------------------------ */
18 /* Helpers */
19 /* ------------------------------------------------------------------ */
20
21 function topicSources(topic: DocTopic): string[] {
22 return Array.isArray(topic.repoSource) ? topic.repoSource : [topic.repoSource];
23 }
24
25 function highlight(text: string, query: string): React.ReactNode {
26 // Index arithmetic lives in search-utils: lowercasing can change a
27 // string's length, so `text` cannot be sliced with indices taken from
28 // its lowercased copy.
29 const span = highlightSpan(text, query);
30 if (!span) return text;
31 return (
32 <>
33 {span.before}
34 <mark className="search-highlight">{span.match}</mark>
35 {span.after}
36 </>
37 );
38 }
39
40 /* ------------------------------------------------------------------ */
41 /* Rows */
42 /* ------------------------------------------------------------------ */
43
44 function TaskRow({ task, locale, query }: { task: DocTask; locale: string; query: string }) {
45 return (
46 <Link href={`/${locale}${task.href}`} className="docs-topic-row docs-task-row">
47 <div className="docs-topic-main">
48 <div className="docs-topic-title">{highlight(pickText(task.label, locale), query)}</div>
49 <p>{highlight(pickText(task.description, locale), query)}</p>
50 </div>
51 <div className="docs-topic-source">{task.href}</div>
52 <span className="docs-topic-arrow" aria-hidden="true">→</span>
53 </Link>
54 );
55 }
56
57 function TopicRow({
58 topic,
59 locale,
60 query,
61 webGuideTag,
62 sourceDocTag,
63 }: {
64 topic: DocTopic;
65 locale: string;
66 query: string;
67 webGuideTag: string;
68 sourceDocTag: string;
69 }) {
70 const href = docTopicHref(topic, locale);
71 const sources = topicSources(topic);
72 const isExternal = docTopicIsExternal(topic);
73
74 return (
75 <Link
76 href={href}
77 target={isExternal ? "_blank" : undefined}
78 rel={isExternal ? "noreferrer" : undefined}
79 className="docs-topic-row"
80 >
81 <div className="docs-topic-main">
82 <div className="docs-topic-title">
83 {highlight(pickText(topic.label, locale), query)}
84 <span>{isExternal ? sourceDocTag : webGuideTag}</span>
85 </div>
86 <p>{highlight(pickText(topic.description, locale), query)}</p>
87 </div>
88 <div className="docs-topic-source">
89 {sources.map((s, i) => (
90 <span key={s}>
91 {i > 0 && ", "}
92 {highlight(s, query)}
93 </span>
94 ))}
95 </div>
96 <span className="docs-topic-arrow" aria-hidden="true">{isExternal ? "↗" : "→"}</span>
97 </Link>
98 );
99 }
100
101 /* ------------------------------------------------------------------ */
102 /* Main component */
103 /* ------------------------------------------------------------------ */
104
105 /**
106 * The docs hub: one search box over two registries — tasks
107 * (`lib/docs-tasks.ts`, "I want to…") and topics (`lib/docs-map.ts`).
108 * Searching matches English and Chinese text regardless of the active
109 * locale. Every string is dictionary-driven; no locale branch here.
110 */
111 export function DocsSearch({ locale }: { locale: string }) {
112 const t = getDocsShell(locale);
113 const [query, setQuery] = useState("");
114 const inputRef = useRef<HTMLInputElement>(null);
115
116 const topicHaystacks = useMemo(() => DOC_TOPICS.map(docTopicHaystack), []);
117 const taskHaystacks = useMemo(() => DOC_TASKS.map(docTaskHaystack), []);
118
119 const q = query.trim().toLowerCase();
120 const filteredTasks = useMemo(
121 () => (q ? DOC_TASKS.filter((_, i) => taskHaystacks[i].includes(q)) : DOC_TASKS),
122 [q, taskHaystacks],
123 );
124 const filteredTopics = useMemo(
125 () => (q ? DOC_TOPICS.filter((_, i) => topicHaystacks[i].includes(q)) : DOC_TOPICS),
126 [q, topicHaystacks],
127 );
128
129 // Group filtered topics by category (preserve DOC_TOPICS order).
130 const grouped = useMemo(() => {
131 const map = new Map<DocTopic["category"], DocTopic[]>();
132 for (const topic of filteredTopics) {
133 const group = map.get(topic.category) ?? [];
134 group.push(topic);
135 map.set(topic.category, group);
136 }
137 return map;
138 }, [filteredTopics]);
139
140 // Keyboard shortcut: focus search on "/".
141 const handleKeyDown = useCallback((e: KeyboardEvent) => {
142 if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
143 e.preventDefault();
144 inputRef.current?.focus();
145 }
146 }, []);
147
148 useEffect(() => {
149 window.addEventListener("keydown", handleKeyDown);
150 return () => window.removeEventListener("keydown", handleKeyDown);
151 }, [handleKeyDown]);
152
153 const total = DOC_TOPICS.length + DOC_TASKS.length;
154 const matched = filteredTopics.length + filteredTasks.length;
155 const hasQuery = q.length > 0;
156
157 return (
158 <div className="docs-index">
159 {/* Search bar */}
160 <div className="docs-search-block">
161 <label htmlFor="docs-search" className="docs-search-label">
162 {t.searchLabel}
163 </label>
164 <div className="relative">
165 <input
166 id="docs-search"
167 ref={inputRef}
168 type="text"
169 value={query}
170 onChange={(e) => setQuery(e.target.value)}
171 placeholder={t.searchPlaceholder}
172 className="search-input docs-search-input w-full"
173 aria-label={t.searchLabel}
174 autoComplete="off"
175 />
176 {hasQuery && (
177 <button
178 type="button"
179 onClick={() => setQuery("")}
180 className="docs-search-clear"
181 aria-label={t.searchClear}
182 >
183
184 </button>
185 )}
186 </div>
187 {hasQuery && (
188 <div className="docs-search-count" aria-live="polite">
189 {matched > 0
190 ? fill(t.searchMatches, { matched, total, query: query.trim() })
191 : fill(t.searchNoMatches, { query: query.trim() })}
192 </div>
193 )}
194 </div>
195
196 {matched > 0 ? (
197 <div className="docs-result-groups">
198 {/* Tasks — "I am trying to…" */}
199 {filteredTasks.length > 0 && (
200 <section id="tasks" className="docs-result-group docs-task-group">
201 <div className="docs-result-heading">
202 <h2>{t.tasksHeading}</h2>
203 <span>{filteredTasks.length}</span>
204 </div>
205 {!hasQuery && <p className="docs-result-lead">{t.tasksLead}</p>}
206 <div className="docs-topic-list">
207 {filteredTasks.map((task) => (
208 <TaskRow key={task.id} task={task} locale={locale} query={query} />
209 ))}
210 </div>
211 </section>
212 )}
213
214 {/* Topics by category */}
215 {grouped.size > 0 && (
216 <div className="docs-result-topics">
217 {!hasQuery && (
218 <div className="docs-result-heading docs-result-heading-topics">
219 <h2>{t.topicsHeading}</h2>
220 <span>{filteredTopics.length}</span>
221 </div>
222 )}
223 {[...grouped.entries()].map(([category, topics]) => (
224 <section key={category} id={category} className="docs-result-group">
225 <div className="docs-result-heading">
226 <h3>{pickText(DOC_CATEGORY_LABELS[category], locale)}</h3>
227 <span>{topics.length}</span>
228 </div>
229 <div className="docs-topic-list">
230 {topics.map((topic) => (
231 <TopicRow
232 key={topic.id}
233 topic={topic}
234 locale={locale}
235 query={query}
236 webGuideTag={t.webGuideTag}
237 sourceDocTag={t.sourceDocTag}
238 />
239 ))}
240 </div>
241 </section>
242 ))}
243 </div>
244 )}
245 </div>
246 ) : (
247 <EmptyState
248 locale={locale}
249 title={t.emptyTitle}
250 body={t.emptyBody}
251 action={
252 <a
253 href="https://github.com/Hmbown/CodeWhale/tree/main/docs"
254 target="_blank"
255 rel="noreferrer"
256 className="portal-button portal-button-secondary"
257 >
258 {t.emptyCta}
259 </a>
260 }
261 />
262 )}
263
264 {/* Registry note (only when not searching) */}
265 {!hasQuery && (
266 <section className="docs-source-note">
267 <p>{t.indexNote}</p>
268 </section>
269 )}
270 </div>
271 );
272 }
273
273 lines Plain Text