1use std::future::Future;
12
13use chrono::{DateTime, Datelike, NaiveDate, Utc};
14use leptos::prelude::*;
15use leptos::task::spawn_local;
16use leptos_meta::Title;
17use leptos_router::hooks::{use_params_map, use_query_map};
18use leptos_router::{lazy_route, LazyRoute};
19
20use crate::api::error::ApiError;
21use crate::api::notes::{get_note, get_note_context, list_notebooks, list_notes, note_stats};
22use crate::components::empty_state::{EmptyState, EmptyStateAction};
23use crate::components::mascot::Mascot;
24use crate::components::post::post_content::PostContent;
25use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
26use crate::components::skeletons::notes_route_skeleton::{
27 NoteReaderSkeleton, NotebookSkeleton, NotesPageSkeleton,
28};
29use crate::components::ui::{
30 use_sheet_close, BottomSheet, Button, ButtonVariant, Icon, IconName, LoadError,
31};
32use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
33use crate::models::note::*;
34use crate::models::public_note::PublicNote;
35use crate::pages::not_found::{NotFound, PageLoadFailed};
36use crate::routes::Route;
37use crate::utils::error::is_not_found;
38use crate::utils::time::{hm, md, sleep_ms, ymd};
39
40const PAGE_SIZE: i64 = 20;
42const FILTER_DEBOUNCE_MS: u32 = 300;
44const SIDE_TAGS: usize = 8;
46
47const KINDS: [(Option<NoteKind>, &str); 3] = [
49 (None, "全部"),
50 (Some(NoteKind::Moment), "随记"),
51 (Some(NoteKind::Topic), "主题笔记"),
52];
53
54fn note_time(note: &Note) -> DateTime<Utc> {
55 note.updated_at
56}
57
58fn note_route(slug: &str, book: Option<i32>) -> Route {
60 Route::NoteDetail {
61 slug: slug.to_string(),
62 book: book.map(|id| id.to_string()).unwrap_or_default(),
63 }
64}
65
66#[derive(Clone, Debug, PartialEq)]
68struct DayGroup {
69 date: NaiveDate,
70 notes: Vec<Note>,
71}
72
73impl DayGroup {
74 fn key(&self) -> (NaiveDate, Vec<(i32, i32)>) {
76 (
77 self.date,
78 self.notes.iter().map(|n| (n.id, n.version)).collect(),
79 )
80 }
81}
82
83fn group_by_day(notes: &[Note]) -> Vec<DayGroup> {
84 let mut groups: Vec<DayGroup> = Vec::new();
85 for note in notes {
86 let date = note_time(note).date_naive();
87 match groups.last_mut() {
88 Some(group) if group.date == date => group.notes.push(note.clone()),
89 _ => groups.push(DayGroup {
90 date,
91 notes: vec![note.clone()],
92 }),
93 }
94 }
95 groups
96}
97
98fn relative_day(date: NaiveDate, today: NaiveDate) -> String {
100 const WEEKDAYS: [&str; 7] = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
101 match (today - date).num_days() {
102 0 => "今天".to_string(),
103 1 => "昨天".to_string(),
104 2..=6 => WEEKDAYS[date.weekday().num_days_from_monday() as usize].to_string(),
105 _ if date.year() != today.year() => date.year().to_string(),
106 _ => String::new(),
107 }
108}
109
110fn use_today() -> RwSignal<Option<NaiveDate>> {
112 let today = RwSignal::new(None::<NaiveDate>);
113 Effect::new(move |_| {
114 let now = DateTime::<Utc>::from_timestamp_millis(crate::utils::time::now_millis());
115 today.set(now.map(|t| t.date_naive()));
116 });
117 today
118}
119
120fn notebook_title(books: &[Notebook], note: &Note) -> Option<(i32, String)> {
121 note.notebook_ids
122 .iter()
123 .find_map(|id| books.iter().find(|b| b.id == *id))
124 .map(|b| (b.id, b.title.clone()))
125}
126
127fn first_filter() -> NoteFilter {
129 NoteFilter {
130 page: 1,
131 ..Default::default()
132 }
133}
134
135pub struct NotesRoute {
139 books: RouteData<Vec<Notebook>>,
140 stats: RouteData<NoteStats>,
141 first: RouteData<NotePage>,
142}
143
144#[leptos::lazy]
148async fn fetch_notebooks() -> PageJson<Vec<Notebook>> {
149 PageJson::new(list_notebooks().await)
150}
151
152#[leptos::lazy]
153async fn fetch_note_stats() -> PageJson<NoteStats> {
154 PageJson::new(note_stats().await)
155}
156
157#[leptos::lazy]
158async fn fetch_note_page(filter: NoteFilter) -> PageJson<NotePage> {
159 PageJson::new(list_notes(filter).await)
160}
161
162fn first_note_page() -> impl Future<Output = PageJson<NotePage>> + Send + 'static {
164 fetch_note_page(first_filter())
165}
166
167#[lazy_route]
168impl LazyRoute for NotesRoute {
169 fn data() -> Self {
170 Self {
171 books: route_data(|| (), unit(fetch_notebooks)),
172 stats: route_data(|| (), unit(fetch_note_stats)),
173 first: route_data(|| (), unit(first_note_page)),
174 }
175 }
176
177 fn view(this: Self) -> AnyView {
178 let NotesRoute {
179 books,
180 stats,
181 first,
182 } = this;
183 view! {
184 <Title text="笔记 · Yggdrasil"/>
185 <div class="notes">
186 <Suspense fallback=|| {
187 view! {
188 <DelayedSkeleton>
189 <NotesPageSkeleton/>
190 </DelayedSkeleton>
191 }
192 }>
193 {move || Suspend::new(async move {
194 let books = books.await.unwrap_or_default();
196 let stats = stats.await.ok();
197 let first = first.await;
198 view! { <NotesBody books stats first/> }
199 })}
200 </Suspense>
201 </div>
202 }
203 .into_any()
204 }
205}
206
207impl NotesRoute {
208 pub fn prefetch_data() {
210 prefetch_route_data((), unit(fetch_notebooks));
211 prefetch_route_data((), unit(fetch_note_stats));
212 prefetch_route_data((), unit(first_note_page));
213 }
214}
215
216#[derive(Clone)]
218struct ShownNotes {
219 filter: NoteFilter,
220 result: Result<NotePage, ApiError>,
221}
222
223fn use_note_page(
229 filter: RwSignal<NoteFilter>,
230 first: Result<NotePage, ApiError>,
231) -> (RwSignal<ShownNotes>, Callback<()>, RwSignal<bool>) {
232 let first_filter = first_filter();
233 let shown = RwSignal::new(ShownNotes {
234 filter: first_filter.clone(),
235 result: first.clone(),
236 });
237 let retry = RwSignal::new(0_u32);
238 let retrying = RwSignal::new(false);
239 let generation = StoredValue::new(0_u64);
241
242 Effect::new(move |previous: Option<u32>| {
243 let wanted = filter.get();
244 let nonce = retry.get();
245 let retried = previous.is_some_and(|p| p != nonce);
246 let id = generation.try_update_value(|g| {
247 *g += 1;
248 *g
249 });
250 let Some(id) = id else { return nonce };
251
252 if !retried {
253 if shown.with_untracked(|s| s.filter == wanted) {
254 return nonce;
255 }
256 if wanted == first_filter && first.is_ok() {
258 shown.set(ShownNotes {
259 filter: first_filter.clone(),
260 result: first.clone(),
261 });
262 return nonce;
263 }
264 }
265 if retried {
266 retrying.set(true);
267 }
268 spawn_local(async move {
269 let result = list_notes(wanted.clone()).await;
270 if generation.try_get_value() != Some(id) {
272 return;
273 }
274 let _ = shown.try_set(ShownNotes {
275 filter: wanted,
276 result,
277 });
278 let _ = retrying.try_set(false);
279 });
280 nonce
281 });
282
283 let on_retry = Callback::new(move |()| retry.update(|n| *n += 1));
284 (shown, on_retry, retrying)
285}
286
287#[derive(Clone, Default, PartialEq)]
289struct OlderPages {
290 filter: NoteFilter,
291 last_page: i32,
292 notes: Vec<Note>,
293}
294
295#[component]
296fn NotesBody(
297 books: Vec<Notebook>,
298 stats: Option<NoteStats>,
299 first: Result<NotePage, ApiError>,
300) -> impl IntoView {
301 let entry_id = crate::bridges::navigation::entry_id();
302 let filter = RwSignal::new(first_filter());
304 let restored = RwSignal::new(false);
306 Effect::new(move |_| {
307 if let Some(mut saved) = crate::bridges::navigation::read_state::<NoteFilter>("notes") {
308 saved.page = 1;
309 if filter.with_untracked(|f| *f != saved) {
310 filter.set(saved);
311 }
312 }
313 restored.set(true);
314 });
315 Effect::new(move |_| {
316 if restored.get() {
317 crate::bridges::navigation::write_state(&entry_id, "notes", &filter.get());
318 }
319 });
320 let (shown, on_retry, retrying) = use_note_page(filter, first);
321
322 let total_text = stats
323 .as_ref()
324 .map(|stats| format!("{} 条 · {} 个笔记本", stats.total(), books.len()));
325
326 view! {
327 <header class="notes-header page-enter">
328 <div>
329 <h1 class="notes-title">"笔记"</h1>
330 <p class="notes-intro">"随手记下的发现,和反复琢磨后成形的主题。"</p>
331 </div>
332 {total_text.map(|text| view! { <p class="notes-count tabular-nums">{text}</p> })}
333 </header>
334 <div class="notes-layout">
335 <NotesSide filter books=books.clone() stats/>
336 <NoteStream filter shown books on_retry retrying/>
337 </div>
338 }
339}
340
341#[component]
343fn NotesSide(
344 filter: RwSignal<NoteFilter>,
345 books: Vec<Notebook>,
346 stats: Option<NoteStats>,
347) -> impl IntoView {
348 let query = RwSignal::new(filter.get_untracked().query);
349 let composing = RwSignal::new(false);
350 let request = RwSignal::new(0_u64);
351 Effect::new(move |_| {
353 let current = filter.with(|f| f.query.clone());
354 if query.with_untracked(|q| q.trim() != current) {
355 query.set(current);
356 }
357 });
358 let apply_query = move || {
359 request.update(|r| *r += 1);
360 let id = request.get_untracked();
361 if composing.get_untracked() {
362 return;
363 }
364 spawn_local(async move {
365 sleep_ms(FILTER_DEBOUNCE_MS).await;
366 if request.try_get_untracked() != Some(id) {
367 return;
368 }
369 let q = query.get_untracked().trim().to_string();
370 if filter.with_untracked(|f| f.query != q) {
371 filter.update(|f| f.query = q);
372 }
373 });
374 };
375
376 let count = |kind: Option<NoteKind>| {
377 stats.as_ref().map(|s| match kind {
378 None => s.total(),
379 Some(NoteKind::Moment) => s.moments,
380 Some(NoteKind::Topic) => s.topics,
381 })
382 };
383 let kinds = KINDS
384 .into_iter()
385 .map(|(kind, label)| {
386 let count = count(kind);
387 view! {
388 <button
389 type="button"
390 class="notes-side-item"
391 aria-pressed=move || filter.with(|f| f.kind == kind).to_string()
392 on:click=move |_| {
393 if filter.with_untracked(|f| f.kind != kind) {
394 filter.update(|f| f.kind = kind);
395 }
396 }
397 >
398 <span>{label}</span>
399 {count
400 .map(|n| {
401 view! { <span class="notes-side-count tabular-nums">{n}</span> }
402 })}
403 </button>
404 }
405 })
406 .collect_view();
407 let tags = stats
408 .as_ref()
409 .map(|s| s.tags.iter().take(SIDE_TAGS).cloned().collect::<Vec<_>>())
410 .unwrap_or_default();
411
412 let books_group = (!books.is_empty()).then(|| {
413 view! {
414 <nav class="notes-side-group notes-side-extra" aria-label="笔记本">
415 <p class="notes-side-label">"笔记本"</p>
416 {books
417 .into_iter()
418 .map(|book| {
419 view! {
420 <a
421 class="notes-side-item"
422 href=Route::NotebookDetail { id: book.id }.to_string()
423 >
424 <Icon name=IconName::BookOpen size=15 class="notes-side-icon"/>
425 <span class="notes-side-text">{book.title}</span>
426 <span class="notes-side-count tabular-nums">{book.note_count}</span>
427 </a>
428 }
429 })
430 .collect_view()}
431 </nav>
432 }
433 });
434 let tags_group = (!tags.is_empty()).then(|| {
435 view! {
436 <div class="notes-side-group notes-side-extra">
437 <p class="notes-side-label" id="notes-tags-label">
438 "标签"
439 </p>
440 <div class="notes-side-tags" role="group" aria-labelledby="notes-tags-label">
441 {tags
442 .into_iter()
443 .map(|(tag, n)| {
444 let pressed_tag = tag.clone();
445 let click_tag = tag.clone();
446 view! {
447 <button
448 type="button"
449 class="chip"
450 aria-pressed=move || {
451 filter.with(|f| f.tag == pressed_tag).to_string()
452 }
453 title=format!("{n} 条")
454 on:click=move |_| {
455 let next = if filter.with_untracked(|f| f.tag == click_tag) {
456 String::new()
457 } else {
458 click_tag.clone()
459 };
460 filter.update(|f| f.tag = next);
461 }
462 >
463 {tag}
464 </button>
465 }
466 })
467 .collect_view()}
468 </div>
469 </div>
470 }
471 });
472
473 view! {
474 <aside class="notes-side page-enter" style="--enter-index: 1" aria-label="笔记筛选">
475 <form
476 class="notes-filter"
477 role="search"
478 on:submit=move |event| {
479 event.prevent_default();
480 request.update(|r| *r += 1);
481 let q = query.get_untracked().trim().to_string();
482 filter.update(|f| f.query = q);
483 }
484 >
485 <Icon name=IconName::Search size=14/>
486 <label class="sr-only" for="notes-filter-input">
487 "筛选笔记"
488 </label>
489 <input
490 id="notes-filter-input"
491 type="search"
492 placeholder="筛选笔记"
493 autocomplete="off"
494 maxlength="200"
495 value=query.get_untracked()
496 prop:value=move || query.get()
497 on:input=move |event| {
498 query.set(event_target_value(&event));
499 apply_query();
500 }
501 on:compositionstart=move |_| composing.set(true)
502 on:compositionend=move |_| {
503 composing.set(false);
504 apply_query();
505 }
506 />
507 </form>
508 <nav class="notes-side-group" aria-label="笔记类型">
509 <p class="notes-side-label">"类型"</p>
510 <div class="notes-kinds">{kinds}</div>
511 </nav>
512 {books_group}
513 {tags_group}
514 </aside>
515 }
516}
517
518#[component]
520fn NoteStream(
521 filter: RwSignal<NoteFilter>,
522 shown: RwSignal<ShownNotes>,
523 books: Vec<Notebook>,
524 on_retry: Callback<()>,
525 retrying: RwSignal<bool>,
526) -> impl IntoView {
527 let today = use_today();
528 let older = RwSignal::new(OlderPages::default());
529 let loading_more = RwSignal::new(false);
530 let more_failed = RwSignal::new(false);
531 let loading = move || shown.with(|s| s.filter != filter.get());
532 let books = std::sync::Arc::new(books);
533
534 let body = move || {
535 let ShownNotes {
536 filter: shown_filter,
537 result,
538 } = shown.get();
539 match result {
540 Ok(page) => {
541 let filtered = !shown_filter.query.is_empty()
542 || shown_filter.kind.is_some()
543 || !shown_filter.tag.is_empty();
544 let total = page.total;
545 let all = Memo::new({
547 let shown_filter = shown_filter.clone();
548 move |_| {
549 let mut all = page.notes.clone();
550 older.with(|extra| {
551 if extra.filter == shown_filter {
552 all.extend(extra.notes.iter().cloned());
553 }
554 });
555 all
556 }
557 });
558 let empty = Memo::new(move |_| all.with(|all| all.is_empty()));
559 let groups = Memo::new(move |_| all.with(|all| group_by_day(all)));
560 let loaded_pages = {
561 let shown_filter = shown_filter.clone();
562 move || {
563 older.with(|extra| {
564 if extra.filter == shown_filter {
565 extra.last_page
566 } else {
567 1
568 }
569 })
570 }
571 };
572 let has_more = {
573 let loaded_pages = loaded_pages.clone();
574 move || i64::from(loaded_pages()) * PAGE_SIZE < total
575 };
576 let books = books.clone();
577 let active_tag = {
578 let tag = shown_filter.tag.clone();
579 Signal::derive(move || tag.clone())
580 };
581 let on_more = {
582 let base = shown_filter.clone();
583 move |_| {
584 let base = base.clone();
585 let page = loaded_pages() + 1;
586 loading_more.set(true);
587 more_failed.set(false);
588 spawn_local(async move {
589 let result = list_notes(NoteFilter {
590 page,
591 ..base.clone()
592 })
593 .await;
594 let _ = loading_more.try_set(false);
595 match result {
596 Ok(next) => {
597 let _ = older.try_update(|o| {
598 if o.filter != base {
599 *o = OlderPages {
600 filter: base.clone(),
601 last_page: 1,
602 notes: Vec::new(),
603 };
604 }
605 o.last_page = page;
606 o.notes.extend(next.notes);
607 });
608 }
609 Err(_) => {
610 let _ = more_failed.try_set(true);
611 }
612 }
613 });
614 }
615 };
616
617 (move || {
618 if empty.get() {
619 if filtered {
620 view! {
621 <EmptyState
622 icon=IconName::SearchX
623 title="没有找到相关笔记"
624 description="换个关键词或类型,或者清除筛选看看全部。"
625 action=EmptyStateAction::new(
626 "清除筛选",
627 move |_| filter.set(first_filter()),
628 )
629 />
630 }
631 .into_any()
632 } else {
633 view! {
634 <EmptyState
635 mascot=Mascot::Doze
636 title="还没有公开的笔记"
637 description="随手记下的发现会按天出现在这里。"
638 />
639 }
640 .into_any()
641 }
642 } else {
643 let books = books.clone();
644 let on_more = on_more.clone();
645 let has_more = has_more.clone();
646 view! {
647 <For
648 each=move || groups.get()
649 key=|group| group.key()
650 children=move |group| {
651 view! {
652 <DaySection
653 group
654 books=books.clone()
655 today
656 active_tag
657 filter
658 />
659 }
660 }
661 />
662 <Show when=has_more.clone()>
663 <div class="notes-more">
664 <Button
665 variant=ButtonVariant::Secondary
666 loading=loading_more
667 onclick=on_more.clone()
668 >
669 "更早的记录"
670 </Button>
671 <Show when=move || more_failed.get()>
672 <p class="notes-more-error" role="alert">
673 "没能加载更早的记录,再试一次。"
674 </p>
675 </Show>
676 </div>
677 </Show>
678 }
679 .into_any()
680 }
681 })
682 .into_any()
683 }
684 Err(_) => view! {
685 <LoadError
686 title="笔记暂时没能加载"
687 retrying=Signal::derive(move || loading() || retrying.get())
688 on_retry=move |_| on_retry.run(())
689 />
690 }
691 .into_any(),
692 }
693 };
694
695 view! {
696 <section
697 class="notes-stream page-enter"
698 style="--enter-index: 2"
699 aria-label="笔记列表"
700 aria-busy=move || loading().to_string()
701 >
702 {body}
703 </section>
704 }
705}
706
707#[component]
709fn DaySection(
710 group: DayGroup,
711 books: std::sync::Arc<Vec<Notebook>>,
712 today: RwSignal<Option<NaiveDate>>,
713 #[prop(into)] active_tag: Signal<String>,
714 filter: RwSignal<NoteFilter>,
715) -> impl IntoView {
716 let date = group.date;
717 let items = group
718 .notes
719 .into_iter()
720 .map(|note| {
721 if note.kind == NoteKind::Topic {
722 let book = notebook_title(&books, ¬e);
723 view! { <TopicCard note book/> }.into_any()
724 } else {
725 view! {
726 <MomentEntry
727 note
728 active_tag
729 on_tag=move |tag: String| filter.update(|f| f.tag = tag)
730 />
731 }
732 .into_any()
733 }
734 })
735 .collect_view();
736 view! {
737 <section class="notes-day" aria-label=date.to_string()>
738 <div class="notes-day-head">
739 <time class="notes-day-date tabular-nums" datetime=date.to_string()>
740 {md(&date, '.')}
741 </time>
742 {move || {
743 today
744 .get()
745 .map(|today| {
746 view! { <span class="notes-day-rel">{relative_day(date, today)}</span> }
747 })
748 }}
749 </div>
750 {items}
751 </section>
752 }
753}
754
755#[component]
757fn MomentEntry(
758 note: Note,
759 #[prop(into)] active_tag: Signal<String>,
760 #[prop(into)] on_tag: Callback<String>,
761) -> impl IntoView {
762 let time = hm(¬e_time(¬e));
763 let book = note.notebook_ids.first().copied();
764 let href = note_route(¬e.slug, book).to_string();
765 let body = if note.content_html.is_empty() {
766 view! {
767 <p class="note-moment-summary">{note.summary.clone()}</p>
768 <a class="note-moment-more" href=href.clone()>
769 "继续阅读"
770 </a>
771 }
772 .into_any()
773 } else {
774 view! {
775 <div class="note-moment-body">
776 <PostContent content_html=note.content_html.clone()/>
777 </div>
778 }
779 .into_any()
780 };
781 let tags = note
782 .tags
783 .iter()
784 .map(|tag| {
785 let pressed = tag.clone();
786 let picked = tag.clone();
787 view! {
788 <button
789 type="button"
790 class="note-moment-tag"
791 aria-pressed=move || (active_tag.get() == pressed).to_string()
792 on:click=move |_| on_tag.run(picked.clone())
793 >
794 {format!("#{tag}")}
795 </button>
796 }
797 })
798 .collect_view();
799 view! {
800 <article class="note-moment">
801 <time class="note-moment-time tabular-nums" datetime=note_time(¬e).to_rfc3339()>
802 {time}
803 </time>
804 <div class="note-moment-main">
805 {(!note.title.trim().is_empty())
806 .then(|| view! { <h2 class="note-moment-title">{note.title.clone()}</h2> })}
807 {body} <div class="note-moment-foot">
808 {tags}
809 <a class="note-moment-link" href=href aria-label="打开这条随记">
810 <Icon name=IconName::Link size=14/>
811 "链接"
812 </a>
813 </div>
814 </div>
815 </article>
816 }
817}
818
819#[component]
821fn TopicCard(note: Note, book: Option<(i32, String)>) -> impl IntoView {
822 let updated = md(¬e_time(¬e), '.');
823 let meta = if note.sections > 0 {
824 format!("{} 节 · 更新于 {updated}", note.sections)
825 } else {
826 format!("更新于 {updated}")
827 };
828 let href = note_route(¬e.slug, book.as_ref().map(|(id, _)| *id)).to_string();
829 let book_label = book.map(|(_, title)| {
830 view! {
831 <span class="note-topic-book">
832 <Icon name=IconName::BookOpen size=13/>
833 {title}
834 </span>
835 }
836 });
837 let summary = (!note.summary.trim().is_empty())
838 .then(|| view! { <p class="note-topic-summary">{note.summary.clone()}</p> });
839 view! {
840 <article class="note-topic">
841 <div class="note-topic-top">
842 <span class="badge">"主题笔记"</span>
843 {book_label}
844 </div>
845 <h2 class="note-topic-title">
846 <a class="note-topic-link" href=href>
847 {note.display_title()}
848 </a>
849 </h2>
850 {summary} <div class="note-topic-foot">
851 <span class="tabular-nums">{meta}</span>
852 <span class="note-topic-arrow" aria-hidden="true">
853 <Icon name=IconName::ArrowRight size=15/>
854 </span>
855 </div>
856 </article>
857 }
858}
859
860pub struct NotebookRoute {
864 id: Memo<Option<i32>>,
865 books: RouteData<Vec<Notebook>>,
866 notes: RouteData<NotePage>,
867}
868
869fn notebook_id(raw: Option<&str>) -> Option<i32> {
871 raw.and_then(|id| id.parse::<i32>().ok())
872}
873
874fn notebook_books(
876 _id: Option<i32>,
877) -> impl Future<Output = PageJson<Vec<Notebook>>> + Send + 'static {
878 fetch_notebooks()
879}
880
881fn notebook_notes(id: Option<i32>) -> impl Future<Output = PageJson<NotePage>> + Send + 'static {
883 fetch_note_page(NoteFilter {
884 notebook_id: id,
885 page: 1,
886 ..Default::default()
887 })
888}
889
890#[lazy_route]
891impl LazyRoute for NotebookRoute {
892 fn data() -> Self {
893 let params = use_params_map();
894 let id = Memo::new(move |_| notebook_id(params.read().get("id").as_deref()));
895 Self {
896 id,
897 books: route_data(move || id.get(), notebook_books),
898 notes: route_data(move || id.get(), notebook_notes),
899 }
900 }
901
902 fn view(this: Self) -> AnyView {
903 let NotebookRoute { id, books, notes } = this;
904 view! {
905 <Suspense fallback=|| {
906 view! {
907 <div class="notebook">
908 <DelayedSkeleton>
909 <NotebookSkeleton/>
910 </DelayedSkeleton>
911 </div>
912 }
913 }>
914 {move || Suspend::new(async move {
915 let books = books.await;
916 let notes = notes.await;
917 match id.get_untracked() {
918 Some(id) => {
919 view! {
920 <div class="notebook">
921 <NotebookPage id books notes/>
922 </div>
923 }
924 .into_any()
925 }
926 None => view! { <NotFound/> }.into_any(),
927 }
928 })}
929 </Suspense>
930 }
931 .into_any()
932 }
933}
934
935impl NotebookRoute {
936 pub fn prefetch_data(id: &str) {
938 let id = notebook_id(Some(id));
939 prefetch_route_data(id, notebook_books);
940 prefetch_route_data(id, notebook_notes);
941 }
942}
943
944#[component]
947fn NotebookPage(
948 id: i32,
949 books: Result<Vec<Notebook>, ApiError>,
950 notes: Result<NotePage, ApiError>,
951) -> impl IntoView {
952 let books = RwSignal::new(books);
953 let notes = RwSignal::new(notes);
954 let books_retrying = RwSignal::new(false);
955 let retry_books = move |_| {
956 books_retrying.set(true);
957 spawn_local(async move {
958 let result = list_notebooks().await;
959 let _ = books.try_set(result);
960 let _ = books_retrying.try_set(false);
961 });
962 };
963
964 move || match books.get() {
965 Err(_) => view! {
966 <Title text="笔记本暂时没能加载 · Yggdrasil"/>
967 <NotebookBack/>
968 <LoadError
969 title="笔记本暂时没能加载"
970 retrying=books_retrying
971 on_retry=retry_books
972 />
973 }
974 .into_any(),
975 Ok(books) => match books.iter().find(|b| b.id == id).cloned() {
976 None => view! {
977 <Title text="笔记本不存在 · Yggdrasil"/>
978 <NotebookBack/>
979 <EmptyState
980 icon=IconName::BookOpen
981 title="笔记本不存在"
982 description="这个笔记本尚未公开、已归档,或已经被移除。"
983 />
984 }
985 .into_any(),
986 Some(book) => {
987 let others: Vec<Notebook> = books.iter().filter(|b| b.id != id).cloned().collect();
988 view! { <NotebookContent id book others notes/> }.into_any()
989 }
990 },
991 }
992}
993
994#[component]
995fn NotebookContent(
996 id: i32,
997 book: Notebook,
998 others: Vec<Notebook>,
999 notes: RwSignal<Result<NotePage, ApiError>>,
1000) -> impl IntoView {
1001 let older = RwSignal::new(Vec::<Note>::new());
1002 let next_page = RwSignal::new(2_i32);
1003 let loading_more = RwSignal::new(false);
1004 let more_failed = RwSignal::new(false);
1005 let notes_retrying = RwSignal::new(false);
1006
1007 let first_note = move || {
1008 notes.with(|notes| {
1009 notes
1010 .as_ref()
1011 .ok()
1012 .and_then(|page| page.notes.first())
1013 .map(|first| note_route(&first.slug, Some(id)).to_string())
1014 })
1015 };
1016 let on_more = move |_| {
1017 loading_more.set(true);
1018 more_failed.set(false);
1019 spawn_local(async move {
1020 let filter = NoteFilter {
1021 notebook_id: Some(id),
1022 page: next_page.get_untracked(),
1023 ..Default::default()
1024 };
1025 match list_notes(filter).await {
1026 Ok(next) => {
1027 let _ = older.try_update(|o| o.extend(next.notes));
1028 let _ = next_page.try_update(|p| *p += 1);
1029 }
1030 Err(_) => {
1031 let _ = more_failed.try_set(true);
1032 }
1033 }
1034 let _ = loading_more.try_set(false);
1035 });
1036 };
1037 let retry_notes = move |_| {
1038 notes_retrying.set(true);
1039 spawn_local(async move {
1040 let filter = NoteFilter {
1041 notebook_id: Some(id),
1042 page: 1,
1043 ..Default::default()
1044 };
1045 let result = list_notes(filter).await;
1046 let _ = notes.try_set(result);
1047 let _ = notes_retrying.try_set(false);
1048 });
1049 };
1050
1051 let entries = Memo::new(move |_| {
1053 notes.with(|notes| match notes {
1054 Ok(page) => {
1055 let mut all = page.notes.clone();
1056 older.with(|extra| all.extend(extra.iter().cloned()));
1057 all.into_iter().enumerate().collect::<Vec<_>>()
1058 }
1059 Err(_) => Vec::new(),
1060 })
1061 });
1062 let has_more = move || {
1063 notes.with(|notes| {
1064 notes
1065 .as_ref()
1066 .is_ok_and(|page| (entries.with(|e| e.len()) as i64) < page.total)
1067 })
1068 };
1069 let state = Memo::new(move |_| {
1070 notes.with(|notes| match notes {
1071 Ok(page) if !page.notes.is_empty() => 1_u8,
1072 Ok(_) => 2,
1073 Err(_) => 0,
1074 })
1075 });
1076
1077 let description = (!book.description.trim().is_empty())
1078 .then(|| view! { <p class="notebook-description">{book.description.clone()}</p> });
1079 let others_section = (!others.is_empty()).then(|| {
1080 view! {
1081 <section class="notebook-others" aria-labelledby="notebook-others-title">
1082 <h2 id="notebook-others-title" class="section-label">
1083 "其他笔记本"
1084 </h2>
1085 <div class="notebook-others-grid">
1086 {others
1087 .into_iter()
1088 .map(|other| {
1089 view! {
1090 <a
1091 class="notebook-other"
1092 href=Route::NotebookDetail { id: other.id }.to_string()
1093 >
1094 <span class="notebook-other-title">{other.title}</span>
1095 <span class="notebook-other-count tabular-nums">
1096 {format!("{} 篇", other.note_count)}
1097 </span>
1098 </a>
1099 }
1100 })
1101 .collect_view()}
1102 </div>
1103 </section>
1104 }
1105 });
1106
1107 view! {
1108 <Title text=format!("{} · 笔记本", book.title)/>
1109 <NotebookBack/>
1110 <header class="notebook-header page-enter">
1111 <p class="notebook-label">
1112 <Icon name=IconName::BookOpen size=14/>
1113 "笔记本"
1114 </p>
1115 <h1 class="notebook-title">{book.title.clone()}</h1>
1116 {description}
1117 <p class="notebook-meta tabular-nums">
1118 <span>{format!("{} 篇", book.note_count)}</span>
1119 <span>"按阅读顺序编排"</span>
1120 <span>{format!("更新于 {}", md(&book.updated_at, '.'))}</span>
1121 </p>
1122 {move || {
1123 first_note()
1124 .map(|href| {
1125 view! {
1126 <a class="btn btn-primary btn-lg notebook-start" href=href>
1127 "从第一篇读起"
1128 <Icon name=IconName::ArrowRight size=16/>
1129 </a>
1130 }
1131 })
1132 }}
1133 </header>
1134 {move || match state.get() {
1135 1 => {
1136 view! {
1137 <ol class="notebook-entries page-enter" style="--enter-index: 1">
1138 <For
1139 each=move || entries.get()
1140 key=|(index, note)| (*index, note.id)
1141 children=move |(index, note)| {
1142 view! {
1143 <li>
1144 <NotebookEntry note number=index + 1 book=id/>
1145 </li>
1146 }
1147 }
1148 />
1149 </ol>
1150 <Show when=has_more>
1151 <div class="notes-more">
1152 <Button
1153 variant=ButtonVariant::Secondary
1154 loading=loading_more
1155 onclick=on_more
1156 >
1157 "加载更多"
1158 </Button>
1159 <Show when=move || more_failed.get()>
1160 <p class="notes-more-error" role="alert">
1161 "没能加载更多笔记,再试一次。"
1162 </p>
1163 </Show>
1164 </div>
1165 </Show>
1166 <p class="notebook-note">"只列出已发布的笔记。"</p>
1167 }
1168 .into_any()
1169 }
1170 2 => {
1171 view! {
1172 <EmptyState
1173 mascot=Mascot::Doze
1174 title="这个笔记本还是空的"
1175 description="发布的笔记加入后会按顺序列在这里。"
1176 />
1177 }
1178 .into_any()
1179 }
1180 _ => {
1181 view! {
1182 <LoadError
1183 title="目录暂时没能加载"
1184 retrying=notes_retrying
1185 on_retry=retry_notes
1186 />
1187 }
1188 .into_any()
1189 }
1190 }}
1191 {others_section}
1192 }
1193}
1194
1195#[component]
1196fn NotebookBack() -> impl IntoView {
1197 view! {
1198 <a class="notes-back" href=Route::Notes.to_string()>
1199 <Icon name=IconName::ArrowLeft size=14/>
1200 "全部笔记"
1201 </a>
1202 }
1203}
1204
1205#[component]
1206fn NotebookEntry(note: Note, number: usize, book: i32) -> impl IntoView {
1207 let updated = md(¬e_time(¬e), '.');
1208 let meta = match note.kind {
1209 NoteKind::Topic if note.sections > 0 => {
1210 format!("主题笔记 · {} 节 · 更新于 {updated}", note.sections)
1211 }
1212 NoteKind::Topic => format!("主题笔记 · 更新于 {updated}"),
1213 NoteKind::Moment => format!("随记 · {updated}"),
1214 };
1215 let summary = (!note.summary.trim().is_empty() && !note.title.trim().is_empty())
1216 .then(|| view! { <span class="notebook-entry-summary">{note.summary.clone()}</span> });
1217 view! {
1218 <a class="notebook-entry" href=note_route(¬e.slug, Some(book)).to_string()>
1219 <span class="notebook-entry-number tabular-nums" aria-hidden="true">
1220 {format!("{number:02}")}
1221 </span>
1222 <span class="notebook-entry-main">
1223 <span class="notebook-entry-title">{note.display_title()}</span>
1224 {summary} <span class="notebook-entry-meta tabular-nums">{meta}</span>
1225 </span>
1226 <span class="notebook-entry-arrow" aria-hidden="true">
1227 <Icon name=IconName::ArrowRight size=15/>
1228 </span>
1229 </a>
1230 }
1231}
1232
1233pub struct NoteRoute {
1238 note: RouteData<PublicNote>,
1239 context: RouteData<Option<NoteContext>>,
1240}
1241
1242#[leptos::lazy]
1243async fn fetch_note(slug: String) -> PageJson<PublicNote> {
1244 PageJson::new(get_note(slug).await)
1245}
1246
1247#[leptos::lazy]
1248async fn fetch_note_context(slug: String, book: Option<i32>) -> PageJson<Option<NoteContext>> {
1249 PageJson::new(get_note_context(slug, book).await)
1250}
1251
1252fn book_param(raw: Option<&str>) -> Option<i32> {
1254 raw.and_then(|book| book.parse::<i32>().ok())
1255}
1256
1257fn note_context(
1259 (slug, book): (String, Option<i32>),
1260) -> impl Future<Output = PageJson<Option<NoteContext>>> + Send + 'static {
1261 fetch_note_context(slug, book)
1262}
1263
1264#[lazy_route]
1265impl LazyRoute for NoteRoute {
1266 fn data() -> Self {
1267 let params = use_params_map();
1268 let query = use_query_map();
1269 let slug = Memo::new(move |_| params.read().get("slug").unwrap_or_default());
1270 let book = Memo::new(move |_| book_param(query.read().get("book").as_deref()));
1271 Self {
1272 note: route_data(move || slug.get(), fetch_note),
1273 context: route_data(move || (slug.get(), book.get()), note_context),
1274 }
1275 }
1276
1277 fn view(this: Self) -> AnyView {
1278 let NoteRoute { note, context } = this;
1279 view! {
1280 <Suspense fallback=|| {
1281 view! {
1282 <div class="notes">
1283 <DelayedSkeleton>
1284 <NoteReaderSkeleton/>
1285 </DelayedSkeleton>
1286 </div>
1287 }
1288 }>
1289 {move || Suspend::new(async move {
1290 let note = note.await;
1291 let context = context.await.ok().flatten();
1293 match note {
1294 Ok(note) => {
1295 view! {
1296 <div class="notes">
1297 <div class="notes-layout note-reader">
1298 <NoteRail note=note.clone() context=context.clone()/>
1299 <NoteDocument note context/>
1300 </div>
1301 </div>
1302 }
1303 .into_any()
1304 }
1305 Err(err) if is_not_found(&err) => view! { <NotFound/> }.into_any(),
1306 Err(_) => view! { <PageLoadFailed/> }.into_any(),
1307 }
1308 })}
1309 </Suspense>
1310 }
1311 .into_any()
1312 }
1313}
1314
1315impl NoteRoute {
1316 pub fn prefetch_data(slug: String, book: Option<&str>) {
1318 prefetch_route_data(slug.clone(), fetch_note);
1319 prefetch_route_data((slug, book_param(book)), note_context);
1320 }
1321}
1322
1323#[component]
1326fn NoteRail(note: PublicNote, context: Option<NoteContext>) -> impl IntoView {
1327 let body = if let Some(context) = context {
1328 let description = (!context.notebook.description.trim().is_empty()).then(|| {
1329 view! { <p class="note-rail-book-description">{context.notebook.description.clone()}</p> }
1330 });
1331 view! {
1332 <div class="note-rail-book">
1333 <p class="notes-side-label">"笔记本"</p>
1334 <a
1335 class="note-rail-book-title"
1336 href=Route::NotebookDetail { id: context.notebook.id }.to_string()
1337 >
1338 {context.notebook.title.clone()}
1339 </a>
1340 {description}
1341 </div>
1342 <NotebookDirectory
1343 context
1344 current=note.slug.clone()
1345 toc_html=note.toc_html.clone()
1346 spy=true
1347 />
1348 }
1349 .into_any()
1350 } else if !note.toc_html.is_empty() {
1351 view! {
1352 <div class="note-rail-book">
1353 <p class="notes-side-label">"本篇"</p>
1354 </div>
1355 <NoteSections toc_html=note.toc_html.clone() spy=true/>
1356 }
1357 .into_any()
1358 } else {
1359 ().into_any()
1360 };
1361 view! {
1362 <aside class="notes-side note-rail" aria-label="笔记本目录">
1363 <NotebookBack/>
1364 {body}
1365 </aside>
1366 }
1367}
1368
1369#[component]
1371fn NotebookDirectory(
1372 context: NoteContext,
1373 current: String,
1374 toc_html: String,
1375 spy: bool,
1376) -> impl IntoView {
1377 let book = context.notebook.id;
1378 let items = context
1379 .siblings
1380 .iter()
1381 .enumerate()
1382 .map(|(index, item)| {
1383 let is_current = item.slug == current;
1384 let sections = (is_current && !toc_html.is_empty())
1385 .then(|| view! { <NoteSections toc_html=toc_html.clone() spy/> });
1386 view! {
1387 <li>
1388 <a
1389 class="note-directory-item"
1390 href=note_route(&item.slug, Some(book)).to_string()
1391 aria-current=is_current.then_some("page")
1392 >
1393 <span class="note-directory-number tabular-nums" aria-hidden="true">
1394 {format!("{:02}", index + 1)}
1395 </span>
1396 <span>{item.title.clone()}</span>
1397 </a>
1398 {sections}
1399 </li>
1400 }
1401 })
1402 .collect_view();
1403 view! { <ol class="note-directory">{items}</ol> }
1404}
1405
1406#[component]
1408fn NoteSections(toc_html: String, spy: bool) -> impl IntoView {
1409 #[cfg(target_arch = "wasm32")]
1410 {
1411 Effect::new(move |_| {
1412 if spy {
1413 crate::bridges::globals::init_toc_sidebar(
1414 Some("#note-rail-toc"),
1415 Some("#note-content"),
1416 None,
1417 );
1418 }
1419 });
1420 on_cleanup(move || {
1421 if spy {
1422 crate::bridges::globals::dispose_toc_sidebar(Some("#note-rail-toc"));
1423 }
1424 });
1425 }
1426 view! {
1427 {spy.then(|| view! { <crate::components::enhance_assets::EnhanceScript/> })}
1429 <nav id=spy.then_some("note-rail-toc") class="note-sections" aria-label="本篇小节">
1430 <div class="toc-sidebar-body" inner_html=toc_html></div>
1431 </nav>
1432 }
1433}
1434
1435#[component]
1440pub fn NoteDocument(
1441 note: PublicNote,
1442 #[prop(optional)] preview: bool,
1443 #[prop(optional, into)] context: MaybeProp<NoteContext>,
1444) -> impl IntoView {
1445 let context = context.get_untracked();
1446 let title = note.display_title();
1447 let created = ymd(¬e.created_at, '.');
1448 let updated = md(¬e.updated_at, '.');
1449 let book = context.as_ref().map(|c| c.notebook.id);
1450 let sheet_open = RwSignal::new(false);
1451
1452 let page_title = (!preview).then(|| view! { <Title text=format!("{title} · 笔记")/> });
1453 let mobile_entry = (!preview)
1454 .then(|| {
1455 let aria_expanded = move || sheet_open.get().to_string();
1456 if let Some(context) = context.as_ref() {
1457 Some(
1458 view! {
1459 <button
1460 id="note-directory-trigger"
1461 class="note-mobile-entry"
1462 type="button"
1463 aria-haspopup="dialog"
1464 aria-expanded=aria_expanded
1465 aria-controls="note-directory-sheet"
1466 on:click=move |_| sheet_open.set(true)
1467 >
1468 <Icon name=IconName::BookOpen size=15/>
1469 <span class="tabular-nums">
1470 {format!(
1471 "{} · 第 {} / {} 篇",
1472 context.notebook.title,
1473 context.position,
1474 context.total,
1475 )}
1476 </span>
1477 <span class="note-mobile-entry-action">"目录"</span>
1478 </button>
1479 }
1480 .into_any(),
1481 )
1482 } else if !note.toc_html.is_empty() {
1483 Some(
1484 view! {
1485 <button
1486 id="note-directory-trigger"
1487 class="note-mobile-entry"
1488 type="button"
1489 aria-haspopup="dialog"
1490 aria-expanded=aria_expanded
1491 aria-controls="note-directory-sheet"
1492 on:click=move |_| sheet_open.set(true)
1493 >
1494 <Icon name=IconName::List size=15/>
1495 <span>"本篇小节"</span>
1496 <span class="note-mobile-entry-action">"目录"</span>
1497 </button>
1498 }
1499 .into_any(),
1500 )
1501 } else {
1502 None
1503 }
1504 })
1505 .flatten();
1506 let position = context.as_ref().map(|context| {
1507 view! {
1508 <span class="tabular-nums">
1509 {format!("第 {} / {} 篇", context.position, context.total)}
1510 </span>
1511 }
1512 });
1513 let updated_meta = (note.updated_at.date_naive() != note.created_at.date_naive())
1514 .then(|| view! { <span>{format!("更新于 {updated}")}</span> });
1515 let sections_meta =
1516 (note.sections > 0).then(|| view! { <span>{format!("{} 节", note.sections)}</span> });
1517 let tags = (!note.tags.is_empty()).then(|| {
1518 view! {
1519 <p class="note-document-tags">
1520 {note
1521 .tags
1522 .iter()
1523 .map(|tag| view! { <span>{format!("#{tag}")}</span> })
1524 .collect_view()}
1525 </p>
1526 }
1527 });
1528 let continue_nav = context
1529 .as_ref()
1530 .filter(|c| c.prev.is_some() || c.next.is_some())
1531 .map(|context| {
1532 let prev = match context.prev.as_ref() {
1533 Some(prev) => view! {
1534 <a
1535 class="note-continue-link"
1536 href=note_route(&prev.slug, book).to_string()
1537 >
1538 <span class="note-continue-dir tabular-nums">
1539 {format!("← {:02}", context.position - 1)}
1540 </span>
1541 <span class="note-continue-title">{prev.title.clone()}</span>
1542 </a>
1543 }
1544 .into_any(),
1545 None => view! { <span></span> }.into_any(),
1546 };
1547 let next = context.next.as_ref().map(|next| {
1548 view! {
1549 <a
1550 class="note-continue-link is-next"
1551 href=note_route(&next.slug, book).to_string()
1552 >
1553 <span class="note-continue-dir tabular-nums">
1554 {format!("{:02} →", context.position + 1)}
1555 </span>
1556 <span class="note-continue-title">{next.title.clone()}</span>
1557 </a>
1558 }
1559 });
1560 view! {
1561 <footer class="note-continue">
1562 <p class="note-continue-label">
1563 {format!("在《{}》中继续", context.notebook.title)}
1564 </p>
1565 <nav class="note-continue-links" aria-label="同一笔记本的上一篇与下一篇">
1566 {prev} {next}
1567 </nav>
1568 </footer>
1569 }
1570 });
1571 let sheet = (!preview).then(|| {
1572 let context = context.clone();
1573 let current = note.slug.clone();
1574 let toc_html = note.toc_html.clone();
1575 view! {
1576 <BottomSheet
1577 open=sheet_open
1578 id="note-directory-sheet"
1579 trigger_id="note-directory-trigger"
1580 label="笔记本目录"
1581 >
1582 <NoteSheetBody
1583 context=context.clone()
1584 current=current.clone()
1585 toc_html=toc_html.clone()
1586 />
1587 </BottomSheet>
1588 }
1589 });
1590
1591 view! {
1592 {page_title}
1593 <article class="note-document">
1594 {mobile_entry} <div class="note-document-kicker">
1595 <span class="badge">{note.kind.label()}</span>
1596 {position}
1597 </div> <h1 class="note-document-title">{title.clone()}</h1>
1598 <p class="note-document-meta tabular-nums">
1599 <span>{format!("记录于 {created}")}</span>
1600 {updated_meta}
1601 {sections_meta}
1602 </p> {tags}
1603 <div id="note-content" class="note-document-body">
1604 <PostContent content_html=note.content_html.clone()/>
1605 </div> {continue_nav}
1606 </article>
1607 {sheet}
1608 }
1609}
1610
1611#[component]
1614fn NoteSheetBody(context: Option<NoteContext>, current: String, toc_html: String) -> impl IntoView {
1615 let sheet = use_sheet_close();
1616 let body = match context {
1617 Some(context) => view! {
1618 <p class="note-sheet-title">{context.notebook.title.clone()}</p>
1619 <NotebookDirectory context current toc_html spy=false/>
1620 }
1621 .into_any(),
1622 None => view! {
1623 <p class="note-sheet-title">"本篇小节"</p>
1624 <NoteSections toc_html spy=false/>
1625 }
1626 .into_any(),
1627 };
1628 view! {
1629 <div
1630 class="note-sheet"
1631 on:pointerup=move |_| sheet.close()
1632 on:keydown=move |event| {
1633 if event.key() == "Enter" {
1634 sheet.close();
1635 }
1636 }
1637 >
1638 {body}
1639 </div>
1640 }
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645 use super::*;
1646 use chrono::TimeZone;
1647
1648 fn note(id: i32, day: u32, hour: u32) -> Note {
1649 let at = Utc.with_ymd_and_hms(2026, 10, day, hour, 0, 0).unwrap();
1650 Note {
1651 id,
1652 slug: format!("n{id}"),
1653 version: 1,
1654 kind: NoteKind::Moment,
1655 title: String::new(),
1656 content_md: String::new(),
1657 content_html: String::new(),
1658 toc_html: String::new(),
1659 summary: String::new(),
1660 tags: Vec::new(),
1661 created_at: at,
1662 updated_at: at,
1663 published_at: Some(at),
1664 published_version: None,
1665 knowledge_version: None,
1666 deleted_at: None,
1667 notebook_ids: Vec::new(),
1668 sections: 0,
1669 }
1670 }
1671
1672 #[test]
1673 fn groups_adjacent_notes_by_day() {
1674 let groups = group_by_day(&[note(1, 15, 16), note(2, 15, 9), note(3, 12, 21)]);
1675 let shape: Vec<_> = groups
1676 .iter()
1677 .map(|g| (g.date.day(), g.notes.len()))
1678 .collect();
1679 assert_eq!(shape, [(15, 2), (12, 1)]);
1680 }
1681
1682 #[test]
1683 fn day_group_key_changes_when_notes_change() {
1684 let one = group_by_day(&[note(1, 15, 16)]);
1685 let two = group_by_day(&[note(1, 15, 16), note(2, 15, 9)]);
1686 assert_ne!(one[0].key(), two[0].key());
1687 assert_eq!(one[0].key(), group_by_day(&[note(1, 15, 16)])[0].key());
1688 }
1689
1690 #[test]
1691 fn relative_day_names_recent_days_and_other_years() {
1692 let today = NaiveDate::from_ymd_opt(2026, 10, 15).unwrap();
1693 let day = |y, m, d| NaiveDate::from_ymd_opt(y, m, d).unwrap();
1694 assert_eq!(relative_day(day(2026, 10, 15), today), "今天");
1695 assert_eq!(relative_day(day(2026, 10, 14), today), "昨天");
1696 assert_eq!(relative_day(day(2026, 10, 11), today), "周日");
1698 assert_eq!(relative_day(day(2026, 9, 1), today), "");
1699 assert_eq!(relative_day(day(2025, 12, 31), today), "2025");
1700 }
1701
1702 #[test]
1703 fn note_routes_carry_the_notebook_only_when_known() {
1704 assert_eq!(note_route("a", Some(3)).to_string(), "/notes/a?book=3");
1705 assert_eq!(note_route("a", None).to_string(), "/notes/a");
1707 }
1708
1709 #[test]
1710 fn first_filter_is_the_unfiltered_first_page() {
1711 let filter = first_filter();
1712 assert_eq!(filter.page, 1);
1713 assert!(filter.query.is_empty() && filter.tag.is_empty() && filter.kind.is_none());
1714 }
1715
1716 #[test]
1717 fn topic_card_links_to_the_note_within_its_notebook() {
1718 let mut topic = note(4, 15, 9);
1719 topic.kind = NoteKind::Topic;
1720 topic.title = "主题".to_string();
1721 topic.summary = "摘要".to_string();
1722 topic.sections = 3;
1723 let html = Owner::new().with(|| {
1724 view! { <TopicCard note=topic book=Some((2, "笔记本".to_string()))/> }.to_html()
1725 });
1726 assert!(html.contains(r#"href="/notes/n4?book=2""#));
1727 assert!(html.contains("3 节 · 更新于 10.15"));
1728 assert!(html.contains("note-topic-book"));
1729 }
1730
1731 #[test]
1732 fn notebook_entry_numbers_and_describes_each_kind() {
1733 let mut moment = note(5, 15, 9);
1734 moment.title = "标题".to_string();
1735 moment.summary = "摘要".to_string();
1736 let html =
1737 Owner::new().with(|| view! { <NotebookEntry note=moment number=3 book=9/> }.to_html());
1738 assert!(html.contains(r#"href="/notes/n5?book=9""#));
1739 assert!(html.contains("03"));
1740 assert!(html.contains("随记 · 10.15"));
1741 assert!(html.contains("notebook-entry-summary"));
1742 }
1743}