Skip to main content

yggdrasil/pages/
home.rs

1//! 首页与分页首页:刊物式开场、最新文章和编号文章流。
2//! 使用服务端分页数据;首页开场独立于文章区加载,翻页订阅路由变化。
3
4use dioxus::prelude::*;
5
6use crate::api::posts::{list_published_posts, PostListResponse};
7use crate::components::empty_state::EmptyState;
8use crate::components::post_card::PostCard;
9use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
10use crate::components::skeletons::home_skeleton::HomePostsSkeleton;
11use crate::components::ui::Pagination;
12use crate::router::Route;
13
14const POSTS_PER_PAGE: i32 = 10;
15
16#[component]
17pub fn Home() -> Element {
18    rsx! { HomePage { page: 1 } }
19}
20
21#[component]
22pub fn HomePage(page: i32) -> Element {
23    let current_page = page.max(1);
24    rsx! {
25        div { class: "home-page",
26            HomeIntro { current_page }
27            section { id: "home-posts", class: "home-posts", tabindex: "-1", aria_label: "文章列表",
28                SuspenseBoundary {
29                    fallback: move |_| rsx! { DelayedSkeleton { HomePostsSkeleton { current_page } } },
30                    // 翻页时卸载旧资源和重试任务,避免旧响应覆盖新页。
31                    HomePosts { key: "page-{current_page}", current_page }
32                }
33            }
34            footer { class: "home-colophon",
35                span { class: "home-colophon-mark", aria_hidden: "true", "✳" }
36                p { "文字落下的地方,便有了生长。" }
37                Link { class: "home-text-link", to: Route::Friends {},
38                    "去朋友的花园坐坐" span { class: "home-arrow", aria_hidden: "true", "↗" }
39                }
40            }
41        }
42    }
43}
44
45/// 首页与路由骨架屏共享静态开场,避免数据加载时首屏布局跳动。
46#[component]
47pub(crate) fn HomeIntro(current_page: i32) -> Element {
48    if current_page > 1 {
49        return rsx! {
50            header { class: "home-page-heading home-enter",
51                Link { class: "home-text-link", to: Route::Home {},
52                    span { class: "home-arrow", aria_hidden: "true", "←" } "回到首页"
53                }
54                p { class: "home-eyebrow", "THE JOURNAL / 过往篇章" }
55                h1 { "往前翻," span { "还有故事。" } }
56                p { class: "home-description", "第 {current_page} 页 · 沿着文字,走进更早的年轮。" }
57            }
58        };
59    }
60
61    rsx! {
62        header { class: "home-hero",
63            div { class: "home-hero-copy home-enter",
64                p { class: "home-eyebrow", span { aria_hidden: "true" } "YGGDRASIL / 一隅数字花园" }
65                h1 { "让想法生根," span { "让文字成林。" } }
66                p { class: "home-description",
67                    "记录技术、生活,与偶然闪光的念头。"
68                    br {}
69                    "在这里,慢慢写,也慢慢生长。"
70                }
71                div { class: "home-hero-actions",
72                    a { class: "home-reading-link", href: "#home-posts",
73                        "开始阅读" span { class: "home-arrow", aria_hidden: "true", "↓" }
74                    }
75                    Link { class: "home-text-link", to: Route::About {},
76                        "关于这里" span { class: "home-arrow", aria_hidden: "true", "↗" }
77                    }
78                }
79            }
80            Link { class: "home-garden home-enter", to: Route::About {}, aria_label: "认识这棵世界树",
81                svg { class: "home-rings", view_box: "0 0 360 360", fill: "none", "aria-hidden": "true",
82                    g { class: "home-ring-lines", stroke: "currentColor", stroke_width: "0.8",
83                        for i in 0..9 {
84                            ellipse {
85                                key: "{i}", cx: "180", cy: "180",
86                                rx: "{42 + i * 14}", ry: "{38 + i * 13}",
87                                transform: "rotate({i * 19} 180 180)",
88                            }
89                        }
90                    }
91                    circle { cx: "180", cy: "180", r: "169", stroke: "currentColor", stroke_dasharray: "1 9", opacity: "0.3" }
92                    path { d: "M180 6V28M180 332V354M6 180H28M332 180H354", stroke: "currentColor", opacity: "0.4" }
93                    g { class: "home-sprout", stroke: "currentColor", stroke_width: "1.8", stroke_linecap: "round", stroke_linejoin: "round",
94                        path { d: "M180 207V175M180 190C157 190 148 173 151 156C169 156 183 166 180 190Z" }
95                        path { d: "M180 176C180 156 192 147 210 149C210 165 199 177 180 176ZM180 190L163 170M180 176L198 159M166 208H194" }
96                    }
97                    g { class: "home-ring-orbit", fill: "currentColor",
98                        circle { cx: "180", cy: "37", r: "4" }
99                        circle { cx: "73", cy: "272", r: "2.5", opacity: "0.6" }
100                    }
101                }
102                span { class: "home-garden-caption", "每一篇,都是新的年轮" span { class: "home-arrow", aria_hidden: "true", "↗" } }
103            }
104        }
105    }
106}
107
108#[component]
109fn HomePosts(current_page: i32) -> Element {
110    let router = dioxus::router::router();
111    // Router 会在原生快照前通知旧路由;页码未变时不重新挂起旧列表。
112    let requested_page = use_memo(move || match router.current::<Route>() {
113        Route::HomePage { page } => page.max(1),
114        _ => current_page,
115    });
116    let mut posts_res =
117        use_server_future(move || list_published_posts(requested_page(), POSTS_PER_PAGE))?;
118
119    let posts_data = posts_res.read();
120    match posts_data.as_ref() {
121        Some(Ok(PostListResponse { posts, total })) => {
122            let total = *total;
123            rsx! {
124                span { hidden: true, "data-vt-list": "true" }
125                HomePostsHeading { current_page, total }
126                if !posts.is_empty() {
127                    div { key: "page-{current_page}", class: "home-post-list",
128                        for (index, post) in posts.iter().enumerate() {
129                            div {
130                                key: "{post.id}",
131                                class: if current_page == 1 && index == 0 { "home-entry home-entry-featured home-enter" } else { "home-entry home-enter" },
132                                style: "--home-delay: {index.min(5) * 45}ms",
133                                if current_page == 1 && index == 0 {
134                                    span { class: "home-featured-label", "最新一篇" span { " / LATEST ENTRY" } }
135                                } else {
136                                    span { class: "home-entry-number", aria_hidden: "true",
137                                        {format!("{:02}", (i64::from(current_page) - 1) * i64::from(POSTS_PER_PAGE) + index as i64 + 1)}
138                                    }
139                                }
140                                PostCard { post: post.clone(), compact: true }
141                                span { class: "home-entry-arrow home-arrow", aria_hidden: "true", "↗" }
142                            }
143                        }
144                    }
145                    Pagination {
146                        variant: "frontend", current_page, total, per_page: POSTS_PER_PAGE,
147                        prev_route: if current_page - 1 <= 1 { Route::Home {} } else { Route::HomePage { page: current_page - 1 } },
148                        next_route: Route::HomePage { page: current_page + 1 },
149                        unit: "篇",
150                    }
151                } else if total == 0 {
152                    EmptyState { title: "还没有文章", description: "这片花园,正等待第一颗文字的种子。" }
153                } else {
154                    div { class: "home-status",
155                        EmptyState { title: "已经翻到最后了", description: "这一页还没有文字,回到首页看看最近的记录吧。" }
156                        Link { class: "home-reading-link", to: Route::Home {}, "回到首页" span { class: "home-arrow", aria_hidden: "true", "↗" } }
157                    }
158                }
159            }
160        }
161        Some(Err(_)) => rsx! {
162            span { hidden: true, "data-vt-list": "true" }
163            HomePostsHeading { current_page }
164            HomePostsError {
165                current_page,
166                on_recovered: move |response| posts_res.set(Some(Ok(response))),
167            }
168        },
169        _ => rsx! { DelayedSkeleton { HomePostsSkeleton { current_page } } },
170    }
171}
172
173#[component]
174fn HomePostsHeading(current_page: i32, total: Option<i64>) -> Element {
175    rsx! {
176        div { class: "home-section-heading home-enter",
177            div { class: "home-section-title",
178                h2 { if current_page == 1 { "最近写下" } else { "过往篇章" } }
179                if let Some(total) = total {
180                    span { class: "home-post-count", "共 {total} 篇" }
181                }
182            }
183            Link { class: "home-text-link", to: Route::Archives {},
184                "全部归档" span { class: "home-arrow", aria_hidden: "true", "↗" }
185            }
186        }
187    }
188}
189
190/// 重试在错误面板的作用域内运行;成功后回填资源,不触发 SSR future 的悬挂骨架。
191#[component]
192fn HomePostsError(current_page: i32, on_recovered: EventHandler<PostListResponse>) -> Element {
193    let router = dioxus::router::router();
194    let mut retrying = use_signal(|| false);
195    let mut retry_failed = use_signal(|| false);
196
197    rsx! {
198        div { class: "home-error", role: "group", aria_labelledby: "home-error-title",
199            div { class: "home-error-copy",
200                p { class: "home-error-eyebrow",
201                    span { class: "home-error-pause", aria_hidden: "true", "Ⅱ" }
202                    "稍作停留" span { class: "home-error-eyebrow-en", " / A LITTLE PAUSE" }
203                }
204                h3 { id: "home-error-title", "文章暂时无法加载" }
205                p { class: "home-error-description", role: "status", aria_live: "polite", aria_atomic: "true",
206                    if retry_failed() {
207                        "这次仍未加载成功,稍后再试一次吧。"
208                    } else {
209                        "加载遇到了一点阻碍,稍后再试一次吧。"
210                    }
211                    if retrying() {
212                        span { class: "sr-only", "正在重新加载文章,请稍候。" }
213                    }
214                }
215                button {
216                    class: "home-error-retry",
217                    r#type: "button",
218                    aria_disabled: retrying(),
219                    aria_busy: retrying(),
220                    onclick: move |_| {
221                        if retrying() {
222                            return;
223                        }
224                        retrying.set(true);
225                        retry_failed.set(false);
226                        let request_route = router.current::<Route>();
227                        spawn(async move {
228                            let result = list_published_posts(current_page, POSTS_PER_PAGE).await;
229                            // Suspense 切换期间旧面板可能尚未卸载,也不能回填另一条路由。
230                            if router.current::<Route>() != request_route {
231                                return;
232                            }
233                            retrying.set(false);
234                            match result {
235                                Ok(response) => on_recovered.call(response),
236                                Err(_) => retry_failed.set(true),
237                            }
238                        });
239                    },
240                    svg {
241                        class: "home-error-retry-icon", view_box: "0 0 24 24", fill: "none",
242                        stroke: "currentColor", stroke_width: "1.6", stroke_linecap: "round", stroke_linejoin: "round",
243                        "aria-hidden": "true", "focusable": "false",
244                        path { d: "M20 7V3M20 7H16M20 7A8 8 0 1 0 20 16" }
245                    }
246                    if retrying() { "正在重新加载…" } else { "重新加载" }
247                }
248            }
249            svg {
250                class: "home-error-art", view_box: "0 0 240 240", fill: "none",
251                "aria-hidden": "true", "focusable": "false",
252                g { class: "home-error-rings", stroke: "currentColor", stroke_width: "0.8", stroke_linecap: "round",
253                    circle { cx: "120", cy: "112", r: "94", stroke_dasharray: "370 52 72 97", transform: "rotate(-62 120 112)" }
254                    circle { cx: "120", cy: "112", r: "78", stroke_dasharray: "264 42 65 119", transform: "rotate(-28 120 112)" }
255                    circle { cx: "120", cy: "112", r: "62", stroke_dasharray: "198 36 59 97", transform: "rotate(-78 120 112)" }
256                    circle { cx: "120", cy: "112", r: "108", stroke_dasharray: "1 11", opacity: "0.55" }
257                }
258                g { stroke: "currentColor", stroke_linecap: "round", stroke_linejoin: "round",
259                    path { class: "home-error-book-fill", stroke_width: "1.5", d: "M120 161C101 148 79 146 58 150L58 184C82 180 102 185 120 194C138 185 158 180 182 184V150C161 146 139 148 120 161Z" }
260                    path { stroke_width: "1.5", d: "M120 162V194M53 156L50 190C76 186 100 192 120 201C140 192 164 186 190 190L187 156" }
261                    path { opacity: "0.35", d: "M70 161C83 160 96 163 108 168M70 170C83 169 96 172 108 177M132 168C144 163 157 160 170 161M132 177C144 172 157 169 170 170" }
262                    path { stroke_width: "1.8", d: "M120 161V105" }
263                    path { class: "home-error-leaf", stroke_width: "1.5", d: "M120 131C96 132 81 115 83 93C104 93 121 106 120 131ZM120 113C118 89 131 73 154 72C157 94 143 113 120 113Z" }
264                    path { stroke_width: "1.2", d: "M120 132L95 106M120 113L142 86" }
265                    path { opacity: "0.5", d: "M52 72V82M47 77H57M182 102V110M178 106H186M162 38V44M159 41H165" }
266                }
267                g { fill: "currentColor",
268                    circle { cx: "70", cy: "36", r: "2.5", opacity: "0.65" }
269                    circle { cx: "209", cy: "140", r: "2", opacity: "0.5" }
270                    circle { cx: "31", cy: "123", r: "1.5", opacity: "0.4" }
271                }
272                path { d: "M104 219H136", stroke: "currentColor", stroke_linecap: "round", opacity: "0.35" }
273            }
274        }
275    }
276}