1use std::collections::{BTreeMap, BTreeSet};
5
6use dioxus::prelude::*;
7
8use crate::api::posts::{get_posts_by_tag, PostListResponse};
9use crate::components::post_card::PostCard;
10use crate::components::skeletons::tags_skeleton::TagPostsLoading;
11use crate::models::post::PostListItem;
12use crate::router::Route;
13
14#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15enum TagSortOrder {
16 Latest,
17 Earliest,
18}
19
20impl TagSortOrder {
21 fn value(self) -> &'static str {
22 match self {
23 Self::Latest => "latest",
24 Self::Earliest => "earliest",
25 }
26 }
27
28 fn label(self) -> &'static str {
29 match self {
30 Self::Latest => "最新",
31 Self::Earliest => "最早",
32 }
33 }
34
35 fn opening(self) -> &'static str {
36 match self {
37 Self::Latest => "最近写下",
38 Self::Earliest => "从最初读起",
39 }
40 }
41}
42
43fn sorted_posts(posts: &[PostListItem], order: TagSortOrder) -> Vec<&PostListItem> {
44 let mut sorted: Vec<_> = posts.iter().collect();
45 sorted.sort_by_key(|post| (post.published_at.unwrap_or(post.created_at), post.id));
46 if order == TagSortOrder::Latest {
47 sorted.reverse();
48 }
49 sorted
50}
51
52fn related_tags(posts: &[PostListItem], current_tag: &str) -> Vec<String> {
54 let mut counts = BTreeMap::new();
55 for post in posts {
56 for tag in post.tags.iter().collect::<BTreeSet<_>>() {
57 if tag != current_tag && !tag.trim().is_empty() {
58 *counts.entry(tag).or_insert(0_usize) += 1;
59 }
60 }
61 }
62 let mut tags: Vec<_> = counts.into_iter().collect();
63 tags.sort_by(|(a, a_count), (b, b_count)| b_count.cmp(a_count).then_with(|| a.cmp(b)));
64 tags.into_iter()
65 .take(6)
66 .map(|(tag, _)| tag.clone())
67 .collect()
68}
69
70#[component]
71pub fn TagDetail(tag: String) -> Element {
72 rsx! {
73 div { class: "tag-page",
74 TagIntro { tag: tag.clone() }
75 for current_tag in std::iter::once(tag) {
78 div { key: "{current_tag}", id: "tag-posts", class: "tag-workspace",
79 SuspenseBoundary {
80 fallback: |_| rsx! { TagPostsLoading {} },
81 TagDetailContent { tag: current_tag }
82 }
83 }
84 }
85 }
86 }
87}
88
89#[component]
91pub(crate) fn TagIntro(tag: String) -> Element {
92 rsx! {
93 header { class: "tag-intro",
94 Link { class: "tag-text-link tag-back", to: Route::Archives {},
95 span { class: "tag-arrow", aria_hidden: "true", "←" }
96 span { "归档与标签" }
97 }
98 div { class: "tag-hero",
99 div { class: "tag-hero-copy tag-enter",
100 p { class: "tag-eyebrow",
101 span { class: "tag-eyebrow-mark", aria_hidden: "true", "#" }
102 "TOPIC COLLECTION" span { class: "tag-eyebrow-translation", " / 主题手记" }
103 }
104 h1 { "{tag}" }
105 p { class: "tag-description", "沿着一个主题,拾起散落的思考。" br {} "让有关的文字,在这里慢慢生长。" }
106 }
107 div { class: "tag-hero-art tag-enter", style: "--tag-delay: 100ms", aria_hidden: "true",
108 TagIllustration {}
109 span { class: "tag-art-caption", "A BRANCH OF THE GARDEN" }
110 }
111 }
112 }
113 }
114}
115
116#[component]
117fn TagIllustration(#[props(default = false)] paused: bool) -> Element {
118 rsx! {
119 svg { class: "tag-illustration", view_box: "0 0 240 240", fill: "none", "aria-hidden": "true",
120 circle { cx: "120", cy: "120", r: "104", stroke: "currentColor", stroke_width: "0.7", stroke_dasharray: "1 7", opacity: "0.3" }
121 path { d: "M120 7V23M120 217V233M7 120H23M217 120H233", stroke: "currentColor", stroke_width: "0.7", opacity: "0.4" }
122 g { class: "tag-illustration-pages", stroke: "currentColor", stroke_linejoin: "round",
123 path { d: "M65 63L164 48L185 187L86 202Z", stroke_width: "0.9", opacity: "0.22" }
124 path { d: "M58 51H168V191H58Z", stroke_width: "1", opacity: "0.48" }
125 path { d: "M70 51V191M83 170H145M83 178H120", stroke_width: "0.8", opacity: "0.28" }
126 path { d: "M141 51V91L151 83L161 91V51", stroke_width: "1.1", fill: "currentColor", fill_opacity: "0.06" }
127 }
128 g { class: "tag-illustration-sprout", stroke: "currentColor", stroke_width: "1.4", stroke_linecap: "round", stroke_linejoin: "round",
129 path { d: "M112 156V109M99 156H126" }
130 path { d: "M112 138C92 138 86 124 88 111C105 112 115 120 112 138ZM112 119C111 100 122 92 136 94C136 109 127 119 112 119Z" }
131 path { d: "M112 137L97 121M112 119L127 103", opacity: "0.65" }
132 }
133 if paused {
134 circle { cx: "179", cy: "174", r: "23", fill: "var(--color-paper-theme)", stroke: "currentColor", stroke_width: "0.8" }
135 path { d: "M174 167V181M184 167V181", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round" }
136 } else {
137 path { d: "M184 101V113M178 107H190M49 151V159M45 155H53", stroke: "currentColor", stroke_width: "1", stroke_linecap: "round", opacity: "0.55" }
138 circle { cx: "184", cy: "145", r: "2", fill: "currentColor", opacity: "0.5" }
139 }
140 }
141 }
142}
143
144#[component]
145fn TagDetailContent(tag: String) -> Element {
146 let entry_id = use_hook(crate::bridges::navigation::entry_id);
147 let mut order = use_signal(|| {
148 crate::bridges::navigation::read_state("tag-order").unwrap_or(TagSortOrder::Latest)
149 });
150 use_effect(move || {
153 crate::bridges::navigation::write_state(&entry_id, "tag-order", &order());
154 });
155 let request_tag = tag.clone();
157 let mut posts_res = use_server_future(move || get_posts_by_tag(request_tag.clone()))?;
158 let posts_data = posts_res.read();
159
160 match posts_data.as_ref() {
161 Some(Ok(PostListResponse { posts, total })) => {
162 let sorted = sorted_posts(posts, order());
163 let related = related_tags(posts, &tag);
164 let recent = posts
165 .iter()
166 .max_by_key(|post| post.published_at.unwrap_or(post.created_at));
167 let truncated = *total > posts.len() as i64;
168 rsx! {
169 section { class: "tag-results", "data-vt-list": "true", aria_label: "主题文章", aria_busy: "false",
170 p { class: "sr-only", role: "status", aria_atomic: "true",
171 "已加载 {posts.len()} 篇文章,按{order().label()}排序。"
172 }
173 div { class: "tag-toolbar tag-enter",
174 div { class: "tag-collection-meta",
175 p { class: "tag-count", "共 " strong { "{total}" } " 篇文章" }
176 if let Some(post) = recent {
177 p { class: "tag-recent", "最近收录 " time { datetime: post.formatted_date(), "{post.formatted_date()}" } }
178 }
179 }
180 if posts.len() > 1 {
181 div { class: "tag-sort-group",
182 if truncated { span { class: "tag-sort-note", "当前列表排序" } }
183 div { class: "tag-sort", role: "group", aria_label: "文章排序", "data-order": order().value(),
184 for choice in [TagSortOrder::Latest, TagSortOrder::Earliest] {
185 button {
186 r#type: "button",
187 aria_pressed: (order() == choice).to_string(),
188 onclick: move |_| { if order() != choice { order.set(choice); } },
189 "{choice.label()}"
190 }
191 }
192 }
193 }
194 }
195 }
196 if truncated {
197 p { class: "tag-limit-note", "共 {total} 篇,当前展示最近 {posts.len()} 篇;排序和相关标签基于当前列表。" }
198 }
199 if posts.is_empty() {
200 TagStatus {}
201 } else {
202 div { class: "tag-post-list", "data-order": order().value(),
203 for (index, post) in sorted.into_iter().enumerate() {
204 div {
205 key: "{post.id}",
206 class: if index == 0 { "tag-entry tag-featured tag-enter" } else { "tag-entry tag-enter" },
207 style: "--tag-delay: {index.min(5) * 50}ms",
208 if index == 0 {
209 span { class: "tag-featured-label", span { aria_hidden: "true", "01 /" } "{order().opening()}" }
210 } else {
211 span { class: "tag-entry-number", aria_hidden: "true", {format!("{:02}", index + 1)} }
212 }
213 PostCard { post: post.clone(), compact: true }
214 span { class: "tag-entry-arrow", aria_hidden: "true", "↗" }
215 }
216 }
217 }
218 }
219 if !related.is_empty() {
220 nav { class: "tag-related tag-enter", aria_label: "相关标签",
221 div { class: "tag-related-heading",
222 div {
223 p { class: "tag-eyebrow", "CONNECTED NOTES / 相邻枝叶" }
224 h2 { "沿着枝叶,继续阅读" }
225 }
226 Link { class: "tag-text-link", to: Route::Archives {},
227 span { "全部标签" } span { class: "tag-arrow", aria_hidden: "true", "↗" }
228 }
229 }
230 div { class: "tag-related-links",
231 for name in related {
232 Link { key: "{name}", class: "tag-related-link", to: Route::TagDetail { tag: name.clone() },
233 span { class: "tag-related-mark", aria_hidden: "true", "#" }
234 span { "{name}" }
235 span { class: "tag-arrow", aria_hidden: "true", "↗" }
236 }
237 }
238 }
239 }
240 }
241 }
242 }
243 }
244 Some(Err(_)) => rsx! {
245 section { class: "tag-results", "data-vt-list": "true", aria_label: "主题文章", aria_busy: "false",
246 TagStatus { failed: true, on_retry: move |_| {
247 if !posts_res.pending() { posts_res.restart(); }
248 } }
249 }
250 },
251 _ => rsx! { TagPostsLoading {} },
252 }
253}
254
255#[component]
256fn TagStatus(
257 #[props(default = false)] failed: bool,
258 #[props(default)] on_retry: EventHandler<()>,
259) -> Element {
260 rsx! {
261 div { class: "tag-status tag-enter", "data-state": if failed { "error" } else { "empty" },
262 div { class: "tag-status-art", aria_hidden: "true", TagIllustration { paused: failed } }
263 div { class: "tag-status-copy",
264 p { class: "tag-eyebrow", if failed { "A LITTLE PAUSE / 稍作停留" } else { "STILL GROWING / 等待生长" } }
265 h2 { if failed { "文字暂时未能展开" } else { "这里的文字,还在生长" } }
266 p { class: "tag-status-description", role: "status", aria_atomic: "true",
267 if failed { "文章暂时未能加载,稍等片刻,再试一次。" }
268 else { "这个主题下还没有发布的文章,先去别的枝叶间逛逛吧。" }
269 }
270 div { class: "tag-status-actions",
271 if failed {
272 button { class: "tag-button", r#type: "button", onclick: move |_| on_retry.call(()),
273 span { class: "tag-retry-icon", aria_hidden: "true", "↻" } "重新加载"
274 }
275 }
276 Link { class: "tag-text-link", to: Route::Archives {},
277 span { "返回归档" } span { class: "tag-arrow", aria_hidden: "true", "↗" }
278 }
279 }
280 }
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::models::post::PostStatus;
289 use chrono::{TimeZone, Utc};
290
291 fn post(id: i32, created_day: u32, published_day: Option<u32>, tags: &[&str]) -> PostListItem {
292 let date = |day| Utc.with_ymd_and_hms(2026, 7, day, 0, 0, 0).unwrap();
293 PostListItem {
294 id,
295 author_id: 1,
296 title: format!("Article {id}"),
297 slug: format!("article-{id}"),
298 summary: None,
299 status: PostStatus::Published,
300 published_at: published_day.map(date),
301 created_at: date(created_day),
302 updated_at: date(created_day),
303 deleted_at: None,
304 tags: tags.iter().map(|tag| (*tag).to_string()).collect(),
305 cover_image: None,
306 reading_time: 1,
307 word_count: 0,
308 }
309 }
310
311 #[test]
312 fn sorting_uses_publication_then_creation_and_stable_id_ties() {
313 let posts = vec![
314 post(4, 1, Some(10), &[]),
315 post(2, 20, Some(5), &[]),
316 post(3, 10, None, &[]),
317 post(1, 2, None, &[]),
318 ];
319 let ids = |order| {
320 sorted_posts(&posts, order)
321 .iter()
322 .map(|post| post.id)
323 .collect::<Vec<_>>()
324 };
325 assert_eq!(ids(TagSortOrder::Latest), [4, 3, 2, 1]);
326 assert_eq!(ids(TagSortOrder::Earliest), [1, 2, 3, 4]);
327 assert_eq!(
328 posts.iter().map(|post| post.id).collect::<Vec<_>>(),
329 [4, 2, 3, 1]
330 );
331 assert!(sorted_posts(&[], TagSortOrder::Latest).is_empty());
332 }
333
334 #[test]
335 fn related_tags_count_articles_exclude_self_and_break_ties_by_name() {
336 let posts = vec![
337 post(
338 1,
339 1,
340 None,
341 &["Architecture", "Rust", "Rust", "CI-CD", "", " "],
342 ),
343 post(2, 2, None, &["Architecture", "Docker", "CI-CD"]),
344 ];
345 assert_eq!(
346 related_tags(&posts, "Architecture"),
347 ["CI-CD", "Docker", "Rust"]
348 );
349 assert!(related_tags(&[post(1, 1, None, &["Architecture"])], "Architecture").is_empty());
350 assert!(related_tags(&[], "Architecture").is_empty());
351 }
352
353 #[test]
354 fn related_tags_keep_the_six_most_connected_topics() {
355 let posts = vec![
356 post(1, 1, None, &["H", "G", "F", "E", "D", "C", "B", "A"]),
357 post(2, 2, None, &["H", "G"]),
358 ];
359 assert_eq!(related_tags(&posts, "A"), ["G", "H", "B", "C", "D", "E"]);
360 }
361}