Skip to main content

yggdrasil/pages/
notes.rs

1//! 笔记:列表 `/notes`、笔记本目录 `/notes/book/:id` 与阅读页 `/notes/:slug?book=`。
2//!
3//! 列表与阅读页共用 880px 容器内的「200px 左栏 + 内容」布局:列表页左栏是筛选,
4//! 阅读页左栏是所在笔记本的目录,进入阅读时布局不跳。
5//!
6//! 数据加载:三个路由在 `LazyRoute::data()` 里建 `Resource` 取 SSR 数据,`view()` 在
7//! `Suspense` 内消费。会刷新的数据(筛选切换、加载更多、重试)一律走
8//! `Effect` / 事件处理里的 `spawn_local` 写镜像信号,不再触碰 `Resource`,
9//! 否则外层 `Suspense` 会把整页退回骨架屏。
10
11use 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
40/// 列表一页的条数,与服务端 `store::list` 一致。
41const PAGE_SIZE: i64 = 20;
42/// 筛选框输入停顿多久后查询。
43const FILTER_DEBOUNCE_MS: u32 = 300;
44/// 左栏常用标签数。
45const SIDE_TAGS: usize = 8;
46
47/// 笔记类型筛选:全部、随记、主题笔记。
48const 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
58/// 阅读页地址;有笔记本上下文时带上 `?book=`。
59fn 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/// 按天分组的笔记流。列表已按时间降序;相邻同一天的笔记归为一组。
67#[derive(Clone, Debug, PartialEq)]
68struct DayGroup {
69    date: NaiveDate,
70    notes: Vec<Note>,
71}
72
73impl DayGroup {
74    /// 分组的渲染键:日期加上每条笔记的 `(id, version)`,内容变化时才重建该组。
75    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
98/// 组标题旁的相对日期:今天、昨天、一周内的星期几;跨年时写年份。
99fn 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
110/// 今天的日期只在浏览器挂载后取得:服务端渲染不写相对日期,避免水合不一致与缓存过期。
111fn 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
127/// 列表页首屏随 SSR 预取的筛选:第一页、无筛选条件。
128fn first_filter() -> NoteFilter {
129    NoteFilter {
130        page: 1,
131        ..Default::default()
132    }
133}
134
135// ======================= 列表页 =======================
136
137/// 笔记列表路由,对应 `/notes`。
138pub struct NotesRoute {
139    books: RouteData<Vec<Notebook>>,
140    stats: RouteData<NoteStats>,
141    first: RouteData<NotePage>,
142}
143
144// 取数函数随分包加载,见 `hooks::ssr_resource`。
145
146/// 公开笔记本目录;列表页与笔记本页共用。
147#[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
162/// 列表页首屏数据;无参,便于 `unit` 包成可作预取键的取数函数。
163fn 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                        // 目录与概况只影响左栏,失败时按空处理;列表失败由列表自己提示重试。
195                        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    /// 悬停预取:与 `data()` 相同的取数函数与 source。
209    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/// 当前显示的笔记列表及其筛选;与请求中的筛选不同时表示新结果仍在加载。
217#[derive(Clone)]
218struct ShownNotes {
219    filter: NoteFilter,
220    result: Result<NotePage, ApiError>,
221}
222
223/// 首屏随 SSR 预取挂载;之后的筛选切换在后台请求,结果返回前继续显示当前列表。
224/// 不能重新触发 `Resource`:外层 `Suspense` 会再次挂起回骨架屏,页面高度坍缩并带动
225/// 滚动位置跳动。回到首屏筛选直接复用预取结果。
226///
227/// 返回当前显示的列表、重试回调与「重试中」标志。
228fn 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    // 世代号:任何一次筛选变化或重试都会作废更早发出的请求。
240    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            // 回到挂载时的筛选:预取结果可用就直接复用。
257            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            // 之后又有新的筛选或重试:这份结果作废。
271            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/// 「更早的记录」追加的页:属于哪个筛选、已加载到第几页。筛选变化后自然作废。
288#[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    // 首帧与服务端一致用默认筛选;历史记录里保存的筛选在挂载后恢复。
303    let filter = RwSignal::new(first_filter());
304    // 恢复完成前不写回,避免用默认值覆盖要恢复的记录。
305    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/// 左栏:筛选框、类型、笔记本与常用标签。窄屏只留筛选框与横排类型。
342#[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    // 筛选被外部改写(恢复历史状态、清除筛选)时,输入框跟着变。
352    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/// 按天分组的笔记流,底部「更早的记录」按页追加。
519#[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                // 追加的页只在属于当前显示的筛选时才算数。
546                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/// 一天的笔记:日期标题(浏览器端再补相对日期)加该天的随记与主题卡片。
708#[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, &note);
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/// 随记:无卡片,「时间 + 正文」两列;正文直接渲染,悬停时出现「链接」。
756#[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(&note_time(&note));
763    let book = note.notebook_ids.first().copied();
764    let href = note_route(&note.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(&note).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/// 主题笔记:浮起卡片,衬线标题、两行摘要与「N 节 · 更新于」。
820#[component]
821fn TopicCard(note: Note, book: Option<(i32, String)>) -> impl IntoView {
822    let updated = md(&note_time(&note), '.');
823    let meta = if note.sections > 0 {
824        format!("{} 节 · 更新于 {updated}", note.sections)
825    } else {
826        format!("更新于 {updated}")
827    };
828    let href = note_route(&note.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
860// ======================= 笔记本目录 =======================
861
862/// 笔记本目录路由,对应 `/notes/book/:id`。
863pub struct NotebookRoute {
864    id: Memo<Option<i32>>,
865    books: RouteData<Vec<Notebook>>,
866    notes: RouteData<NotePage>,
867}
868
869/// 笔记本 id 参数:非数字为 `None`(渲染 404)。`data()` 与悬停预取共用。
870fn notebook_id(raw: Option<&str>) -> Option<i32> {
871    raw.and_then(|id| id.parse::<i32>().ok())
872}
873
874/// 笔记本目录页的笔记本列表;source 是笔记本 id,仅用于与 `notes` 共用刷新节奏。
875fn notebook_books(
876    _id: Option<i32>,
877) -> impl Future<Output = PageJson<Vec<Notebook>>> + Send + 'static {
878    fetch_notebooks()
879}
880
881/// 笔记本目录页的第一页笔记。
882fn 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    /// 悬停预取:与 `data()` 相同的取数函数与 source;`id` 是路由参数原文。
937    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/// 笔记本页:目录请求失败时如实提示并可重试,不误报「笔记本不存在」。
945/// 重试与加载更多都是事件处理里的后台请求,结果写进本组件的镜像信号。
946#[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    // 条目:首屏页加已追加的页;编号随位置,追加只会在末尾增加,键用 (序号, id)。
1052    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(&note_time(&note), '.');
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(&note.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
1233// ======================= 阅读页 =======================
1234
1235/// 笔记阅读路由,对应 `/notes/:slug`。`book` 来自 `?book=`,指明一条属于多个笔记本的
1236/// 笔记在哪本里读;无法解析时按未指明处理。
1237pub 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
1252/// `?book=` 参数:非数字按没有处理。`data()` 与悬停预取共用。
1253fn book_param(raw: Option<&str>) -> Option<i32> {
1254    raw.and_then(|book| book.parse::<i32>().ok())
1255}
1256
1257/// 笔记所在笔记本的上下文。
1258fn 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                    // 上下文只影响左栏,失败时按不在任何笔记本中处理。
1292                    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    /// 悬停预取:与 `data()` 相同的取数函数与 source;`book` 是 `?book=` 原文。
1317    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/// 阅读页左栏:所在笔记本的目录,当前篇浮起并展开本篇小节;不在公开笔记本中时
1324/// 只有返回链接与本篇小节。小节的当前位置由 yggdrasil-core 的目录 scroll-spy 标出。
1325#[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/// 笔记本目录的编号列表;当前篇浮起,其下展开本篇小节。
1370#[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/// 本篇小节(目录 HTML 的一级)。`spy` 为真时挂上 scroll-spy;抽屉里的副本不挂。
1407#[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        // P5 §8.2:scroll-spy 增强在 yggdrasil-enhance.js,按需注入。
1428        {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/// 笔记正文:类型与篇序、衬线标题、时间与标签、正文,以及笔记本内的上一篇 / 下一篇。
1436/// 后台编辑器的预览(`preview`)只显示正文部分。
1437///
1438/// 组件只运行一次:同一实例内 `note` 不会更新,换笔记须由调用方重建。
1439#[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(&note.created_at, '.');
1448    let updated = md(&note.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/// 手机抽屉里的目录:选中一篇或一个小节后收起。锚点点击会被全局处理拦下,收不到
1612/// click,所以改听 pointerup 与 Enter。
1613#[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        // 2026-10-11 是周日。
1697        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        // 查询值为空时整个 `?book=` 省略;阅读页把缺省视为「未指明笔记本」。
1706        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}