1use chrono::{DateTime, Datelike, Utc};
9use leptos::prelude::*;
10use leptos_meta::Title;
11use leptos_router::{lazy_route, LazyRoute};
12
13use crate::api::error::ApiError;
14use crate::api::posts::{
15 list_archive_posts, list_tags, ArchiveResponse, TagListResponse, ARCHIVE_PAGE_SIZE,
16};
17use crate::components::empty_state::EmptyState;
18use crate::components::skeletons::archive_skeleton::ArchiveBodySkeleton;
19use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
20use crate::components::ui::{Button, ButtonVariant, LoadError};
21use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
22use crate::models::post::Tag;
23use crate::routes::Route;
24
25const TAG_PREVIEW: usize = 16;
27const ROW_TAGS: usize = 2;
29
30#[derive(Clone, Debug, PartialEq)]
32pub struct IndexRow {
33 pub id: i32,
34 pub slug: String,
35 pub title: String,
36 pub date: DateTime<Utc>,
37 pub tags: Vec<String>,
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub(crate) struct YearGroup {
42 pub year: i32,
43 pub rows: Vec<IndexRow>,
44}
45
46pub(crate) fn group_by_year(rows: &[IndexRow]) -> Vec<YearGroup> {
48 let mut groups: Vec<YearGroup> = Vec::new();
49 for row in rows {
50 let year = row.date.year();
51 match groups.last_mut() {
52 Some(group) if group.year == year => group.rows.push(row.clone()),
53 _ => groups.push(YearGroup {
54 year,
55 rows: vec![row.clone()],
56 }),
57 }
58 }
59 groups
60}
61
62pub(crate) fn ranked_tags(mut tags: Vec<Tag>) -> Vec<Tag> {
64 tags.retain(|t| t.post_count > 0);
65 tags.sort_by(|a, b| {
66 b.post_count
67 .cmp(&a.post_count)
68 .then_with(|| a.name.cmp(&b.name))
69 });
70 tags
71}
72
73fn visible_tags<'a>(tags: &'a [Tag], current: Option<&str>, expanded: bool) -> Vec<&'a Tag> {
75 if expanded {
76 return tags.iter().collect();
77 }
78 let mut shown: Vec<&Tag> = tags.iter().take(TAG_PREVIEW).collect();
79 if let Some(current) = current {
80 if !shown.iter().any(|t| t.name == current) {
81 if let Some(tag) = tags.iter().find(|t| t.name == current) {
82 shown.push(tag);
83 }
84 }
85 }
86 shown
87}
88
89pub struct ArchivesRoute {
91 posts: RouteData<ArchiveResponse>,
92 tags: RouteData<TagListResponse>,
93}
94
95#[leptos::lazy]
96async fn fetch_archive() -> PageJson<ArchiveResponse> {
97 PageJson::new(list_archive_posts(1).await)
98}
99
100#[leptos::lazy]
102pub(crate) async fn fetch_tags() -> PageJson<TagListResponse> {
103 PageJson::new(list_tags().await)
104}
105
106#[lazy_route]
107impl LazyRoute for ArchivesRoute {
108 fn data() -> Self {
109 Self {
110 posts: route_data(|| (), unit(fetch_archive)),
111 tags: route_data(|| (), unit(fetch_tags)),
112 }
113 }
114
115 fn view(this: Self) -> AnyView {
116 let ArchivesRoute { posts, tags } = this;
117 view! {
118 <Title text="归档 · Yggdrasil"/>
119 <div class="archive">
120 <header class="archive-header page-enter">
121 <h1 class="archive-title">"归档"</h1>
122 </header>
123 <Suspense fallback=|| {
124 view! {
125 <DelayedSkeleton>
126 <ArchiveBodySkeleton/>
127 </DelayedSkeleton>
128 }
129 }>
130 {move || Suspend::new(async move {
131 let first = posts.await.ok();
133 let tags = tag_list(tags.await);
134 view! { <ArchivesBody first=first tags=tags/> }
135 })}
136 </Suspense>
137 </div>
138 }
139 .into_any()
140 }
141}
142
143impl ArchivesRoute {
144 pub fn prefetch_data() {
146 prefetch_route_data((), unit(fetch_archive));
147 prefetch_route_data((), unit(fetch_tags));
148 }
149}
150
151#[component]
154fn ArchivesBody(first: Option<ArchiveResponse>, tags: Vec<Tag>) -> impl IntoView {
155 let listing = RwSignal::new(first);
156 let older = RwSignal::new(Vec::<IndexRow>::new());
157 let next_page = RwSignal::new(2_i32);
158 let loading_more = RwSignal::new(false);
159 let load_more_failed = RwSignal::new(false);
160 let exhausted = RwSignal::new(false);
161 let retrying = RwSignal::new(false);
162 let retry_failed = RwSignal::new(false);
163
164 let loaded = Memo::new(move |_| listing.with(Option::is_some));
165 let total = move || listing.with(|l| l.as_ref().map_or(0, |r| r.total));
166 let rows = Signal::derive(move || {
167 let mut rows: Vec<IndexRow> = listing.with(|l| {
168 l.as_ref()
169 .map(|r| r.posts.iter().cloned().map(archive_row).collect())
170 .unwrap_or_default()
171 });
172 older.with(|older| rows.extend(older.iter().cloned()));
173 rows
174 });
175 let has_more = move || !exhausted.get() && (rows.with(Vec::len) as i64) < total();
176
177 let on_more = move |_| {
178 loading_more.set(true);
179 load_more_failed.set(false);
180 let page = next_page.get_untracked();
181 leptos::task::spawn_local(async move {
182 match list_archive_posts(page).await {
183 Ok(response) => {
184 let _ = exhausted.try_set(response.posts.len() < ARCHIVE_PAGE_SIZE);
185 let _ = older.try_update(|older| {
186 older.extend(response.posts.into_iter().map(archive_row));
187 });
188 let _ = next_page.try_update(|page| *page += 1);
189 }
190 Err(_) => {
191 let _ = load_more_failed.try_set(true);
192 }
193 }
194 let _ = loading_more.try_set(false);
195 });
196 };
197
198 let on_retry = Callback::new(move |()| {
199 retrying.set(true);
200 retry_failed.set(false);
201 leptos::task::spawn_local(async move {
202 let result = list_archive_posts(1).await;
203 let _ = retrying.try_set(false);
204 match result {
205 Ok(response) => {
206 let _ = listing.try_set(Some(response));
207 }
208 Err(_) => {
209 let _ = retry_failed.try_set(true);
210 }
211 }
212 });
213 });
214
215 view! {
216 {move || {
217 loaded
218 .get()
219 .then(|| {
220 view! {
221 <p class="archive-summary tabular-nums page-enter">
222 {move || archive_summary(&rows.get(), total(), has_more())}
223 </p>
224 }
225 })
226 }}
227 <TagFilter tags=tags current=None/>
228 {move || {
229 if !loaded.get() {
230 return view! {
231 <div class="archive-years" data-vt-list="true">
232 <LoadError
233 title="归档暂时没能加载"
234 retrying=retrying
235 retry_failed=retry_failed
236 on_retry=on_retry
237 />
238 </div>
239 }
240 .into_any();
241 }
242 if total() == 0 {
243 return view! {
244 <span hidden data-vt-list="true"></span>
245 <EmptyState title="还没有文章" description="发布文章后,这里会按年份排好。"/>
246 }
247 .into_any();
248 }
249 view! {
250 <YearBlocks rows=rows/>
251 {move || {
252 has_more()
253 .then(|| {
254 view! {
255 <div class="archive-more">
256 <Button
257 variant=ButtonVariant::Secondary
258 loading=loading_more
259 onclick=on_more
260 >
261 "加载更早的文章"
262 </Button>
263 {move || {
264 load_more_failed
265 .get()
266 .then(|| {
267 view! {
268 <p class="archive-more-error" role="alert">
269 "没能加载更早的文章,再试一次。"
270 </p>
271 }
272 })
273 }}
274 </div>
275 }
276 })
277 }}
278 }
279 .into_any()
280 }}
281 }
282}
283
284fn archive_row(post: crate::models::archive::ArchivePost) -> IndexRow {
285 IndexRow {
286 id: post.id,
287 slug: post.slug,
288 title: post.title,
289 date: post.published_at,
290 tags: post.tags,
291 }
292}
293
294fn archive_summary(rows: &[IndexRow], total: i64, has_more: bool) -> String {
296 match (rows.first(), rows.last()) {
297 (Some(newest), Some(oldest)) if !has_more => {
298 let (from, to) = (oldest.date.year(), newest.date.year());
299 if from == to {
300 format!("{total} 篇文章,写于 {to}。")
301 } else {
302 format!("{total} 篇文章,写于 {from} — {to}。")
303 }
304 }
305 (Some(newest), _) => format!(
306 "{total} 篇文章,最近一篇写于 {}。",
307 crate::utils::time::ymd(&newest.date, '.')
308 ),
309 _ => "还没有文章。".to_string(),
310 }
311}
312
313pub(crate) fn tag_list(data: Result<TagListResponse, ApiError>) -> Vec<Tag> {
315 data.map(|r| ranked_tags(r.tags)).unwrap_or_default()
316}
317
318#[component]
322pub(crate) fn TagFilter(tags: Vec<Tag>, current: Option<String>) -> impl IntoView {
323 let entry_id = crate::bridges::navigation::entry_id();
324 let expanded = RwSignal::new(false);
326 Effect::new(move |previous: Option<()>| {
327 if previous.is_none() {
328 if let Some(saved) = crate::bridges::navigation::read_state::<bool>("archive-tags-open")
329 {
330 if saved != expanded.get_untracked() {
331 expanded.set(saved);
332 }
333 }
334 }
335 crate::bridges::navigation::write_state(&entry_id, "archive-tags-open", &expanded.get());
336 });
337 if tags.is_empty() {
338 return ().into_any();
339 }
340 let total = tags.len();
341 let shown_tags = tags.clone();
342 let list_current = current.clone();
343
344 view! {
345 <section class="archive-tags page-enter" aria-labelledby="archive-tags-title">
346 <div class="archive-tags-head">
347 <h2 id="archive-tags-title" class="archive-tags-title">
348 "标签 " <span class="tabular-nums">{total}</span>
349 </h2>
350 {(total > TAG_PREVIEW)
351 .then(|| {
352 view! {
353 <button
354 type="button"
355 class="archive-tags-toggle"
356 aria-expanded=move || expanded.get().to_string()
357 on:click=move |_| expanded.update(|open| *open = !*open)
358 >
359 {move || {
360 if expanded.get() { "收起".to_string() } else { format!("全部 {total} 个") }
361 }}
362 </button>
363 }
364 })}
365 </div>
366 <div class="archive-tags-list">
367 {move || {
368 visible_tags(&shown_tags, list_current.as_deref(), expanded.get())
369 .into_iter()
370 .map(|tag| {
371 if list_current.as_deref() == Some(tag.name.as_str()) {
372 view! {
373 <a
374 class="chip is-current"
375 href=Route::Archives.to_string()
376 aria-current="page"
377 title="回到全部归档"
378 >
379 {tag.name.clone()}
380 <span class="chip-count">{tag.post_count}</span>
381 </a>
382 }
383 .into_any()
384 } else {
385 view! {
386 <a
387 class="chip"
388 href=Route::TagDetail {
389 tag: tag.name.clone(),
390 }
391 .to_string()
392 >
393 {tag.name.clone()}
394 <span class="chip-count">{tag.post_count}</span>
395 </a>
396 }
397 .into_any()
398 }
399 })
400 .collect_view()
401 }}
402 </div>
403 </section>
404 }
405 .into_any()
406}
407
408#[component]
413pub(crate) fn YearBlocks(
414 #[prop(into)] rows: Signal<Vec<IndexRow>>,
415 #[prop(optional)] hide_tag: Option<String>,
416) -> impl IntoView {
417 let years = Memo::new(move |_| {
419 rows.with(|rows| {
420 group_by_year(rows)
421 .into_iter()
422 .enumerate()
423 .map(|(index, group)| (group.year, index))
424 .collect::<Vec<_>>()
425 })
426 });
427
428 view! {
429 <section class="archive-years" data-vt-list="true" aria-label="按年份">
430 <For
431 each=move || years.get()
432 key=|(year, _)| *year
433 children=move |(year, index)| {
434 view! { <YearBlock year=year index=index rows=rows hide_tag=hide_tag.clone()/> }
435 }
436 />
437 </section>
438 }
439}
440
441#[component]
442fn YearBlock(
443 year: i32,
444 index: usize,
445 rows: Signal<Vec<IndexRow>>,
446 hide_tag: Option<String>,
447) -> impl IntoView {
448 let group_rows = Memo::new(move |_| {
449 rows.with(|rows| {
450 rows.iter()
451 .filter(|row| row.date.year() == year)
452 .cloned()
453 .collect::<Vec<_>>()
454 })
455 });
456
457 view! {
458 <div
459 class="archive-year page-enter"
460 style=format!("--enter-index: {}", index.min(5))
461 >
462 <div class="archive-year-label">
463 <h2 id=format!("y{year}") class="archive-year-number">
464 {year}
465 </h2>
466 <p class="archive-year-count tabular-nums">
467 {move || format!("{} 篇", group_rows.with(Vec::len))}
468 </p>
469 </div>
470 <ol class="archive-rows" aria-labelledby=format!("y{year}")>
471 <For
472 each=move || group_rows.get()
473 key=|row| row.id
474 children=move |row| {
475 view! {
476 <li>
477 <ArchiveRow row=row hide_tag=hide_tag.clone()/>
478 </li>
479 }
480 }
481 />
482 </ol>
483 </div>
484 }
485}
486
487#[component]
488fn ArchiveRow(row: IndexRow, hide_tag: Option<String>) -> impl IntoView {
489 let tags = row
490 .tags
491 .iter()
492 .filter(|t| Some(t.as_str()) != hide_tag.as_deref())
493 .take(ROW_TAGS)
494 .cloned()
495 .collect::<Vec<_>>()
496 .join(" · ");
497 let datetime = crate::utils::time::ymd(&row.date, '-');
498 let date = crate::utils::time::md(&row.date, '.');
499 let tags = (!tags.is_empty()).then(|| view! { <span class="archive-entry-tags">{tags}</span> });
500 view! {
501 <a
502 class="archive-entry"
503 href=Route::PostDetail {
504 slug: row.slug.clone(),
505 }
506 .to_string()
507 data-vt-post-link=row.id.to_string()
508 >
509 <time class="archive-entry-date tabular-nums" datetime=datetime>
510 {date}
511 </time>
512 <span
513 class="archive-entry-title"
514 data-vt-post-id=row.id.to_string()
515 data-vt-role="title"
516 >
517 {row.title}
518 </span>
519 {tags}
520 </a>
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use chrono::TimeZone;
528
529 fn row(id: i32, y: i32, m: u32, d: u32) -> IndexRow {
530 IndexRow {
531 id,
532 slug: format!("post-{id}"),
533 title: format!("文章 {id}"),
534 date: Utc.with_ymd_and_hms(y, m, d, 8, 0, 0).unwrap(),
535 tags: Vec::new(),
536 }
537 }
538
539 fn tag(id: i32, name: &str, post_count: i64) -> Tag {
540 Tag {
541 id,
542 name: name.into(),
543 post_count,
544 }
545 }
546
547 #[test]
548 fn groups_adjacent_rows_by_year() {
549 let rows = [
550 row(1, 2026, 10, 15),
551 row(2, 2026, 1, 2),
552 row(3, 2024, 5, 22),
553 ];
554 let groups = group_by_year(&rows);
555 let years: Vec<_> = groups.iter().map(|g| (g.year, g.rows.len())).collect();
556 assert_eq!(years, [(2026, 2), (2024, 1)]);
557 assert!(group_by_year(&[]).is_empty());
558 }
559
560 #[test]
561 fn summary_names_the_year_range_only_when_everything_is_loaded() {
562 let rows = [row(1, 2026, 10, 15), row(2, 2020, 12, 11)];
563 assert_eq!(
564 archive_summary(&rows, 2, false),
565 "2 篇文章,写于 2020 — 2026。"
566 );
567 assert_eq!(
568 archive_summary(&rows, 150, true),
569 "150 篇文章,最近一篇写于 2026.10.15。"
570 );
571 assert_eq!(
572 archive_summary(&rows[..1], 1, false),
573 "1 篇文章,写于 2026。"
574 );
575 assert_eq!(archive_summary(&[], 0, false), "还没有文章。");
576 }
577
578 #[test]
579 fn ranks_tags_by_count_and_drops_empty_ones() {
580 let ranked = ranked_tags(vec![
581 tag(1, "Go", 4),
582 tag(2, "Rust", 16),
583 tag(3, "草稿", 0),
584 tag(4, "CSS", 4),
585 ]);
586 let names: Vec<_> = ranked.iter().map(|t| t.name.as_str()).collect();
587 assert_eq!(names, ["Rust", "CSS", "Go"]);
588 }
589
590 #[test]
591 fn collapsed_tags_still_show_the_current_one() {
592 let tags: Vec<Tag> = (0..20)
593 .map(|i| tag(i, &format!("t{i}"), 100 - i64::from(i)))
594 .collect();
595 let names = |shown: Vec<&Tag>| shown.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
596 assert_eq!(visible_tags(&tags, None, false).len(), TAG_PREVIEW);
597 let shown = names(visible_tags(&tags, Some("t19"), false));
598 assert_eq!(shown.len(), TAG_PREVIEW + 1);
599 assert_eq!(shown.last().map(String::as_str), Some("t19"));
600 assert_eq!(visible_tags(&tags, Some("t3"), false).len(), TAG_PREVIEW);
601 assert_eq!(visible_tags(&tags, None, true).len(), 20);
602 }
603}