yggdrasil/components/
post_card.rs1use 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#[component]
30pub fn PostCard(post: PostListItem, #[props(default = false)] compact: bool) -> Element {
31 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 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}