1use leptos::prelude::*;
5use leptos_meta::Title;
6use leptos_router::hooks::use_params_map;
7use leptos_router::{lazy_route, LazyRoute};
8
9use crate::api::posts::{get_posts_by_tag, PostListResponse, TagListResponse};
10use crate::components::empty_state::EmptyState;
11use crate::components::mascot::Mascot;
12use crate::components::skeletons::archive_skeleton::ArchiveBodySkeleton;
13use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
14use crate::components::ui::{Icon, IconName, LoadError};
15use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
16use crate::models::post::PostListItem;
17use crate::pages::archives::{fetch_tags, tag_list, IndexRow, TagFilter, YearBlocks};
18use crate::routes::Route;
19
20pub struct TagRoute {
23 tag: Memo<String>,
24 posts: RouteData<PostListResponse>,
25 tags: RouteData<TagListResponse>,
26}
27
28#[leptos::lazy]
29async fn fetch_tag_posts(tag: String) -> PageJson<PostListResponse> {
30 PageJson::new(get_posts_by_tag(tag).await)
31}
32
33#[lazy_route]
34impl LazyRoute for TagRoute {
35 fn data() -> Self {
36 let params = use_params_map();
37 let tag = Memo::new(move |_| params.read().get("tag").unwrap_or_default());
38 Self {
39 tag,
40 posts: route_data(move || tag.get(), fetch_tag_posts),
41 tags: route_data(|| (), unit(fetch_tags)),
42 }
43 }
44
45 fn view(this: Self) -> AnyView {
46 let TagRoute { tag, posts, tags } = this;
47 (move || {
49 let current = tag.get();
50 view! {
51 <Title text=format!("{current} · 标签 · Yggdrasil")/>
52 <div class="archive">
53 <TagIntro tag=current.clone()/>
54 <Suspense fallback=|| {
55 view! {
56 <DelayedSkeleton>
57 <ArchiveBodySkeleton/>
58 </DelayedSkeleton>
59 }
60 }>
61 {
62 let current = current.clone();
63 move || {
64 let current = current.clone();
65 Suspend::new(async move {
66 let listing = posts.await.ok();
67 let tags = tag_list(tags.await);
68 view! { <TagBody tag=current initial=listing tags=tags/> }
69 })
70 }
71 }
72 </Suspense>
73 </div>
74 }
75 })
76 .into_any()
77 }
78}
79
80impl TagRoute {
81 pub fn prefetch_data(tag: String) {
83 prefetch_route_data(tag, fetch_tag_posts);
84 prefetch_route_data((), unit(fetch_tags));
85 }
86}
87
88#[component]
90pub fn TagIntro(#[prop(into)] tag: String) -> impl IntoView {
91 view! {
92 <header class="archive-header page-enter">
93 <a class="archive-back" href=Route::Archives.to_string()>
94 <Icon name=IconName::ArrowLeft size=14/>
95 "全部归档"
96 </a>
97 <h1 class="archive-title">
98 <span class="archive-title-hash" aria-hidden="true">
99 "# "
100 </span>
101 {tag}
102 </h1>
103 </header>
104 }
105}
106
107#[component]
110fn TagBody(
111 tag: String,
112 initial: Option<PostListResponse>,
113 tags: Vec<crate::models::post::Tag>,
114) -> impl IntoView {
115 let listing = RwSignal::new(initial);
116 let retrying = RwSignal::new(false);
117 let retry_failed = RwSignal::new(false);
118
119 let request_tag = tag.clone();
120 let on_retry = Callback::new(move |()| {
121 retrying.set(true);
122 retry_failed.set(false);
123 let tag = request_tag.clone();
124 leptos::task::spawn_local(async move {
125 let result = get_posts_by_tag(tag).await;
126 let _ = retrying.try_set(false);
127 match result {
128 Ok(response) => {
129 let _ = listing.try_set(Some(response));
130 }
131 Err(_) => {
132 let _ = retry_failed.try_set(true);
133 }
134 }
135 });
136 });
137
138 let body_tag = tag.clone();
139 view! {
140 {move || {
141 listing
142 .with(|listing| {
143 listing
144 .as_ref()
145 .map(|PostListResponse { posts, total }| tag_summary(posts, *total))
146 })
147 .map(|summary| {
148 view! { <p class="archive-summary tabular-nums page-enter">{summary}</p> }
149 })
150 }}
151 <TagFilter tags=tags current=Some(tag.clone())/>
152 {move || {
153 let Some(PostListResponse { posts, total }) = listing.get() else {
154 return view! {
155 <div class="archive-years" data-vt-list="true">
156 <LoadError
157 title="这个标签下的文章暂时没能加载"
158 retrying=retrying
159 retry_failed=retry_failed
160 on_retry=on_retry
161 />
162 </div>
163 }
164 .into_any();
165 };
166 let rows: Vec<IndexRow> = posts.iter().map(tag_row).collect();
167 let truncated = total > posts.len() as i64;
168 let listed = posts.len();
169 if rows.is_empty() {
170 return view! {
171 <span hidden data-vt-list="true"></span>
172 <EmptyState
173 title="这个标签下还没有文章"
174 description="换一个标签,或者回到全部归档看看。"
175 mascot=Mascot::Doze
176 />
177 }
178 .into_any();
179 }
180 view! {
181 <YearBlocks rows=rows hide_tag=body_tag.clone()/>
182 {truncated
183 .then(|| {
184 view! {
185 <p class="archive-limit-note tabular-nums">
186 {format!("共 {total} 篇,这里列出最近的 {listed} 篇。")}
187 </p>
188 }
189 })}
190 }
191 .into_any()
192 }}
193 }
194}
195
196fn tag_row(post: &PostListItem) -> IndexRow {
197 IndexRow {
198 id: post.id,
199 slug: post.slug.clone(),
200 title: post.title.clone(),
201 date: post.published_at.unwrap_or(post.created_at),
202 tags: post.tags.clone(),
203 }
204}
205
206fn tag_summary(posts: &[PostListItem], total: i64) -> String {
208 match posts
209 .iter()
210 .map(|p| p.published_at.unwrap_or(p.created_at))
211 .max()
212 {
213 Some(latest) => format!(
214 "{total} 篇文章,最近一篇写于 {}。",
215 crate::utils::time::ymd(&latest, '.')
216 ),
217 None => "还没有文章。".to_string(),
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::models::post::PostStatus;
225 use chrono::{TimeZone, Utc};
226
227 fn post(id: i32, created_day: u32, published_day: Option<u32>) -> PostListItem {
228 let date = |day| Utc.with_ymd_and_hms(2026, 7, day, 0, 0, 0).unwrap();
229 PostListItem {
230 id,
231 author_id: 1,
232 title: format!("Article {id}"),
233 slug: format!("article-{id}"),
234 summary: None,
235 status: PostStatus::Published,
236 published_at: published_day.map(date),
237 created_at: date(created_day),
238 updated_at: date(created_day),
239 deleted_at: None,
240 tags: Vec::new(),
241 cover_image: None,
242 reading_time: 1,
243 word_count: 0,
244 }
245 }
246
247 #[test]
248 fn summary_uses_the_latest_publication_or_creation_date() {
249 let posts = [post(1, 1, Some(10)), post(2, 20, None), post(3, 2, Some(5))];
250 assert_eq!(
251 tag_summary(&posts, 3),
252 "3 篇文章,最近一篇写于 2026.07.20。"
253 );
254 assert_eq!(tag_summary(&[], 0), "还没有文章。");
255 }
256
257 #[test]
258 fn rows_fall_back_to_creation_date() {
259 let row = tag_row(&post(7, 3, None));
260 assert_eq!(row.date, Utc.with_ymd_and_hms(2026, 7, 3, 0, 0, 0).unwrap());
261 assert_eq!(row.slug, "article-7");
262 }
263}