Skip to main content

yggdrasil/components/
post_card.rs

1//! 文章卡片组件
2//!
3//! 在首页、标签详情等列表中展示单篇文章的标题、摘要、封面、日期与标签。
4
5use dioxus::prelude::*;
6use dioxus::router::components::Link;
7
8use crate::components::ui::{SproutPlaceholder, TagChip};
9use crate::models::post::PostListItem;
10use crate::router::Route;
11
12/// 文章卡片组件。
13///
14/// Props:
15/// - `post`:文章数据模型
16/// - `compact`:首页紧凑文章流;其它列表默认保留普通卡片
17///
18/// 展示内容包括:
19/// - 封面图(如有,按版式请求缩略图,不启用灯箱)
20/// - 文章标题
21/// - 摘要(最多两行)
22/// - 发布日期与标签
23///
24/// 交互模型(采用覆盖层链接,避免 `<a>` 嵌套 `<a>` 的非法 HTML):
25/// - 整张卡片可点击跳转到文章详情:通过末尾一个绝对定位、铺满卡片的覆盖层 `Link` 实现。
26/// - 标签是独立的 `Link`,通过 `relative z-10` 叠在覆盖层之上,并 `stop_propagation`,
27///   点击标签进入标签详情页而不触发卡片跳转。
28/// - 封面用裸 `.blur-img`(纯展示,无灯箱),点击走卡片跳转,避免交互歧义。
29#[component]
30pub fn PostCard(post: PostListItem, #[props(default = false)] compact: bool) -> Element {
31    // 记住失败的 URL;同一文章更换封面后仍可重新加载。
32    let mut failed_cover = use_signal(|| None::<String>);
33    let cover_for_error = post.cover_image.clone();
34    let cover_failed = failed_cover.read().as_ref() == post.cover_image.as_ref();
35    let post_id = post.id;
36    #[cfg(target_arch = "wasm32")]
37    let cover_for_mount = post.cover_image.clone();
38    // 与友链头像一致:SSR 图片可能在 hydration 前就失败,补查已经完成的请求。
39    use_effect(move || {
40        #[cfg(target_arch = "wasm32")]
41        {
42            use wasm_bindgen::JsCast;
43            if let Some(img) = web_sys::window()
44                .and_then(|window| window.document())
45                .and_then(|document| document.get_element_by_id(&format!("post-cover-{post_id}")))
46                .and_then(|element| element.dyn_into::<web_sys::HtmlImageElement>().ok())
47            {
48                if img.complete() && img.natural_width() == 0 {
49                    failed_cover.set(cover_for_mount.clone());
50                }
51            }
52        }
53    });
54    let post_slug = post.slug.clone();
55    let date_str = post.formatted_date();
56    let reading_time = post.reading_time.max(1);
57    let tag_variant = if compact { "text" } else { "outline" };
58    let thumb_size = if compact { "480x360" } else { "840x360" };
59    let (article_class, title_class, summary_class) = if compact {
60        (
61            "post-card-editorial post-card-compact group relative",
62            "post-card-title",
63            "post-card-summary line-clamp-2",
64        )
65    } else {
66        (
67            "group relative mb-10 flex flex-col bg-paper-entry rounded-card hover:shadow-md transition-shadow overflow-hidden",
68            "text-xl sm:text-2xl md:text-3xl font-extrabold tracking-tight leading-snug text-paper-primary group-hover:text-paper-accent transition-colors",
69            "text-base text-paper-secondary leading-relaxed line-clamp-2",
70        )
71    };
72
73    rsx! {
74        article { class: "{article_class}",
75            if let Some(cover) = post.cover_image.as_deref() {
76                div { class: "post-card-cover overflow-hidden", "data-vt-post-id": "{post.id}", "data-vt-role": "cover",
77                    div { class: "blur-img post-card-cover-blur !rounded-none",
78                        if cover_failed {
79                            div { class: "absolute inset-0 flex items-center justify-center bg-paper-entry text-paper-tertiary", aria_hidden: "true",
80                                SproutPlaceholder { class: "w-8 h-8" }
81                            }
82                        } else {
83                            img {
84                                class: "blur-img-placeholder",
85                                src: "{cover}?w=30",
86                                alt: "",
87                                loading: "lazy",
88                            }
89                            img {
90                                id: "post-cover-{post_id}",
91                                class: "blur-img-full is-loaded",
92                                src: "{cover}?thumb={thumb_size}",
93                                alt: "{post.title}",
94                                loading: "lazy",
95                                decoding: "async",
96                                onerror: move |_| failed_cover.set(cover_for_error.clone()),
97                            }
98                        }
99                    }
100                }
101            }
102            div { class: "post-card-body p-8 flex flex-col gap-3.5 min-w-0",
103                div { class: "post-card-meta flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-paper-secondary",
104                    time { datetime: "{date_str}", "{date_str}" }
105                    span { "{reading_time} 分钟阅读" }
106                    if post.word_count > 0 {
107                        span { "{post.word_count} 字" }
108                    }
109                }
110                h2 { class: "{title_class}", "data-vt-post-id": "{post.id}", "data-vt-role": "title",
111                    "{post.title}"
112                }
113                if let Some(summary) = post.summary.as_deref().filter(|s| !s.is_empty()) {
114                    p { class: "{summary_class}",
115                        "{summary}"
116                    }
117                }
118                if !post.tags.is_empty() {
119                    div { class: "post-card-footer post-card-tags flex flex-wrap gap-x-3 gap-y-2 text-xs text-paper-secondary",
120                        for tag in post.tags.iter() {
121                            span { key: "{tag}", class: "relative z-10",
122                                TagChip {
123                                    label: tag.clone(),
124                                    to: Route::TagDetail { tag: tag.clone() },
125                                    variant: tag_variant,
126                                    stop_propagation: true,
127                                }
128                            }
129                        }
130                    }
131                }
132            }
133            Link {
134                class: "absolute inset-0 z-[2] rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-[-3px] focus-visible:outline-paper-accent",
135                aria_label: "阅读文章:{post.title}",
136                "data-vt-post-link": "{post.id}",
137                to: Route::PostDetail {
138                    slug: post_slug,
139                },
140            }
141        }
142    }
143}