1use chrono::{DateTime, Datelike, Utc};
10use leptos::prelude::*;
11use leptos_meta::Title;
12use leptos_router::hooks::use_params_map;
13use leptos_router::{lazy_route, LazyRoute};
14
15use crate::api::notes::list_notes;
16use crate::api::posts::{
17 latest_post_outline, list_published_posts, list_tags, PostListResponse, PostOutline,
18 TagListResponse,
19};
20use crate::api::settings::get_site_settings;
21use crate::components::empty_state::EmptyState;
22use crate::components::mascot::Mascot;
23use crate::components::post::post_meta::group_digits;
24use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
25use crate::components::skeletons::home_skeleton::HomeSkeleton;
26use crate::components::ui::{
27 Composer, ComposerInput, ComposerSend, Icon, IconName, LoadError, Pagination,
28};
29use crate::hooks::ssr_resource::{prefetch_route_data, route_data, PageJson, RouteData};
30use crate::models::note::{Note, NoteFilter, NotePage};
31use crate::models::post::{PostListItem, Tag};
32use crate::models::settings::{HomeTagline, SiteSettings};
33use crate::pages::archives::ranked_tags;
34use crate::pages::not_found::NotFound;
35use crate::routes::Route;
36
37const POSTS_PER_PAGE: i32 = 10;
38const TOP_TAGS: usize = 6;
40const TAG_CLOUD: usize = 10;
42const RECENT_NOTES: usize = 4;
44
45pub struct HomeRoute {
51 page: Memo<Option<i32>>,
53 posts: RouteData<Option<PostListResponse>>,
54 tags: RouteData<Option<TagListResponse>>,
55 settings: RouteData<Option<SiteSettings>>,
56 outline: RouteData<Option<PostOutline>>,
57 notes: RouteData<Option<NotePage>>,
58}
59
60#[leptos::lazy]
63async fn fetch_posts(page: Option<i32>) -> PageJson<Option<PostListResponse>> {
64 PageJson::new(match page {
65 Some(page) => list_published_posts(page, POSTS_PER_PAGE).await.map(Some),
66 None => Ok(None),
67 })
68}
69
70#[leptos::lazy]
71async fn fetch_home_tags(first: bool) -> PageJson<Option<TagListResponse>> {
72 PageJson::new(if first {
73 list_tags().await.map(Some)
74 } else {
75 Ok(None)
76 })
77}
78
79#[leptos::lazy]
80async fn fetch_settings(first: bool) -> PageJson<Option<SiteSettings>> {
81 PageJson::new(if first {
82 get_site_settings().await.map(Some)
83 } else {
84 Ok(None)
85 })
86}
87
88#[leptos::lazy]
89async fn fetch_outline(first: bool) -> PageJson<Option<PostOutline>> {
90 PageJson::new(if first {
91 latest_post_outline().await
92 } else {
93 Ok(None)
94 })
95}
96
97#[leptos::lazy]
98async fn fetch_notes(first: bool) -> PageJson<Option<NotePage>> {
99 PageJson::new(if first {
100 list_notes(NoteFilter::default()).await.map(Some)
101 } else {
102 Ok(None)
103 })
104}
105
106fn page_param(raw: Option<&str>) -> Option<i32> {
109 match raw {
110 None => Some(1),
111 Some(page) => page.parse::<i32>().ok().map(|page| page.max(1)),
112 }
113}
114
115#[lazy_route]
116impl LazyRoute for HomeRoute {
117 fn data() -> Self {
118 let params = use_params_map();
119 let page = Memo::new(move |_| page_param(params.read().get("page").as_deref()));
120 let is_first = Memo::new(move |_| page.get() == Some(1));
121 Self {
122 page,
123 posts: route_data(move || page.get(), fetch_posts),
124 tags: route_data(move || is_first.get(), fetch_home_tags),
125 settings: route_data(move || is_first.get(), fetch_settings),
126 outline: route_data(move || is_first.get(), fetch_outline),
127 notes: route_data(move || is_first.get(), fetch_notes),
128 }
129 }
130
131 fn view(this: Self) -> AnyView {
132 let HomeRoute {
133 page,
134 posts,
135 tags,
136 settings,
137 outline,
138 notes,
139 } = this;
140 let title = move || {
143 let title = match page.get().unwrap_or(1) {
144 1 => "Yggdrasil Blog".to_string(),
145 current_page => format!("第 {current_page} 页 · Yggdrasil"),
146 };
147 view! { <Title text=title/> }
148 };
149 view! {
150 {title}
151 <Suspense fallback=move || {
152 let current_page = page.get_untracked().unwrap_or(1);
153 view! {
154 <DelayedSkeleton>
155 <HomeSkeleton current_page=current_page/>
156 </DelayedSkeleton>
157 }
158 }>
159 {move || {
160 let current_page = page.get();
161 Suspend::new(async move {
162 match current_page {
163 None => view! { <NotFound/> }.into_any(),
165 Some(1) => {
166 let posts = posts.await.ok().flatten();
168 let tags = ranked_tags(
169 tags.await.ok().flatten().map(|r| r.tags).unwrap_or_default(),
170 );
171 let tagline = settings
172 .await
173 .ok()
174 .flatten()
175 .unwrap_or_default()
176 .home_tagline();
177 let outline = outline.await.ok().flatten();
178 let notes: Vec<Note> = notes
179 .await
180 .ok()
181 .flatten()
182 .map(|page| {
183 page.notes.into_iter().take(RECENT_NOTES).collect()
184 })
185 .unwrap_or_default();
186 view! {
187 <HomeDesk
188 posts=posts
189 tags=tags
190 tagline=tagline
191 outline=outline
192 notes=notes
193 />
194 }
195 .into_any()
196 }
197 Some(current_page) => {
198 let posts = posts.await.ok().flatten();
199 view! { <HomeArchive current_page=current_page posts=posts/> }
200 .into_any()
201 }
202 }
203 })
204 }}
205 </Suspense>
206 }
207 .into_any()
208 }
209}
210
211impl HomeRoute {
212 pub fn prefetch_data(page: Option<&str>) {
214 let page = page_param(page);
215 let first = page == Some(1);
216 prefetch_route_data(page, fetch_posts);
217 prefetch_route_data(first, fetch_home_tags);
218 prefetch_route_data(first, fetch_settings);
219 prefetch_route_data(first, fetch_outline);
220 prefetch_route_data(first, fetch_notes);
221 }
222}
223
224#[component]
229fn HomeDesk(
230 posts: Option<PostListResponse>,
231 tags: Vec<Tag>,
232 tagline: HomeTagline,
233 outline: Option<PostOutline>,
234 notes: Vec<Note>,
235) -> impl IntoView {
236 let listing = RwSignal::new(posts);
237 let latest = move || listing.with(|l| l.as_ref().and_then(|r| r.posts.first().cloned()));
238 let updated = Signal::derive(move || latest().as_ref().map(post_date));
239 let total = Signal::derive(move || listing.with(|l| l.as_ref().map(|r| r.total)));
240 let outline_for_latest =
242 move || latest().and_then(|post| outline.clone().filter(|o| o.slug == post.slug));
243
244 let top_tags = (!tags.is_empty()).then(|| {
245 let chips = tags
246 .iter()
247 .take(TOP_TAGS)
248 .map(|tag| {
249 view! {
250 <a
251 class="chip"
252 href=Route::TagDetail {
253 tag: tag.name.clone(),
254 }
255 .to_string()
256 >
257 {tag.name.clone()}
258 <span class="chip-count">{tag.post_count}</span>
259 </a>
260 }
261 })
262 .collect_view();
263 view! {
264 <div class="home-top-tags">
265 <span class="home-top-tags-label">"常读"</span>
266 {chips}
267 </div>
268 }
269 });
270 let tag_count = tags.len();
271 let subtitle = (!tagline.subtitle.is_empty()).then(|| {
272 view! {
273 <br/>
274 <span class="home-title-muted">{tagline.subtitle.clone()}</span>
275 }
276 });
277 let cloud = tags.into_iter().take(TAG_CLOUD).collect::<Vec<_>>();
278
279 view! {
280 <div class="home">
281 <section class="home-hero home-enter" aria-labelledby="home-title">
282 <HomeStatus updated=updated total=total tag_count=tag_count/>
283 {Mascot::Wave.img("mascot home-hero-mascot")}
284 <h1 id="home-title" class="home-title">
285 {tagline.title.clone()} {subtitle}
286 </h1>
287 <p class="home-intro">{tagline.intro.clone()}</p>
288 <HomeSearch/>
289 {top_tags}
290 </section>
291 {move || match listing.get() {
292 Some(PostListResponse { posts, total }) => {
293 let latest_section = posts
294 .first()
295 .cloned()
296 .map(|latest| {
297 view! {
298 <section
299 class="home-section home-enter"
300 aria-labelledby="home-latest-label"
301 >
302 <h2 id="home-latest-label" class="section-label">
303 "最新一篇"
304 </h2>
305 <LatestPanel post=latest outline=outline_for_latest()/>
306 </section>
307 }
308 });
309 let rest = posts.into_iter().skip(1).collect::<Vec<_>>();
310 view! {
311 {latest_section}
312 <HomeIndex current_page=1 posts=rest total=total/>
313 }
314 .into_any()
315 }
316 None => {
317 view! {
318 <section
319 id="home-posts"
320 class="home-index"
321 tabindex="-1"
322 aria-labelledby="home-index-title"
323 >
324 <span hidden data-vt-list="true"></span>
325 <HomeIndexHeading total=None/>
326 <HomePostsError
327 current_page=1
328 on_recovered=move |response| listing.set(Some(response))
329 />
330 </section>
331 }
332 .into_any()
333 }
334 }}
335 <HomeCorners notes=notes tags=cloud/>
336 </div>
337 }
338}
339
340#[component]
342fn HomeArchive(current_page: i32, posts: Option<PostListResponse>) -> impl IntoView {
343 let listing = RwSignal::new(posts);
344 view! {
345 <div class="home home-paged">
346 <header class="home-paged-header home-enter">
347 <a class="home-back" href=Route::Home.to_string()>
348 <Icon name=IconName::ArrowLeft size=15/>
349 "回到首页"
350 </a>
351 <h1 class="home-paged-title">"全部文章"</h1>
352 <p class="home-paged-page">{format!("第 {current_page} 页")}</p>
353 </header>
354 {move || match listing.get() {
355 Some(PostListResponse { posts, total }) => {
356 view! { <HomeIndex current_page=current_page posts=posts total=total/> }
357 .into_any()
358 }
359 None => {
360 view! {
361 <section
362 id="home-posts"
363 class="home-index"
364 tabindex="-1"
365 aria-label="文章列表"
366 >
367 <span hidden data-vt-list="true"></span>
368 <HomePostsError
369 current_page=current_page
370 on_recovered=move |response| listing.set(Some(response))
371 />
372 </section>
373 }
374 .into_any()
375 }
376 }}
377 </div>
378 }
379}
380
381#[component]
383fn HomeStatus(
384 #[prop(into)] updated: Signal<Option<String>>,
385 #[prop(into)] total: Signal<Option<i64>>,
386 tag_count: usize,
387) -> impl IntoView {
388 view! {
389 <p class="home-status">
390 <span class="home-pulse" aria-hidden="true"></span>
391 {move || {
392 updated
393 .get()
394 .map(|date| {
395 view! {
396 <span>
397 "最近更新 " <time class="tabular-nums">{date}</time>
398 </span>
399 }
400 })
401 }}
402 {move || {
403 total
404 .get()
405 .map(|total| {
406 view! {
407 <span class="home-status-sep" aria-hidden="true">
408 "·"
409 </span>
410 <a class="home-status-link" href="#home-posts">
411 <span class="tabular-nums">{total}</span>
412 " 篇文章"
413 </a>
414 }
415 })
416 }}
417 {(tag_count > 0)
418 .then(|| {
419 view! {
420 <span class="home-status-sep" aria-hidden="true">
421 "·"
422 </span>
423 <span>
424 <span class="tabular-nums">{tag_count}</span>
425 " 个标签"
426 </span>
427 }
428 })}
429 </p>
430 }
431}
432
433#[component]
435fn HomeSearch() -> impl IntoView {
436 let query = RwSignal::new(String::new());
437 view! {
438 <form
439 class="home-search"
440 role="search"
441 action="/search"
442 method="get"
443 on:submit=move |ev| {
444 ev.prevent_default();
445 let route = Route::Search {
446 q: query.get_untracked().trim().to_string(),
447 };
448 crate::navigation::push(&route.to_string());
449 }
450 >
451 <Composer>
452 <label class="sr-only" for="home-search-input">
453 "搜索文章"
454 </label>
455 <Icon name=IconName::Search size=18/>
456 <ComposerInput
457 id="home-search-input"
458 search=true
459 placeholder="搜索文章、标签,或一个念头…"
460 value=query
461 oninput=move |value: String| query.set(value)
462 />
463 <kbd class="kbd home-search-kbd" aria-hidden="true">
464 "⌘K"
465 </kbd>
466 <ComposerSend label="搜索"/>
467 </Composer>
468 </form>
469 }
470}
471
472#[component]
474fn LatestPanel(post: PostListItem, outline: Option<PostOutline>) -> impl IntoView {
475 let class = if outline.is_some() {
476 "home-latest"
477 } else {
478 "home-latest is-single"
479 };
480 let date = post_date(&post);
481 let words = group_digits(post.word_count);
482 let summary = post
483 .summary
484 .as_ref()
485 .filter(|s| !s.trim().is_empty())
486 .map(|summary| view! { <p class="home-latest-summary">{summary.clone()}</p> });
487 let tags = (!post.tags.is_empty())
488 .then(|| view! { <p class="home-latest-tags">{post.tags.join(" · ")}</p> });
489 let count = outline.as_ref().map(|outline| {
490 view! { <span class="home-latest-count">{format!("本文 {} 节 · ", outline.total)}</span> }
491 });
492 let aside = outline.map(|outline| {
493 let rest = (outline.total as usize > outline.headings.len()).then(|| {
494 view! {
495 <li class="home-latest-outline-rest">
496 {format!("还有 {} 节", outline.total as usize - outline.headings.len())}
497 </li>
498 }
499 });
500 let headings = outline
501 .headings
502 .iter()
503 .enumerate()
504 .map(|(i, heading)| {
505 view! {
506 <li>
507 <span class="home-latest-outline-num" aria-hidden="true">
508 {format!("{:02}", i + 1)}
509 </span>
510 <span>{heading.clone()}</span>
511 </li>
512 }
513 })
514 .collect_view();
515 view! {
516 <aside class="home-latest-outline" aria-label="本文目录">
517 <p class="home-latest-outline-total tabular-nums">
518 {format!("本文 {} 节", outline.total)}
519 </p>
520 <ol>{headings} {rest}</ol>
521 </aside>
522 }
523 });
524
525 view! {
526 <article class=class>
527 <div class="home-latest-main">
528 <p class="home-latest-meta tabular-nums">
529 <time>{date}</time>
530 {format!(" · {} 分钟 · {words} 字", post.reading_time)}
531 </p>
532 <h3 class="home-latest-title">
533 <a
534 class="home-latest-link"
535 href=Route::PostDetail {
536 slug: post.slug.clone(),
537 }
538 .to_string()
539 data-vt-post-link=post.id.to_string()
540 >
541 <span data-vt-post-id=post.id.to_string() data-vt-role="title">
542 {post.title.clone()}
543 </span>
544 </a>
545 </h3>
546 {summary}
547 <div class="home-latest-foot">
548 {tags} <span class="home-latest-more" aria-hidden="true">
549 {count} "阅读全文" <Icon name=IconName::ArrowRight size=15/>
550 </span>
551 </div>
552 </div>
553 {aside}
554 </article>
555 }
556}
557
558#[component]
559fn HomeIndexHeading(total: Option<i64>) -> impl IntoView {
560 view! {
561 <div class="home-index-heading">
562 <h2 id="home-index-title">
563 "全部文章"
564 {total
565 .map(|total| {
566 view! { <span class="home-index-total tabular-nums">{total}</span> }
567 })}
568 </h2>
569 <a class="home-index-archive" href=Route::Archives.to_string()>
570 "归档"
571 <Icon name=IconName::ArrowUpRight size=14/>
572 </a>
573 </div>
574 }
575}
576
577#[component]
579fn HomeIndex(current_page: i32, posts: Vec<PostListItem>, total: i64) -> impl IntoView {
580 let first_row = (i64::from(current_page) - 1) * i64::from(POSTS_PER_PAGE);
581 let heading = if current_page == 1 {
582 view! { <HomeIndexHeading total=Some(total)/> }.into_any()
583 } else {
584 view! {
585 <h2 id="home-index-title" class="sr-only">
586 {format!("第 {current_page} 页文章")}
587 </h2>
588 }
589 .into_any()
590 };
591 let body = if total == 0 {
592 view! { <EmptyState title="还没有文章" description="写下第一篇后,它会出现在这里。"/> }
593 .into_any()
594 } else if posts.is_empty() && first_row >= total {
595 view! {
596 <div class="home-index-end">
597 <EmptyState
598 mascot=Mascot::Sleepy
599 title="已经翻到最后了"
600 description="这一页没有文章,回到首页看看最近写下的吧。"
601 />
602 <a class="btn btn-secondary" href=Route::Home.to_string()>
603 "回到首页"
604 </a>
605 </div>
606 }
607 .into_any()
608 } else {
609 let groups = group_by_month(&posts)
610 .into_iter()
611 .enumerate()
612 .map(|(index, group)| {
613 let rows = group
614 .posts
615 .into_iter()
616 .map(|post| view! { <li><HomeRow post=post/></li> })
617 .collect_view();
618 view! {
619 <div
620 class="home-month home-enter"
621 style=format!("--home-delay: {}ms", index.min(5) * 45)
622 >
623 <h3 class="home-month-label">
624 <span class="tabular-nums">{group.label}</span>
625 </h3>
626 <ul>{rows}</ul>
627 </div>
628 }
629 })
630 .collect_view();
631 let prev_route = if current_page - 1 <= 1 {
632 Route::Home.to_string()
633 } else {
634 Route::HomePage {
635 page: current_page - 1,
636 }
637 .to_string()
638 };
639 let next_route = Route::HomePage {
640 page: current_page + 1,
641 }
642 .to_string();
643 view! {
644 {groups}
645 <Pagination
646 current_page=current_page
647 total=total
648 per_page=POSTS_PER_PAGE
649 prev_route=prev_route
650 next_route=next_route
651 />
652 }
653 .into_any()
654 };
655 view! {
656 <section
657 id="home-posts"
658 class="home-index"
659 tabindex="-1"
660 aria-labelledby="home-index-title"
661 >
662 <span hidden data-vt-list="true"></span>
663 {heading} {body}
664 </section>
665 }
666}
667
668#[component]
669fn HomeRow(post: PostListItem) -> impl IntoView {
670 let date = crate::utils::time::md(&post.published_at.unwrap_or(post.created_at), '.');
671 let summary = post
672 .summary
673 .as_ref()
674 .filter(|s| !s.trim().is_empty())
675 .map(|summary| view! { <span class="home-row-summary">{summary.clone()}</span> });
676 view! {
677 <a
678 class="home-row"
679 href=Route::PostDetail {
680 slug: post.slug.clone(),
681 }
682 .to_string()
683 data-vt-post-link=post.id.to_string()
684 >
685 <time class="home-row-date tabular-nums">{date}</time>
686 <span class="home-row-main">
687 <span
688 class="home-row-title"
689 data-vt-post-id=post.id.to_string()
690 data-vt-role="title"
691 >
692 {post.title.clone()}
693 </span>
694 {summary}
695 </span>
696 <span class="home-row-meta tabular-nums">
697 {format!("{} 分钟", post.reading_time)}
698 <span class="home-row-arrow">
699 <Icon name=IconName::ArrowRight size=15/>
700 </span>
701 </span>
702 </a>
703 }
704}
705
706#[component]
708fn HomeCorners(notes: Vec<Note>, tags: Vec<Tag>) -> impl IntoView {
709 let notes_body = if notes.is_empty() {
710 view! { <p class="home-corner-empty">"还没有公开的笔记。"</p> }.into_any()
711 } else {
712 let items = notes
713 .into_iter()
714 .map(|note| {
715 let href = Route::NoteDetail {
716 slug: note.slug.clone(),
717 book: String::new(),
718 }
719 .to_string();
720 let date =
721 crate::utils::time::md(¬e.published_at.unwrap_or(note.updated_at), '.');
722 view! {
723 <li>
724 <a href=href>
725 <span class="home-corner-note-title">{note.display_title()}</span>
726 <time class="tabular-nums">{date}</time>
727 </a>
728 </li>
729 }
730 })
731 .collect_view();
732 view! { <ul class="home-corner-notes">{items}</ul> }.into_any()
733 };
734 let tags_body = if tags.is_empty() {
735 view! { <p class="home-corner-empty">"还没有标签。"</p> }.into_any()
736 } else {
737 let items = tags
738 .into_iter()
739 .map(|tag| {
740 view! {
741 <a
742 href=Route::TagDetail {
743 tag: tag.name.clone(),
744 }
745 .to_string()
746 >
747 {tag.name.clone()}
748 <span class="tabular-nums">{tag.post_count}</span>
749 </a>
750 }
751 })
752 .collect_view();
753 view! { <div class="home-corner-tags">{items}</div> }.into_any()
754 };
755
756 view! {
757 <section class="home-corners home-enter" aria-label="更多入口">
758 <div class="home-corner">
759 <div class="home-corner-head">
760 <h2>"最近笔记"</h2>
761 <a class="home-corner-more" href=Route::Notes.to_string()>
762 "全部"
763 </a>
764 </div>
765 {notes_body}
766 </div>
767 <div class="home-corner">
768 <div class="home-corner-head">
769 <h2>"常去标签"</h2>
770 <a class="home-corner-more" href=Route::Archives.to_string()>
771 "全部"
772 </a>
773 </div>
774 {tags_body}
775 </div>
776 <div class="home-corner">
777 <div class="home-corner-head">
778 <h2>"去别处走走"</h2>
779 </div>
780 <ul class="home-corner-links">
781 <li>
782 <HomeCornerLink to=Route::Friends label="友链"/>
783 </li>
784 <li>
785 <HomeCornerLink to=Route::About label="关于"/>
786 </li>
787 <li>
788 <HomeCornerLink to=Route::Changelog label="更新日志"/>
789 </li>
790 <li>
791 <HomeCornerLink to=Route::WritingGuide label="写作指南"/>
792 </li>
793 </ul>
794 </div>
795 </section>
796 }
797}
798
799#[component]
800fn HomeCornerLink(to: Route, label: &'static str) -> impl IntoView {
801 view! {
802 <a class="home-corner-link" href=to.to_string()>
803 {label}
804 <Icon name=IconName::ArrowUpRight size=14/>
805 </a>
806 }
807}
808
809#[component]
811fn HomePostsError(
812 current_page: i32,
813 #[prop(into)] on_recovered: Callback<PostListResponse>,
814) -> impl IntoView {
815 let retrying = RwSignal::new(false);
816 let retry_failed = RwSignal::new(false);
817 let on_retry = Callback::new(move |()| {
818 retrying.set(true);
819 retry_failed.set(false);
820 leptos::task::spawn_local(async move {
821 let result = list_published_posts(current_page, POSTS_PER_PAGE).await;
822 if retrying.try_set(false).is_some() {
824 return;
825 }
826 match result {
827 Ok(response) => on_recovered.run(response),
828 Err(_) => {
829 let _ = retry_failed.try_set(true);
830 }
831 }
832 });
833 });
834 view! {
835 <LoadError
836 title="文章暂时没能加载"
837 class="home-error"
838 retrying=retrying
839 retry_failed=retry_failed
840 on_retry=on_retry
841 />
842 }
843}
844
845fn post_time(post: &PostListItem) -> DateTime<Utc> {
846 post.published_at.unwrap_or(post.created_at)
847}
848
849fn post_date(post: &PostListItem) -> String {
850 crate::utils::time::ymd(&post_time(post), '.')
851}
852
853#[derive(Debug, PartialEq)]
854struct MonthGroup {
855 label: String,
857 posts: Vec<PostListItem>,
858}
859
860fn group_by_month(posts: &[PostListItem]) -> Vec<MonthGroup> {
862 let mut groups: Vec<MonthGroup> = Vec::new();
863 for post in posts {
864 let time = post_time(post);
865 let label = format!("{} · {:02}", time.year(), time.month());
866 match groups.last_mut() {
867 Some(group) if group.label == label => group.posts.push(post.clone()),
868 _ => groups.push(MonthGroup {
869 label,
870 posts: vec![post.clone()],
871 }),
872 }
873 }
874 groups
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use chrono::TimeZone;
881
882 fn post(id: i32, y: i32, m: u32, d: u32) -> PostListItem {
883 let at = Utc.with_ymd_and_hms(y, m, d, 8, 0, 0).unwrap();
884 PostListItem {
885 id,
886 author_id: 1,
887 title: format!("文章 {id}"),
888 slug: format!("post-{id}"),
889 summary: None,
890 status: crate::models::post::PostStatus::Published,
891 published_at: Some(at),
892 created_at: at,
893 updated_at: at,
894 deleted_at: None,
895 tags: Vec::new(),
896 cover_image: None,
897 reading_time: 3,
898 word_count: 900,
899 }
900 }
901
902 #[test]
903 fn groups_adjacent_posts_by_month() {
904 let posts = [
905 post(1, 2026, 10, 15),
906 post(2, 2026, 9, 16),
907 post(3, 2026, 9, 10),
908 post(4, 2025, 9, 1),
909 ];
910 let groups = group_by_month(&posts);
911 let labels: Vec<_> = groups.iter().map(|g| g.label.as_str()).collect();
912 assert_eq!(labels, ["2026 · 10", "2026 · 09", "2025 · 09"]);
913 assert_eq!(groups[1].posts.len(), 2);
914 }
915}