Skip to main content

yggdrasil/components/skeletons/
notes_route_skeleton.rs

1//! 前台笔记骨架屏:列表 / 笔记本 / 单篇阅读三种版式,以及路由切换时的整页占位。
2//!
3//! 原先与 `pages/notes.rs` 同处一文件;`FrontendLayout` 的路由级 fallback 要按路径选择
4//! 骨架屏,而页面本身是懒加载分包,因此占位组件放在主包的 `skeletons` 层,
5//! 笔记页的 `Suspense` fallback 同样从这里引用,保证两处视觉一致。
6
7use leptos::prelude::*;
8
9use crate::components::skeletons::atoms::SkeletonBox;
10
11/// 笔记流占位:日期标签、两条随记与一块卡片。
12#[component]
13pub fn NotesStreamSkeleton() -> impl IntoView {
14    view! {
15        <div class="notes-stream-skeleton" aria-hidden="true">
16            <SkeletonBox class="h-3 w-24"/>
17            {(0..2)
18                .map(|_| {
19                    view! {
20                        <div class="note-moment">
21                            <SkeletonBox class="h-3 w-10"/>
22                            <div>
23                                <SkeletonBox class="h-4 w-full"/>
24                                <SkeletonBox class="h-4 mt-2 w-3/4"/>
25                            </div>
26                        </div>
27                    }
28                })
29                .collect_view()}
30            <SkeletonBox class="h-36 w-full rounded-[10px] mt-3"/>
31        </div>
32    }
33}
34
35/// 列表页整页占位:页头、左栏与笔记流,与真实布局对齐。
36#[component]
37pub fn NotesPageSkeleton() -> impl IntoView {
38    view! {
39        <div class="notes" role="status" aria-label="正在加载笔记">
40            <div class="notes-header" aria-hidden="true">
41                <div>
42                    <h1 class="notes-title">"笔记"</h1>
43                    <SkeletonBox class="h-4 mt-3 w-72 max-w-full"/>
44                </div>
45            </div>
46            <div class="notes-layout" aria-hidden="true">
47                <div class="notes-side">
48                    <SkeletonBox class="h-[34px] w-full rounded-lg"/>
49                    <div class="notes-side-group">
50                        {(0..3)
51                            .map(|_| view! { <SkeletonBox class="h-8 w-full rounded-lg mt-1"/> })
52                            .collect_view()}
53                    </div>
54                </div>
55                <div class="notes-stream">
56                    <NotesStreamSkeleton/>
57                </div>
58            </div>
59        </div>
60    }
61}
62
63/// 笔记本页占位:标题、简介、开始阅读按钮与条目列表。
64#[component]
65pub fn NotebookSkeleton() -> impl IntoView {
66    view! {
67        <div role="status" aria-label="正在加载笔记本">
68            <div aria-hidden="true">
69                <SkeletonBox class="h-3 w-20"/>
70                <SkeletonBox class="h-9 mt-8 w-56"/>
71                <SkeletonBox class="h-4 mt-4 w-full"/>
72                <SkeletonBox class="h-10 mt-6 w-36 rounded-[10px]"/>
73                <div class="notebook-entries">
74                    {(0..4)
75                        .map(|_| {
76                            view! {
77                                <div class="notebook-entry">
78                                    <SkeletonBox class="h-5 w-7"/>
79                                    <div>
80                                        <SkeletonBox class="h-5 w-3/5"/>
81                                        <SkeletonBox class="h-3 mt-3 w-full"/>
82                                    </div>
83                                </div>
84                            }
85                        })
86                        .collect_view()}
87                </div>
88            </div>
89        </div>
90    }
91}
92
93/// 阅读页占位:左栏目录与正文。
94#[component]
95pub fn NoteReaderSkeleton() -> impl IntoView {
96    view! {
97        <div class="notes-layout note-reader" role="status" aria-label="正在加载笔记">
98            <div class="notes-side note-rail" aria-hidden="true">
99                <SkeletonBox class="h-3 w-20"/>
100                {(0..4)
101                    .map(|_| view! { <SkeletonBox class="h-7 w-full rounded-lg mt-2"/> })
102                    .collect_view()}
103            </div>
104            <div class="note-document" aria-hidden="true">
105                <SkeletonBox class="h-5 w-24 rounded-full"/>
106                <SkeletonBox class="h-9 mt-4 w-4/5"/>
107                <SkeletonBox class="h-3 mt-4 w-48"/>
108                <div class="flex flex-col gap-3 mt-10">
109                    {(0..6)
110                        .map(|_| view! { <SkeletonBox class="h-3.5 w-full"/> })
111                        .collect_view()}
112                </div>
113            </div>
114        </div>
115    }
116}
117
118/// 前台笔记路径的三种版式。
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120enum NotesLayout {
121    /// `/notes`:笔记列表。
122    List,
123    /// `/notes/book/:id`:笔记本。
124    Notebook,
125    /// `/notes/:slug`:单篇笔记。
126    Note,
127}
128
129/// 按路径区分版式;忽略查询串、片段与结尾的 `/`。
130/// 与路由表一致:`/notes/book/:id` 恰好两段才是笔记本,其余单段 `/notes/<x>` 都是笔记
131/// (含 slug 恰为 `book` 的笔记)。
132fn notes_layout(path: &str) -> NotesLayout {
133    let path = path.split(['?', '#']).next().unwrap_or_default();
134    let mut segments = path
135        .strip_prefix("/notes")
136        .unwrap_or_default()
137        .split('/')
138        .filter(|segment| !segment.is_empty());
139    match (segments.next(), segments.next(), segments.next()) {
140        (Some("book"), Some(_), None) => NotesLayout::Notebook,
141        (Some(_), None, _) => NotesLayout::Note,
142        _ => NotesLayout::List,
143    }
144}
145
146/// 路由切换时的整页占位:按目标路径选择(`path` 为 `pathname`,可带查询串)。
147#[component]
148pub fn NotesRouteSkeleton(#[prop(into)] path: String) -> impl IntoView {
149    match notes_layout(&path) {
150        NotesLayout::Note => view! {
151            <div class="notes">
152                <NoteReaderSkeleton/>
153            </div>
154        }
155        .into_any(),
156        NotesLayout::Notebook => view! {
157            <div class="notebook">
158                <NotebookSkeleton/>
159            </div>
160        }
161        .into_any(),
162        NotesLayout::List => view! { <NotesPageSkeleton/> }.into_any(),
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn notes_layout_distinguishes_list_notebook_and_note() {
172        assert_eq!(notes_layout("/notes"), NotesLayout::List);
173        assert_eq!(notes_layout("/notes/"), NotesLayout::List);
174        assert_eq!(notes_layout("/notes/book/3"), NotesLayout::Notebook);
175        assert_eq!(notes_layout("/notes/hello"), NotesLayout::Note);
176        assert_eq!(notes_layout("/notes/hello?book=3"), NotesLayout::Note);
177        assert_eq!(notes_layout("/notes/book"), NotesLayout::Note);
178        assert_eq!(notes_layout("/notes/book/3/x"), NotesLayout::List);
179    }
180
181    #[test]
182    fn notes_route_skeleton_uses_the_matching_wrapper() {
183        let html = |path: &str| {
184            let path = path.to_string();
185            Owner::new().with(|| view! { <NotesRouteSkeleton path=path/> }.to_html())
186        };
187        assert!(html("/notes/book/3").contains(r#"class="notebook""#));
188        assert!(html("/notes/hello").contains("note-reader"));
189        assert!(html("/notes").contains("notes-header"));
190    }
191}