Skip to main content

yggdrasil/pages/
post_detail.rs

1//! 文章详情页面模块。
2//!
3//! 对应路由 `/post/:slug`(`SsrMode::Async`:`<Title>`/`<Meta>` 与 404 状态码依赖文章数据)。
4//!
5//! 数据获取:`LazyRoute::data` 在主包里同步创建 `Resource`,调用
6//! `get_public_post_by_slug` server function,根据 URL 中的 slug 获取单篇文章详情
7//! (含正文 HTML、目录、封面及上下篇导航);视图代码在 `--split` 构建下拆进独立
8//! WASM 分包,与数据并发加载。
9//!
10//! # 反应式取数的关键
11//! Leptos 路由在参数变化时复用本路由视图(上/下一篇导航即同一路由间的 slug 变化),
12//! 因此资源必须以 `use_params_map` 派生的 `Memo<String>` 为 source:slug 变化即重新
13//! 请求,memo 的相等比较过滤无关的路由通知。
14//!
15//! # 按文章重建子树
16//! `PostContent`、`PostToc`、`CommentSection` 的函数体只运行一次(增强脚本初始化、
17//! scroll-spy 绑定、评论资源都在挂载时建立),必须随文章重建。整篇文章视图由 `Suspend`
18//! 在资源每次解析后重新产出,子组件天然随 slug 重建,无需额外 key。
19
20use leptos::prelude::*;
21use leptos_meta::Title;
22use leptos_router::hooks::use_params_map;
23use leptos_router::{lazy_route, LazyRoute};
24
25use crate::api::posts::{get_public_post_by_slug, PublicPostResponse};
26use crate::components::comments::section::CommentSection;
27use crate::components::post::post_content::{
28    hydration_skeleton, PostContent, HYDRATION_PLACEHOLDER,
29};
30use crate::components::post::post_cover::PostCover;
31use crate::components::post::post_footer::PostFooter;
32use crate::components::post::post_header::PostHeader;
33use crate::components::post::post_toc::PostToc;
34use crate::components::share_meta::PostShareMeta;
35use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
36use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
37use crate::hooks::ssr_resource::{prefetch_route_data, route_data, PageJson, RouteData};
38use crate::models::post::{Post, PostFooterData, PostHeaderData, PostStatus};
39use crate::models::public_post::PublicPost;
40use crate::pages::not_found::{NotFound, PageLoadFailed};
41use crate::utils::error::is_not_found;
42
43/// 文章详情路由,对应 `/post/:slug`。
44pub struct PostRoute {
45    post: RouteData<PublicPostResponse>,
46}
47
48/// 取数函数随分包加载,见 `hooks::ssr_resource`。
49#[leptos::lazy]
50async fn fetch_post(slug: String) -> PageJson<PublicPostResponse> {
51    PageJson::with_hydration(get_public_post_by_slug(slug).await, hydration_form)
52}
53
54/// 水合数据里的文章:正文与目录已在 SSR HTML 里(`inner_html` 水合不改写 DOM),
55/// 正文只留可运行块骨架,目录换成占位;其余字段照旧。长文的水合数据因此不再重复一遍正文。
56fn hydration_form(response: &PublicPostResponse) -> PublicPostResponse {
57    PublicPostResponse {
58        post: response.post.as_ref().map(|post| PublicPost {
59            id: post.id,
60            author_id: post.author_id,
61            title: post.title.clone(),
62            slug: post.slug.clone(),
63            summary: post.summary.clone(),
64            content_html: post.content_html.as_deref().map(hydration_skeleton),
65            published_at: post.published_at,
66            created_at: post.created_at,
67            updated_at: post.updated_at,
68            tags: post.tags.clone(),
69            cover_image: post.cover_image.clone(),
70            reading_time: post.reading_time,
71            word_count: post.word_count,
72            toc_html: post.toc_html.as_ref().map(|toc| {
73                if toc.is_empty() {
74                    String::new()
75                } else {
76                    HYDRATION_PLACEHOLDER.to_owned()
77                }
78            }),
79            prev_post: post.prev_post.clone(),
80            next_post: post.next_post.clone(),
81        }),
82    }
83}
84
85#[lazy_route]
86impl LazyRoute for PostRoute {
87    fn data() -> Self {
88        let params = use_params_map();
89        let slug = Memo::new(move |_| params.read().get("slug").unwrap_or_default());
90        Self {
91            post: route_data(move || slug.get(), fetch_post),
92        }
93    }
94
95    /// 根据 slug 异步获取文章,渲染文章头部、封面、目录、正文、页脚及评论区;
96    /// 文章不存在渲染 404 页,其它加载失败渲染“页面加载失败”。
97    fn view(this: Self) -> AnyView {
98        let post = this.post;
99        view! {
100            <Suspense fallback=|| {
101                view! {
102                    <DelayedSkeleton>
103                        <PostDetailSkeleton/>
104                    </DelayedSkeleton>
105                }
106            }>
107                {move || Suspend::new(async move {
108                    match post.await {
109                        Ok(PublicPostResponse { post: Some(post) }) => {
110                            view! { <PostDetail post=post.into_display_post()/> }.into_any()
111                        }
112                        Ok(PublicPostResponse { post: None }) => view! { <NotFound/> }.into_any(),
113                        Err(err) if is_not_found(&err) => view! { <NotFound/> }.into_any(),
114                        Err(_) => view! { <PageLoadFailed/> }.into_any(),
115                    }
116                })}
117            </Suspense>
118        }
119        .into_any()
120    }
121}
122
123impl PostRoute {
124    /// 悬停预取:与 `data()` 相同的取数函数与 source。
125    pub fn prefetch_data(slug: String) {
126        prefetch_route_data(slug, fetch_post);
127    }
128}
129
130/// 文章详情主体:文章头部、封面、正文、页脚、评论区与目录。
131#[component]
132fn PostDetail(post: Post) -> impl IntoView {
133    let header = PostHeaderData::from(&post);
134    let footer = PostFooterData::from(&post);
135    let cover = post
136        .cover_image
137        .clone()
138        .map(|cover| view! { <PostCover src=cover post_id=post.id/> });
139    // 仅对已发布文章展示评论区域;评论区自带加载骨架屏。
140    let comments = (post.status == PostStatus::Published).then(|| {
141        let post_id = post.id;
142        view! {
143            <div class="post-comments">
144                <CommentSection post_id=post_id/>
145            </div>
146        }
147    });
148    let toc = post
149        .toc_html
150        .clone()
151        .filter(|t| !t.is_empty())
152        .map(|toc| view! { <PostToc toc_html=toc/> });
153    let content_html = post.content_html.clone().unwrap_or_default();
154    let title = format!("{} · Yggdrasil", post.title);
155    let vt_detail = post.id.to_string();
156
157    view! {
158        <div class="post-layout">
159            <article class="post-single animate-page-enter" data-vt-detail=vt_detail>
160                // P4.2:页眉/页脚只取小型字段集,避免复制完整正文。
161                <PostHeader post=header/>
162                {cover}
163                <PostContent content_html=content_html/>
164                <PostFooter post=footer/>
165                {comments}
166            </article>
167            {toc}
168        </div>
169        <Title text=title/>
170        <PostShareMeta post=post/>
171    }
172}