Skip to main content

yggdrasil/pages/
home.rs

1//! 首页模块。
2//!
3//! 对应路由:
4//! - `/`:首页,默认展示第 1 页文章。
5//! - `/page/:page`:分页首页,展示指定页码的已发布文章列表。
6//!
7//! 数据获取:通过 `use_server_future` 调用 `list_published_posts` server function,
8//! 从服务端获取已发布文章的分页列表与总数,并渲染文章卡片与分页导航。
9//! 在 `wasm32` 目标下,server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
10//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
11
12use dioxus::prelude::*;
13
14use crate::api::posts::{list_published_posts, PostListResponse};
15use crate::components::empty_state::EmptyState;
16use crate::components::post_card::PostCard;
17use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
18use crate::components::skeletons::home_skeleton::HomeSkeleton;
19use crate::components::ui::Pagination;
20use crate::router::Route;
21
22// 每页展示的已发布文章数量,用于分页计算。
23const POSTS_PER_PAGE: i32 = 10;
24
25/// 首页组件,对应路由 `/`。
26///
27/// 直接委托给 `HomePage` 并固定页码为 1。
28#[component]
29pub fn Home() -> Element {
30    rsx! {
31        HomePage { page: 1 }
32    }
33}
34
35/// 首页分页组件,对应路由 `/page/:page`。
36///
37/// 对传入的页码进行下限校正后,渲染头部信息与文章列表。
38#[component]
39pub fn HomePage(page: i32) -> Element {
40    let current_page = page.max(1);
41
42    rsx! {
43        div { class: "animate-page-enter",
44            HomeInfo {}
45            HomePosts { current_page }
46        }
47    }
48}
49
50/// 首页文章列表与分页展示组件。
51///
52/// 通过 `use_server_future` 异步获取当前页文章;
53/// 加载中显示骨架屏,加载失败显示错误提示,成功则渲染文章卡片与分页。
54///
55/// # 反应式取数
56/// 同 `post_detail.rs`:`current_page` 是普通 `i32` prop,被 `move` 进
57/// `use_server_future` 闭包后成为冻结快照,读取它不建立反应式订阅。因此
58/// `/page/1 → /page/2`(同为 `Route::HomePage` 变体,复用组件实例)这样的
59/// 同变体分页导航无法触发 future 重跑。修复方式:在闭包内通过
60/// `router().current::<Route>()` 读取当前 page,建立订阅,路由变化即重跑。
61/// `Home`(`/`)调用时路由变体无 page 字段,兜底用传入的 `current_page`。
62#[component]
63fn HomePosts(current_page: i32) -> Element {
64    let router = dioxus::router::router();
65
66    let posts_res = use_server_future(move || {
67        let page = match router.current::<Route>() {
68            Route::HomePage { page } => page.max(1),
69            // / 路由(Route::Home)及其它变体:用 prop 兜底。
70            _ => current_page,
71        };
72        list_published_posts(page, POSTS_PER_PAGE)
73    })?;
74
75    // 将结果映射为更便于本地使用的 (posts, total) 形式。
76    let posts_data = posts_res.read().as_ref().map(|r| match r {
77        Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)),
78        Err(e) => Err(e.to_string()),
79    });
80
81    match posts_data {
82        Some(Ok((posts, total))) => {
83            rsx! {
84                for post in posts.iter() {
85                    PostCard { key: "{post.id}", post: post.clone() }
86                }
87                // total == 0 表示站点确实无文章:显示空状态,且不渲染分页。
88                // 注意:total > 0 但 posts 为空(如越界页码 /page/9999)也不显示空状态,
89                // 避免误导用户以为站点无内容;此时仅靠下方的分页导航引导回有效页。
90                if total == 0 {
91                    EmptyState {
92                        title: "还没有文章",
93                        description: "这里会收集你写下的每一篇文字。",
94                    }
95                }
96                // 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页。
97                // frontend variant 不渲染页码计数,unit 不显示(仅满足必填 prop)。
98                if total > 0 {
99                    Pagination {
100                        variant: "frontend",
101                        current_page,
102                        total,
103                        per_page: POSTS_PER_PAGE,
104                        prev_route: if current_page - 1 <= 1 { Route::Home {} } else { Route::HomePage {
105                            page: current_page - 1,
106                        } },
107                        next_route: Route::HomePage {
108                            page: current_page + 1,
109                        },
110                        unit: "篇",
111                    }
112                }
113            }
114        }
115        // 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。
116        Some(Err(_)) => {
117            rsx! {
118                div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" }
119            }
120        }
121        _ => {
122            rsx! {
123                DelayedSkeleton { HomeSkeleton {} }
124            }
125        }
126    }
127}
128
129/// 首页头部信息组件,展示站点名称与副标题。
130#[component]
131fn HomeInfo() -> Element {
132    rsx! {
133        div { class: "mt-12 mb-20 flex flex-col items-start gap-4",
134            h1 { class: "text-6xl md:text-[5rem] font-extrabold text-[var(--color-paper-primary)] tracking-tighter leading-none",
135                "Yggdrasil."
136            }
137            p { class: "text-lg md:text-xl text-[var(--color-paper-secondary)] font-medium max-w-lg leading-relaxed",
138                "极简、快速、现代。专注于文字本身的开源博客平台。"
139            }
140        }
141    }
142}