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