1use std::rc::Rc;
5
6use dioxus::prelude::*;
7
8use crate::api::posts::{list_tags, search_posts, PostListResponse};
9use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
10use crate::components::skeletons::search_skeleton::SearchSkeleton;
11use crate::models::post::PostListItem;
12use crate::router::Route;
13use crate::utils::time::sleep_ms;
14
15const SEARCH_DEBOUNCE_MS: u32 = 350;
16
17#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
20struct SearchHistoryState {
21 query: String,
22 submitted_query: String,
23 response: Option<PostListResponse>,
24 failed: bool,
25}
26
27impl SearchHistoryState {
28 fn result(&self) -> Option<Result<PostListResponse, ServerFnError>> {
29 if self.failed {
30 Some(Err(ServerFnError::new("搜索暂时未能完成,请重试")))
31 } else {
32 self.response.clone().map(Ok)
33 }
34 }
35}
36
37#[component]
38pub fn Search() -> Element {
39 let entry_id = use_hook(crate::bridges::navigation::entry_id);
40 let saved = use_hook(|| {
41 crate::bridges::navigation::read_state::<SearchHistoryState>("search").unwrap_or_default()
42 });
43 let mut query = use_signal(|| saved.query.clone());
44 let mut submitted_query = use_signal(|| saved.submitted_query.clone());
45 let mut search_res = use_signal(|| saved.result());
46 let mut is_searching = use_signal(|| false);
47 let mut request_id = use_signal(|| 0_u64);
48 let mut is_composing = use_signal(|| false);
49 let mut input_element = use_signal(|| None::<Rc<MountedData>>);
50
51 use_effect(move || {
52 let result = search_res();
53 let state = SearchHistoryState {
54 query: query(),
55 submitted_query: if is_searching() {
56 String::new()
57 } else {
58 submitted_query()
59 },
60 response: result
61 .as_ref()
62 .and_then(|result| result.as_ref().ok())
63 .cloned(),
64 failed: result.as_ref().is_some_and(|result| result.is_err()),
65 };
66 crate::bridges::navigation::write_state(&entry_id, "search", &state);
67 });
68
69 let mut on_search = move |value: String| {
70 let q = value.trim().to_string();
71 if is_composing() || q.is_empty() || (is_searching() && q == submitted_query()) {
72 return;
73 }
74 let id = request_id() + 1;
75 request_id.set(id);
76 submitted_query.set(q.clone());
77 is_searching.set(true);
78 search_res.set(None);
79 spawn(async move {
80 let res = search_posts(q).await;
81 if request_id() == id {
82 search_res.set(Some(res));
83 is_searching.set(false);
84 }
85 });
86 };
87
88 let mut clear_search = move || {
89 request_id += 1;
90 query.set(String::new());
91 submitted_query.set(String::new());
92 search_res.set(None);
93 is_searching.set(false);
94 if let Some(element) = input_element() {
95 spawn(async move {
96 let _ = element.set_focus(true).await;
97 });
98 }
99 };
100
101 let mut schedule_search = move || {
102 request_id += 1;
104 is_searching.set(false);
105 let id = request_id();
106 let q = query().trim().to_string();
107 if q.is_empty() {
108 submitted_query.set(String::new());
109 search_res.set(None);
110 return;
111 }
112 if is_composing() {
113 return;
114 }
115 spawn(async move {
116 sleep_ms(SEARCH_DEBOUNCE_MS).await;
117 if request_id() == id
118 && !is_composing()
119 && !(q == submitted_query() && search_res().as_ref().is_some_and(|res| res.is_ok()))
120 {
121 on_search(q);
122 }
123 });
124 };
125
126 let status = if is_searching() {
127 format!("正在查找「{}」…", submitted_query())
128 } else {
129 match search_res().as_ref() {
130 Some(Ok(res)) if res.posts.len() == 50 => {
131 "找到 50 篇文章,展示最相关的结果".to_string()
132 }
133 Some(Ok(res)) => format!("找到 {} 篇相关文章", res.posts.len()),
134 Some(Err(_)) => "搜索暂时未能完成,请重试".to_string(),
135 None => "输入关键词,搜索标题与正文".to_string(),
136 }
137 };
138
139 rsx! {
140 div { class: "search-page",
141 header { class: "search-hero search-enter",
142 div { class: "search-hero-copy",
143 p { class: "search-eyebrow", span { aria_hidden: "true" } "SEARCH / 文字索引" }
144 h1 { "在字里行间," span { "找到一点灵感。" } }
145 p { class: "search-description", "一个词,一条线索。重新遇见那些值得留下的文字。" }
146 }
147 SearchIllustration {}
148 }
149
150 section { class: "search-workspace search-enter", style: "--search-delay: 90ms",
151 aria_label: "文章搜索",
152 form { class: "search-form", role: "search",
153 onsubmit: move |event| {
154 event.prevent_default();
155 on_search(query());
156 },
157 label { class: "search-label", r#for: "article-search", "你想寻找什么?" }
158 div { class: "search-field", "data-loading": is_searching().to_string(),
159 svg { class: "search-field-icon", view_box: "0 0 24 24", fill: "none", "aria-hidden": "true",
160 circle { cx: "10.75", cy: "10.75", r: "6.75", stroke: "currentColor", stroke_width: "1.6" }
161 path { d: "m16 16 4.5 4.5", stroke: "currentColor", stroke_width: "1.6", stroke_linecap: "round" }
162 }
163 input {
164 id: "article-search",
165 class: "search-input ygg-search-clear",
166 r#type: "search",
167 name: "q",
168 placeholder: "搜索文章、想法、只言片语…",
169 autocomplete: "off",
170 maxlength: "200",
171 enterkeyhint: "search",
172 aria_describedby: "search-hint",
173 value: query(),
174 onmounted: move |event| input_element.set(Some(event.data())),
175 oninput: move |event| {
176 let value = event.value();
177 if value.is_empty() {
178 clear_search();
179 } else {
180 query.set(value);
181 schedule_search();
182 }
183 },
184 oncompositionstart: move |_| {
185 is_composing.set(true);
186 schedule_search();
187 },
188 oncompositionend: move |_| {
189 is_composing.set(false);
190 schedule_search();
191 },
192 onkeydown: move |event| {
193 if event.is_composing() || is_composing() {
194 return;
195 }
196 if event.key() == Key::Escape {
197 event.prevent_default();
198 clear_search();
199 }
200 },
201 }
202 button {
203 class: "search-clear",
204 r#type: "button",
205 aria_label: "清空搜索",
206 "data-visible": (!query().is_empty()).to_string(),
207 disabled: query().is_empty(),
208 tabindex: if query().is_empty() { "-1" } else { "0" },
209 onclick: move |_| clear_search(),
210 svg { view_box: "0 0 20 20", fill: "none", "aria-hidden": "true",
211 path { d: "m6 6 8 8M14 6l-8 8", stroke: "currentColor", stroke_width: "1.5", stroke_linecap: "round" }
212 }
213 }
214 button {
215 class: "search-submit",
216 r#type: "submit",
217 disabled: query().trim().is_empty() || (is_searching() && query().trim() == submitted_query()),
218 aria_label: if is_searching() { "正在搜索" } else { "搜索文章" },
219 span { if is_searching() { "寻找中" } else { "搜索" } }
220 span { class: "search-submit-symbol", "data-loading": is_searching().to_string(), aria_hidden: "true",
221 if is_searching() { span { class: "search-spinner" } } else { "↗" }
222 }
223 }
224 }
225 div { class: "search-hints", id: "search-hint",
226 span { "输入完成后自动搜索标题与正文。" }
227 span { class: "search-keyboard-hint", kbd { "Enter" } " 立即搜索" span { "·" } kbd { "Esc" } " 清空" }
228 }
229 }
230 }
231
232 p { class: "sr-only", role: "status", aria_live: "polite", "{status}" }
233 section { class: "search-content", "data-vt-list": (!is_searching()).then_some("true"), aria_label: "搜索结果", aria_busy: is_searching().to_string(),
234 if is_searching() {
235 div { class: "search-results-heading search-enter",
236 h2 { "正在翻阅文字" }
237 span { "正在寻找相关的文章…" }
238 }
239 DelayedSkeleton { SearchSkeleton {} }
240 } else if let Some(Ok(res)) = search_res() {
241 div { key: "results-{submitted_query}", class: "search-enter",
242 div { class: "search-results-heading",
243 h2 { "关于「{submitted_query}」" }
244 span { "{status}" }
245 }
246 if res.posts.is_empty() {
247 div { class: "search-empty",
248 span { class: "search-empty-symbol", aria_hidden: "true", "∅" }
249 h3 { "这条线索,还没有回音。" }
250 p { "试试更简短的词,或换一种表达。" }
251 button { class: "search-text-link", r#type: "button", onclick: move |_| clear_search(),
252 "换个关键词" span { aria_hidden: "true", "↗" }
253 }
254 }
255 } else {
256 p { class: "search-result-order", "按相关度排列 · 最多展示 50 篇" }
257 div { class: "search-result-list",
258 for (index, post) in res.posts.into_iter().enumerate() {
259 SearchResult { key: "{post.id}", post, index }
260 }
261 }
262 }
263 }
264 } else if search_res().as_ref().is_some_and(|res| res.is_err()) {
265 div { class: "search-empty search-enter",
266 span { class: "search-empty-symbol", aria_hidden: "true", "↻" }
267 h2 { "线索还在,稍后再试。" }
268 p { "暂时没能完成搜索,请重新试一次。" }
269 button { class: "search-text-link", r#type: "button", onclick: move |_| on_search(query()),
270 "重新搜索" span { aria_hidden: "true", "↗" }
271 }
272 }
273 } else {
274 div { class: "search-intro search-enter", style: "--search-delay: 170ms",
275 span { class: "search-section-number", aria_hidden: "true", "01 / DISCOVER" }
276 h2 { "不必有答案,从好奇开始。" }
277 p { "想找的也许是一篇文章,也许是曾经一闪而过的念头。" }
278 }
279 }
280 }
281
282 SearchTopics {}
283
284 footer { class: "search-footer search-enter", style: "--search-delay: 260ms",
285 span { "每一片文字,都有它的来处。" }
286 Link { class: "search-text-link", to: Route::Archives {},
287 "去归档随意翻翻" span { aria_hidden: "true", "↗" }
288 }
289 }
290 }
291 }
292}
293
294#[component]
296fn SearchTopics() -> Element {
297 let tags = use_resource(move || async move { list_tags().await });
298 let data = tags.read();
299 let Some(Ok(data)) = data.as_ref() else {
300 return rsx! {};
301 };
302 let mut topics: Vec<_> = data.tags.iter().filter(|tag| tag.post_count > 0).collect();
303 topics.sort_by_key(|tag| std::cmp::Reverse(tag.post_count));
304 if topics.is_empty() {
305 return rsx! {};
306 }
307 rsx! {
308 nav { class: "search-topics search-enter", style: "--search-delay: 210ms", aria_label: "按主题探索文章",
309 div { class: "search-topics-heading", span { "也可以,沿着主题探索" } span { aria_hidden: "true", "EXPLORE BY TOPIC" } }
310 div { class: "search-topic-list",
311 for tag in topics.into_iter().take(8) {
312 Link { key: "{tag.id}", class: "search-topic", to: Route::TagDetail { tag: tag.name.clone() },
313 span { class: "search-topic-hash", aria_hidden: "true", "#" }
314 span { "{tag.name}" }
315 span { class: "search-topic-count", aria_label: "{tag.post_count} 篇文章", "{tag.post_count}" }
316 }
317 }
318 }
319 }
320 }
321}
322
323#[component]
324fn SearchResult(post: PostListItem, index: usize) -> Element {
325 let number = format!("{:02}", index + 1);
326 let delay = index.min(7) * 45;
327 let date = post.formatted_date();
328 let reading_time = post.reading_time.max(1);
329 rsx! {
330 article { class: "search-result search-enter", style: "--search-delay: {delay}ms",
331 Link { class: "search-result-link", "data-vt-post-link": "{post.id}", to: Route::PostDetail { slug: post.slug },
332 span { class: "search-result-number", aria_hidden: "true", "{number}" }
333 div { class: "search-result-body",
334 div { class: "search-result-meta", time { datetime: "{date}", "{date}" } span { "{reading_time} 分钟阅读" } }
335 h2 { "data-vt-post-id": "{post.id}", "data-vt-role": "title", "{post.title}" }
336 if let Some(summary) = post.summary.filter(|text| !text.is_empty()) {
337 p { class: "search-result-summary", "{summary}" }
338 }
339 if !post.tags.is_empty() {
340 div { class: "search-result-tags",
341 for tag in post.tags.iter().take(3) { span { key: "{tag}", "# {tag}" } }
342 }
343 }
344 }
345 span { class: "search-result-arrow", aria_hidden: "true", "↗" }
346 }
347 }
348 }
349}
350
351#[component]
353fn SearchIllustration() -> Element {
354 rsx! {
355 div { class: "search-illustration", aria_hidden: "true",
356 svg { view_box: "0 0 260 230", fill: "none",
357 circle { class: "search-art-orbit", cx: "130", cy: "112", r: "88", stroke: "currentColor", stroke_width: "0.7", stroke_dasharray: "2 7" }
358 circle { class: "search-art-orbit", cx: "130", cy: "112", r: "107", stroke: "currentColor", stroke_width: "0.6" }
359 g { class: "search-art-pages",
360 rect { class: "search-art-back", x: "57", y: "40", width: "124", height: "155", rx: "8", transform: "rotate(-10 119 117)" }
361 rect { class: "search-art-paper", x: "68", y: "37", width: "124", height: "155", rx: "8", stroke: "currentColor", stroke_width: "1" }
362 path { class: "search-art-lines", d: "M89 68h43M89 82h76M89 96h62M89 144h69M89 157h54M89 170h29", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round" }
363 path { d: "M156 37v28l9-6 9 6V37", fill: "currentColor", opacity: "0.2" }
364 }
365 g { class: "search-art-lens",
366 circle { class: "search-art-glass", cx: "163", cy: "121", r: "36", stroke: "currentColor", stroke_width: "2" }
367 circle { cx: "163", cy: "121", r: "29", stroke: "currentColor", stroke_width: "0.7", opacity: "0.3" }
368 path { d: "m189 147 26 28", stroke: "currentColor", stroke_width: "9", stroke_linecap: "round" }
369 path { d: "m189 147 26 28", stroke: "var(--color-paper-theme)", stroke_width: "5", stroke_linecap: "round" }
370 path { d: "M151 134c0-17 10-28 25-29-1 17-9 26-25 29Zm0 0 15-17", stroke: "currentColor", stroke_width: "1.5", stroke_linecap: "round", stroke_linejoin: "round" }
371 }
372 path { class: "search-art-spark", d: "M43 102v12m-6-6h12M208 65v10m-5-5h10", stroke: "currentColor", stroke_width: "1.3", stroke_linecap: "round" }
373 circle { cx: "63", cy: "177", r: "3", fill: "currentColor", opacity: "0.5" }
374 }
375 span { "A LITTLE CURIOSITY GOES A LONG WAY" }
376 }
377 }
378}