Skip to main content

yggdrasil/pages/
archives.rs

1//! 归档页面模块。
2//!
3//! 对应路由 `/archives`。
4//! 顶部标签索引复用可折叠卡片与 TagChip,独立加载,避免标签接口失败影响时间归档。
5//!
6//! 数据获取:通过 `use_server_future` 调用 `list_published_posts(1, 10000)` server function,
7//! 一次性拉取全部已发布文章,然后在内存中按发布日期的年、月进行分组展示。
8//! 在 `wasm32` 目标下,server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
9//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
10
11use dioxus::prelude::*;
12use dioxus::router::components::Link;
13
14use crate::api::posts::{list_published_posts, list_tags, PostListResponse, TagListResponse};
15use crate::components::empty_state::EmptyState;
16use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
17use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
18use crate::components::skeletons::tags_skeleton::TagsSkeleton;
19use crate::components::ui::{CollapsibleSettingsCard, TagChip, BTN_OUTLINE};
20use crate::models::post::PostListItem;
21use crate::router::Route;
22
23/// 按年份分组的文章归档结构。
24#[derive(Clone, PartialEq)]
25struct YearGroup {
26    year: String,
27    months: Vec<MonthGroup>,
28}
29
30/// 按月份分组的文章归档结构。
31#[derive(Clone, PartialEq)]
32struct MonthGroup {
33    month: String,
34    month_en: String,
35    posts: Vec<PostListItem>,
36}
37
38/// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。
39///
40/// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。
41fn group_posts(posts: &[PostListItem]) -> Vec<YearGroup> {
42    let mut years: Vec<YearGroup> = vec![];
43
44    for post in posts {
45        let date_str = post.formatted_date();
46
47        // 将日期字符串拆分为 [年, 月, 日] 三部分。
48        let parts: Vec<&str> = date_str.split('-').collect();
49        if parts.len() != 3 {
50            continue;
51        }
52        let year = parts[0].to_string();
53        let month_num = parts[1];
54        // 将数字月份转换为英文月份名称,用于展示与锚点 id。
55        let month_en = match month_num {
56            "01" => "January",
57            "02" => "February",
58            "03" => "March",
59            "04" => "April",
60            "05" => "May",
61            "06" => "June",
62            "07" => "July",
63            "08" => "August",
64            "09" => "September",
65            "10" => "October",
66            "11" => "November",
67            "12" => "December",
68            _ => month_num,
69        };
70
71        // 尝试追加到当前年份与月份的组中;如果不匹配则新建分组。
72        if let Some(yg) = years.last_mut() {
73            if yg.year == year {
74                if let Some(mg) = yg.months.last_mut() {
75                    if mg.month_en == month_en {
76                        mg.posts.push(post.clone());
77                        continue;
78                    }
79                }
80                yg.months.push(MonthGroup {
81                    month: month_num.to_string(),
82                    month_en: month_en.to_string(),
83                    posts: vec![post.clone()],
84                });
85                continue;
86            }
87        }
88        years.push(YearGroup {
89            year,
90            months: vec![MonthGroup {
91                month: month_num.to_string(),
92                month_en: month_en.to_string(),
93                posts: vec![post.clone()],
94            }],
95        });
96    }
97
98    years
99}
100
101/// 归档页面组件,对应路由 `/archives`。
102///
103/// 渲染页面标题,并委托给 `ArchivesContent` 展示按年月分组的文章列表。
104#[component]
105pub fn Archives() -> Element {
106    rsx! {
107        div { class: "archives-page animate-page-enter",
108            header { class: "archive-intro",
109                p { class: "archive-eyebrow", span { aria_hidden: "true" } "THE ARCHIVE / 时间的索引" }
110                div { class: "archive-intro-line",
111                    h1 { "归档" span { class: "archive-title-dot", "。" } }
112                    p { "把片刻写成文字,把文字留给时间。" }
113                }
114            }
115            SuspenseBoundary {
116                fallback: move |_| rsx! {
117                    div { "data-vt-list-pending": "true",
118                        DelayedSkeleton { TagsSkeleton {} }
119                    }
120                },
121                ArchiveTags {}
122            }
123            SuspenseBoundary {
124                fallback: move |_| rsx! { DelayedSkeleton { ArchiveSkeleton { include_tags: false } } },
125                ArchivesContent {}
126            }
127        }
128    }
129}
130
131/// 标签云保持挂载,让收起也能完成高度与标签淡出动画。
132#[component]
133fn ArchiveTags() -> Element {
134    let entry_id = use_hook(crate::bridges::navigation::entry_id);
135    let mut tags_open =
136        use_signal(|| crate::bridges::navigation::read_state("archive-tags-open").unwrap_or(false));
137    use_effect(move || {
138        crate::bridges::navigation::write_state(&entry_id, "archive-tags-open", &tags_open());
139    });
140    let mut tags_res = use_server_future(list_tags)?;
141    let tags_data = tags_res.read();
142    let summary = match &*tags_data {
143        Some(Ok(TagListResponse { tags })) => {
144            format!("{} 个标签 · 选择一个话题,发现相关文章", tags.len())
145        }
146        Some(Err(_)) => "标签暂时未能加载,仍可浏览下方归档".to_string(),
147        None => "正在整理标签…".to_string(),
148    };
149
150    rsx! {
151        CollapsibleSettingsCard {
152            title: "标签索引",
153            summary,
154            enabled: true,
155            default_open: tags_open(),
156            on_toggle: move |_| tags_open.set(!tags_open()),
157            class: "archive-tags",
158            panel_id: "archive-tags-panel",
159            div { class: "archive-tags-body",
160                match &*tags_data {
161                    Some(Ok(TagListResponse { tags })) if !tags.is_empty() => rsx! {
162                        ul { class: "archive-tags-list", aria_label: "文章标签",
163                            for (index, tag) in tags.iter().enumerate() {
164                                li {
165                                    key: "{tag.id}",
166                                    style: "--tag-delay: {index.min(10) * 18}ms",
167                                    TagChip {
168                                        label: tag.name.clone(),
169                                        to: Route::TagDetail { tag: tag.name.clone() },
170                                        variant: "archive",
171                                        count: tag.post_count,
172                                    }
173                                }
174                            }
175                        }
176                    },
177                    Some(Ok(_)) => rsx! {
178                        p { class: "text-sm text-paper-secondary py-2", "还没有标签,先看看下方的文章吧。" }
179                    },
180                    Some(Err(_)) => rsx! {
181                        button {
182                            r#type: "button",
183                            class: "{BTN_OUTLINE} archive-tags-retry",
184                            onclick: move |_| tags_res.restart(),
185                            "重新加载标签"
186                        }
187                    },
188                    None => rsx! {
189                        p { class: "text-sm text-paper-secondary py-2", role: "status", "正在加载标签…" }
190                    },
191                }
192            }
193        }
194    }
195}
196
197/// 归档页面内容组件。
198///
199/// 通过 `use_server_future` 获取全部已发布文章,按年月分组后渲染;
200/// 加载中显示骨架屏,失败显示错误提示。
201#[component]
202fn ArchivesContent() -> Element {
203    // 一次性获取足够多的已发布文章,用于生成完整的年/月归档。
204    let mut posts_res = use_server_future(move || list_published_posts(1, 10000))?;
205
206    let posts_data = posts_res.read();
207    match &*posts_data {
208        Some(Ok(PostListResponse { posts, total })) => {
209            if *total == 0 {
210                rsx! {
211                    span { hidden: true, "data-vt-list": "true" }
212                    EmptyState {
213                        title: "还没有文章归档",
214                        description: "发布文章后,这里会自动按年月进行归档显示。",
215                    }
216                }
217            } else {
218                let grouped = group_posts(posts);
219                rsx! {
220                    section { class: "archive-timeline", "data-vt-list": "true", aria_labelledby: "archive-timeline-title",
221                        div { class: "archive-toolbar",
222                            div { class: "archive-toolbar-title",
223                                h2 { id: "archive-timeline-title", "时间归档" }
224                                span { class: "archive-total", "{total} 篇文章" }
225                            }
226                            span { class: "archive-order", "由近及远" span { aria_hidden: "true", "↓" } }
227                        }
228                        if grouped.len() > 1 {
229                            nav { class: "archive-year-nav", aria_label: "按年份跳转",
230                                for year_group in grouped.iter() {
231                                    a { key: "{year_group.year}", href: "#{year_group.year}", "{year_group.year}" }
232                                }
233                            }
234                        }
235                        for year_group in grouped.iter() {
236                            YearSection {
237                                key: "{year_group.year}",
238                                year_group: year_group.clone(),
239                            }
240                        }
241                        footer { class: "archive-colophon",
242                            span { aria_hidden: "true", "✳" }
243                            p { "写下的,替我们记得。" }
244                        }
245                    }
246                }
247            }
248        }
249        Some(Err(_)) => {
250            rsx! {
251                div { class: "archive-state", "data-vt-list": "true", role: "alert",
252                    h2 { "暂时没能翻开归档" }
253                    p { "文章加载失败,请稍后再试。" }
254                    button {
255                        r#type: "button",
256                        class: "{BTN_OUTLINE} archive-tags-retry",
257                        onclick: move |_| posts_res.restart(),
258                        "重新加载"
259                    }
260                }
261            }
262        }
263        None => {
264            rsx! {
265                DelayedSkeleton { ArchiveSkeleton { include_tags: false } }
266            }
267        }
268    }
269}
270
271/// 单一年份归档区块组件,展示该年份下的所有月份分组。
272#[component]
273fn YearSection(year_group: YearGroup) -> Element {
274    let total = year_group
275        .months
276        .iter()
277        .map(|m| m.posts.len())
278        .sum::<usize>();
279
280    rsx! {
281        section { class: "archive-year", aria_labelledby: "{year_group.year}",
282            header { class: "archive-year-heading",
283                div { class: "archive-year-sticky",
284                    span { class: "archive-year-kicker", aria_hidden: "true", "YEAR / 年份" }
285                    h3 { class: "archive-year-number", id: "{year_group.year}",
286                        a { class: "archive-header-link", href: "#{year_group.year}", "{year_group.year}" }
287                    }
288                    p { class: "archive-year-summary", "{total} 篇文章" span { aria_hidden: "true", "·" } "{year_group.months.len()} 个月" }
289                    span { class: "archive-year-rule", aria_hidden: "true" }
290                }
291            }
292            div { class: "archive-months",
293                for month_group in year_group.months.iter() {
294                    MonthSection {
295                        key: "{month_group.month_en}",
296                        month_group: month_group.clone(),
297                        year: year_group.year.clone(),
298                    }
299                }
300            }
301        }
302    }
303}
304
305/// 单一月份归档区块组件,展示该月份下的文章条目。
306#[component]
307fn MonthSection(month_group: MonthGroup, year: String) -> Element {
308    let count = month_group.posts.len();
309
310    rsx! {
311        section { class: "archive-month", aria_labelledby: "{year}-{month_group.month_en}",
312            h4 {
313                class: "archive-month-header",
314                id: "{year}-{month_group.month_en}",
315                a {
316                    class: "archive-header-link",
317                    href: "#{year}-{month_group.month_en}",
318                    span { class: "archive-month-number", "{month_group.month}" }
319                    span { "月" }
320                    span { class: "archive-month-name", "{month_group.month_en}" }
321                }
322                span { class: "archive-month-count", "{count:02} 篇" }
323            }
324            ul { class: "archive-posts",
325                for (index, post) in month_group.posts.iter().enumerate() {
326                    li { key: "{post.id}", style: "--archive-delay: {index.min(6) * 35}ms",
327                        ArchiveEntry { post: post.clone() }
328                    }
329                }
330            }
331        }
332    }
333}
334
335/// 整行使用原生链接,日期保留完整 datetime,标题在窄屏自然换行。
336#[component]
337fn ArchiveEntry(post: PostListItem) -> Element {
338    let date_str = post.formatted_date();
339    let day = post
340        .published_at
341        .unwrap_or(post.created_at)
342        .format("%d")
343        .to_string();
344    let topics = post
345        .tags
346        .iter()
347        .take(2)
348        .cloned()
349        .collect::<Vec<_>>()
350        .join(" / ");
351
352    rsx! {
353        Link {
354            class: "archive-entry",
355            "data-vt-post-link": "{post.id}",
356            to: Route::PostDetail { slug: post.slug.clone() },
357            time { class: "archive-date", datetime: "{date_str}", title: "{date_str}", aria_label: "{date_str}", "{day}" }
358            div { class: "archive-entry-copy",
359                h5 { class: "archive-entry-title", "data-vt-post-id": "{post.id}", "data-vt-role": "title", "{post.title}" }
360                if !topics.is_empty() || post.reading_time > 0 {
361                    div { class: "archive-entry-meta",
362                        if !topics.is_empty() {
363                            span { class: "archive-entry-topics", "{topics}" }
364                        }
365                        if !topics.is_empty() && post.reading_time > 0 {
366                            span { aria_hidden: "true", "·" }
367                        }
368                        if post.reading_time > 0 {
369                            span { class: "archive-reading-time", "{post.reading_time} 分钟阅读" }
370                        }
371                    }
372                }
373            }
374            span { class: "archive-entry-arrow", aria_hidden: "true", "↗" }
375        }
376    }
377}