Skip to main content

yggdrasil/pages/
search.rs

1//! 搜索页:全文检索、主题入口与轻量阅读索引。
2//! 输入停顿后自动检索,回车可立即提交;编辑与清空会立即作废旧请求。
3
4use 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/// Only settled responses are persisted; request IDs and pending work belong to
18/// the mounted page and must never be revived by browser Back.
19#[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        // 同一序号同时约束防抖计时与网络响应,A → B → A 也不会提前提交。
103        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/// 独立加载主题,失败时仍保留可用的搜索与归档入口。
295#[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/// 原生矢量插画,颜色随站点主题变化,镜片随搜索框聚焦轻移。
352#[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}