1use leptos::prelude::*;
14
15use crate::api::posts::{list_tags, suggest_posts, PostSuggestion};
16use crate::components::palette::{
17 contains_ci, snippet, use_palette_shortcut, use_palette_state, viewport_center, PaletteEntry,
18 PaletteGroup, PaletteShell, PaletteState, SearchPaletteHandle,
19};
20use crate::components::ui::{Icon, IconName};
21use crate::routes::Route;
22use crate::theme::{choose_theme, use_resolved_theme, use_theme, ResolvedTheme, Theme};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26enum Scope {
27 All,
28 Posts,
29 Tags,
30 Pages,
31}
32
33impl Scope {
34 const ALL: [Scope; 4] = [Scope::All, Scope::Posts, Scope::Tags, Scope::Pages];
35
36 fn label(self) -> &'static str {
37 match self {
38 Scope::All => "全部",
39 Scope::Posts => "文章",
40 Scope::Tags => "标签",
41 Scope::Pages => "页面",
42 }
43 }
44}
45
46#[derive(Clone, Debug, PartialEq)]
48enum Item {
49 Post(PostSuggestion),
50 Tag {
51 name: String,
52 count: i64,
53 },
54 Page {
55 label: &'static str,
56 path: &'static str,
57 },
58 Recent(String),
59 Theme {
60 to: Theme,
61 label: &'static str,
62 },
63}
64
65#[derive(Clone, Debug, PartialEq)]
66struct Group {
67 label: &'static str,
68 items: Vec<Item>,
69}
70
71const PAGES: [(&str, &str); 8] = [
73 ("首页", "/"),
74 ("笔记", "/notes"),
75 ("归档", "/archives"),
76 ("友链", "/friends"),
77 ("关于", "/about"),
78 ("写作指南", "/about/writing"),
79 ("更新日志", "/changelog"),
80 ("组件图鉴", "/about/components"),
81];
82
83#[cfg(target_arch = "wasm32")]
85const RECENT_KEY: &str = "yggdrasil-search-recent";
86const RECENT_LIMIT: usize = 5;
87
88fn page_href(path: &str) -> String {
90 let route = match path {
91 "/notes" => Route::Notes,
92 "/archives" => Route::Archives,
93 "/friends" => Route::Friends,
94 "/about" => Route::About,
95 "/about/writing" => Route::WritingGuide,
96 "/changelog" => Route::Changelog,
97 "/about/components" => Route::ComponentShowcase,
98 _ => Route::Home,
99 };
100 route.to_string()
101}
102
103fn group_limit(scope: Scope, group: Scope) -> usize {
105 match (scope, group) {
106 (Scope::All, Scope::Posts) => 5,
107 (Scope::All, _) => 3,
108 _ => 8,
109 }
110}
111
112fn build_groups(
114 query: &str,
115 scope: Scope,
116 posts: &[PostSuggestion],
117 tags: &[(String, i64)],
118 recents: Option<&[String]>,
119 dark: bool,
120) -> Vec<Group> {
121 let q = query.trim();
122 let pages = |limit: usize| -> Vec<Item> {
123 PAGES
124 .iter()
125 .filter(|(label, path)| contains_ci(label, q) || contains_ci(path, q))
126 .take(limit)
127 .map(|(label, path)| Item::Page { label, path })
128 .collect()
129 };
130 let mut groups = Vec::new();
131 if q.is_empty() {
132 if let Some(recents) = recents.filter(|r| !r.is_empty()) {
133 groups.push(Group {
134 label: "最近搜索",
135 items: recents.iter().cloned().map(Item::Recent).collect(),
136 });
137 }
138 let mut jump = pages(4);
139 jump.push(if dark {
140 Item::Theme {
141 to: Theme::Light,
142 label: "切换到浅色",
143 }
144 } else {
145 Item::Theme {
146 to: Theme::Dark,
147 label: "切换到深色",
148 }
149 });
150 groups.push(Group {
151 label: "跳转",
152 items: jump,
153 });
154 return groups;
155 }
156 if matches!(scope, Scope::All | Scope::Posts) {
157 let items: Vec<Item> = posts
158 .iter()
159 .take(group_limit(scope, Scope::Posts))
160 .cloned()
161 .map(Item::Post)
162 .collect();
163 if !items.is_empty() {
164 groups.push(Group {
165 label: "文章",
166 items,
167 });
168 }
169 }
170 if matches!(scope, Scope::All | Scope::Tags) {
171 let items: Vec<Item> = tags
172 .iter()
173 .filter(|(name, _)| contains_ci(name, q))
174 .take(group_limit(scope, Scope::Tags))
175 .map(|(name, count)| Item::Tag {
176 name: name.clone(),
177 count: *count,
178 })
179 .collect();
180 if !items.is_empty() {
181 groups.push(Group {
182 label: "标签",
183 items,
184 });
185 }
186 }
187 if matches!(scope, Scope::All | Scope::Pages) {
188 let items = pages(group_limit(scope, Scope::Pages));
189 if !items.is_empty() {
190 groups.push(Group {
191 label: "跳转",
192 items,
193 });
194 }
195 }
196 groups
197}
198
199fn scope_count(
201 scope: Scope,
202 query: &str,
203 posts: &[PostSuggestion],
204 tags: &[(String, i64)],
205) -> usize {
206 let q = query.trim();
207 match scope {
208 Scope::All => {
209 scope_count(Scope::Posts, q, posts, tags)
210 + scope_count(Scope::Tags, q, posts, tags)
211 + scope_count(Scope::Pages, q, posts, tags)
212 }
213 Scope::Posts => posts.len(),
214 Scope::Tags => tags.iter().filter(|(name, _)| contains_ci(name, q)).count(),
215 Scope::Pages => PAGES
216 .iter()
217 .filter(|(label, path)| contains_ci(label, q) || contains_ci(path, q))
218 .count(),
219 }
220}
221
222fn read_recents() -> Option<Vec<String>> {
224 #[cfg(target_arch = "wasm32")]
225 {
226 let storage = web_sys::window()?.local_storage().ok()??;
227 let raw = storage.get_item(RECENT_KEY).ok()?;
228 match raw {
229 None => Some(Vec::new()),
230 Some(raw) => serde_json::from_slice::<Vec<String>>(raw.as_bytes()).ok(),
231 }
232 }
233 #[cfg(not(target_arch = "wasm32"))]
234 None
235}
236
237fn remember(recents: &[String], query: &str) -> Vec<String> {
239 let q = query.trim();
240 let mut next: Vec<String> = Vec::with_capacity(RECENT_LIMIT);
241 if !q.is_empty() {
242 next.push(q.to_string());
243 }
244 next.extend(recents.iter().filter(|r| r.as_str() != q).cloned());
245 next.truncate(RECENT_LIMIT);
246 next
247}
248
249#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
250fn write_recents(recents: &[String]) {
251 #[cfg(target_arch = "wasm32")]
252 if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
253 if let Ok(raw) = serde_json::to_string(recents) {
254 let _ = storage.set_item(RECENT_KEY, &raw);
255 }
256 }
257}
258
259fn entry(item: &Item, query: &str) -> PaletteEntry {
261 match item {
262 Item::Post(post) => PaletteEntry {
263 icon: IconName::FileText,
264 title: post.title.clone(),
265 summary: post
266 .summary
267 .as_deref()
268 .filter(|s| !s.trim().is_empty())
269 .map(|s| snippet(s, query, 18)),
270 meta: post
271 .published_at
272 .map(|d| crate::utils::time::ymd(&d, '.'))
273 .unwrap_or_default(),
274 mono_meta: true,
275 highlight_hits: true,
276 },
277 Item::Tag { name, count } => PaletteEntry {
278 icon: IconName::Tag,
279 title: name.clone(),
280 summary: None,
281 meta: format!("{count} 篇"),
282 mono_meta: false,
283 highlight_hits: true,
284 },
285 Item::Page { label, path } => PaletteEntry {
286 icon: IconName::CornerDownLeft,
287 title: label.to_string(),
288 summary: None,
289 meta: path.to_string(),
290 mono_meta: true,
291 highlight_hits: true,
292 },
293 Item::Recent(term) => PaletteEntry {
294 icon: IconName::RotateCcwClock,
295 title: term.clone(),
296 summary: None,
297 meta: String::new(),
298 mono_meta: false,
299 highlight_hits: false,
300 },
301 Item::Theme { label, .. } => PaletteEntry {
302 icon: IconName::Moon,
303 title: label.to_string(),
304 summary: None,
305 meta: "外观".to_string(),
306 mono_meta: false,
307 highlight_hits: false,
308 },
309 }
310}
311
312#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
314fn open_in_new_tab(href: &str) {
315 #[cfg(target_arch = "wasm32")]
316 if let Some(window) = web_sys::window() {
317 let _ = window.open_with_url_and_target(href, "_blank");
318 }
319}
320
321#[derive(Clone, Copy)]
323struct SearchData {
324 scope: RwSignal<Scope>,
325 posts: RwSignal<Vec<PostSuggestion>>,
326 posts_query: RwSignal<String>,
328 tags: RwSignal<Option<Vec<(String, i64)>>>,
330 recents: RwSignal<Option<Vec<String>>>,
332}
333
334#[leptos::lazy]
338fn search_palette_body(
339 palette: PaletteState,
340 data: SearchData,
341 theme: RwSignal<Theme>,
342 resolved: Memo<ResolvedTheme>,
343) -> AnyView {
344 let SearchData {
345 scope,
346 posts,
347 posts_query,
348 tags,
349 recents,
350 } = data;
351 let query = palette.query;
352 let active = palette.active;
353
354 let close = move || {
355 palette.close(move || {
357 scope.set(Scope::All);
358 posts.set(Vec::new());
359 posts_query.set(String::new());
360 });
361 };
362
363 let schedule_fetch = move || {
364 palette.schedule_fetch(posts, posts_query, |q| async move {
366 suggest_posts(q).await.ok().map(|res| res.posts)
367 })
368 };
369
370 let has_query = move || !query.read().trim().is_empty();
371 let groups = Memo::new(move |_| {
373 let q = query.get();
374 let scope = scope.get();
375 let dark = resolved.get() == ResolvedTheme::Dark;
376 posts.with(|posts| {
377 tags.with(|tags| {
378 recents.with(|recents| {
379 build_groups(
380 &q,
381 scope,
382 posts,
383 tags.as_deref().unwrap_or_default(),
384 recents.as_deref(),
385 dark,
386 )
387 })
388 })
389 })
390 });
391 let view_groups = Signal::derive(move || {
392 let q = query.get();
393 groups.with(|groups| {
394 groups
395 .iter()
396 .map(|g| PaletteGroup {
397 label: g.label,
398 entries: g.items.iter().map(|item| entry(item, &q)).collect(),
399 })
400 .collect::<Vec<_>>()
401 })
402 });
403 let waiting = Signal::derive(move || posts_query.read().as_str() != query.read().trim());
404
405 let remember_query = move |current: &str| {
406 let next = recents.with_untracked(|r| remember(r.as_deref().unwrap_or_default(), current));
407 write_recents(&next);
408 };
409
410 let activate = move |item: Item, new_tab: bool| {
411 let current = query.get_untracked();
412 match item {
413 Item::Recent(term) => {
414 query.set(term);
415 active.set(0);
416 schedule_fetch();
417 }
418 Item::Theme { to, .. } => {
419 close();
420 choose_theme(theme, to, viewport_center());
421 }
422 Item::Post(post) => {
423 remember_query(¤t);
424 close();
425 let href = Route::PostDetail { slug: post.slug }.to_string();
426 if new_tab {
427 open_in_new_tab(&href);
428 } else {
429 crate::navigation::push(&href);
430 }
431 }
432 Item::Tag { name, .. } => {
433 remember_query(¤t);
434 close();
435 crate::navigation::push(&Route::TagDetail { tag: name }.to_string());
436 }
437 Item::Page { path, .. } => {
438 close();
439 crate::navigation::push(&page_href(path));
440 }
441 }
442 };
443
444 let full_text_search = move || {
445 let q = query.get_untracked().trim().to_string();
446 close();
447 crate::navigation::push(&Route::Search { q }.to_string());
448 };
449
450 view! {
451 <PaletteShell
452 state=palette
453 label="搜索"
454 input_label="搜索文章、标签或页面"
455 placeholder="搜索文章、标签,或跳转到页面…"
456 list_label="搜索结果"
457 enter_hint="打开"
458 empty_hint="标题、摘要与标签里都没有命中,试试更短的关键词,或去全文搜索。"
459 groups=view_groups
460 waiting=waiting
461 clearable=true
462 on_query_change=move |_| schedule_fetch()
463 on_pick=move |(index, new_tab): (usize, bool)| {
464 let item = groups
465 .with_untracked(|gs| gs.iter().flat_map(|g| g.items.iter()).nth(index).cloned());
466 if let Some(item) = item {
467 activate(item, new_tab);
468 }
469 }
470 on_close=move |_| close()
471 toolbar=move || {
472 view! {
473 <div class="palette-scopes" role="tablist" aria-label="搜索范围">
474 {Scope::ALL
475 .into_iter()
476 .map(|s| {
477 view! {
478 <button
479 class="palette-scope"
480 type="button"
481 role="tab"
482 aria-selected=move || (scope.get() == s).to_string()
483 on:click=move |_| {
484 scope.set(s);
485 active.set(0);
486 palette.focus_input();
487 }
488 >
489 {s.label()}
490 {move || {
491 has_query()
492 .then(|| {
493 view! {
494 <span class="palette-scope-count">
495 {move || {
496 let q = query.get();
497 posts
498 .with(|posts| {
499 tags.with(|tags| {
500 scope_count(
501 s,
502 &q,
503 posts,
504 tags.as_deref().unwrap_or_default(),
505 )
506 })
507 })
508 }}
509 </span>
510 }
511 })
512 }}
513 </button>
514 }
515 })
516 .collect_view()}
517 </div>
518 }
519 }
520 empty_action=move || {
521 view! {
522 <button
523 class="btn btn-sm btn-secondary mt-4"
524 type="button"
525 on:click=move |_| full_text_search()
526 >
527 "在全文中搜索"
528 <Icon name=IconName::ArrowRight size=14/>
529 </button>
530 }
531 }
532 footer_action=move || {
533 move || {
535 has_query()
536 .then(|| {
537 view! {
538 <button
539 class="palette-full-text"
540 type="button"
541 on:click=move |_| full_text_search()
542 >
543 {move || format!("全文搜索「{}」", query.read().trim())}
544 <Icon name=IconName::ArrowRight size=13/>
545 </button>
546 }
547 })
548 }
549 }
550 />
551 }
552 .into_any()
553}
554
555#[component]
563pub fn SearchPalette(#[prop(default = true)] shortcut: bool) -> impl IntoView {
564 use_palette_shortcut(expect_context::<SearchPaletteHandle>(), shortcut);
565 let palette = use_palette_state("palette");
566 let open = palette.open;
567
568 let data = SearchData {
569 scope: RwSignal::new(Scope::All),
570 posts: RwSignal::new(Vec::new()),
571 posts_query: RwSignal::new(String::new()),
572 tags: RwSignal::new(None),
573 recents: RwSignal::new(None),
574 };
575 let theme = use_theme();
576 let resolved = use_resolved_theme();
577
578 Effect::new(move |_| {
580 if !open.get() {
581 return;
582 }
583 data.recents.set(read_recents());
584 if data.tags.read_untracked().is_none() {
585 leptos::task::spawn_local(async move {
586 if let Ok(res) = list_tags().await {
587 let _ = data.tags.try_set(Some(
588 res.tags
589 .into_iter()
590 .filter(|t| t.post_count > 0)
591 .map(|t| (t.name, t.post_count))
592 .collect(),
593 ));
594 }
595 });
596 }
597 });
598
599 view! {
600 <Suspense fallback=|| ()>
601 {move || {
602 palette
603 .visible()
604 .then(|| {
605 Suspend::new(async move {
606 search_palette_body(palette, data, theme, resolved).await
607 })
608 })
609 }}
610 </Suspense>
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 fn post(slug: &str, title: &str) -> PostSuggestion {
619 PostSuggestion {
620 slug: slug.into(),
621 title: title.into(),
622 summary: None,
623 published_at: None,
624 tags: Vec::new(),
625 }
626 }
627
628 #[test]
629 fn empty_query_shows_recents_and_jumps() {
630 let recents = vec!["waker".to_string()];
631 let groups = build_groups("", Scope::All, &[], &[], Some(&recents), false);
632 assert_eq!(groups[0].label, "最近搜索");
633 assert_eq!(groups[1].label, "跳转");
634 assert_eq!(
635 groups[1].items.last(),
636 Some(&Item::Theme {
637 to: Theme::Dark,
638 label: "切换到深色"
639 })
640 );
641 assert_eq!(
643 build_groups("", Scope::All, &[], &[], None, true)[0].label,
644 "跳转"
645 );
646 }
647
648 #[test]
649 fn groups_respect_scope_limits() {
650 let posts: Vec<_> = (0..8).map(|i| post(&format!("p{i}"), "关于源码")).collect();
651 let tags: Vec<_> = (0..5).map(|i| (format!("关于{i}"), 1)).collect();
652 let all = build_groups("关于", Scope::All, &posts, &tags, None, false);
653 assert_eq!(
654 all.iter()
655 .map(|g| (g.label, g.items.len()))
656 .collect::<Vec<_>>(),
657 [("文章", 5), ("标签", 3), ("跳转", 1)]
658 );
659 let only_posts = build_groups("关于", Scope::Posts, &posts, &tags, None, false);
660 assert_eq!(only_posts.len(), 1);
661 assert_eq!(only_posts[0].items.len(), 8);
662 assert_eq!(scope_count(Scope::All, "关于", &posts, &tags), 8 + 5 + 1);
663 }
664
665 #[test]
666 fn remember_dedupes_and_caps_recents() {
667 let recents: Vec<String> = ["a", "b", "c", "d", "e"]
668 .iter()
669 .map(|s| s.to_string())
670 .collect();
671 assert_eq!(remember(&recents, " c "), ["c", "a", "b", "d", "e"]);
672 assert_eq!(remember(&recents, "f"), ["f", "a", "b", "c", "d"]);
673 assert_eq!(remember(&recents, " "), ["a", "b", "c", "d", "e"]);
674 }
675
676 #[test]
677 fn page_routes_cover_every_jump_target() {
678 for (_, path) in PAGES {
679 assert_eq!(page_href(path), *path, "{path}");
680 }
681 }
682}