1use 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#[derive(Clone, PartialEq)]
25struct YearGroup {
26 year: String,
27 months: Vec<MonthGroup>,
28}
29
30#[derive(Clone, PartialEq)]
32struct MonthGroup {
33 month: String,
34 month_en: String,
35 posts: Vec<PostListItem>,
36}
37
38fn 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 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 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 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#[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#[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#[component]
202fn ArchivesContent() -> Element {
203 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#[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#[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#[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}