Skip to main content

yggdrasil/pages/
home.rs

1//! 首页「书桌」与分页首页。
2//!
3//! 第 1 页:状态行、站点标语、搜索与常读标签、最新一篇(右侧目录预览)、按「年 · 月」
4//! 分组的文章索引、尾部三格。第 2 页起只有页标题与索引。数据都来自已缓存的公开接口,
5//! 在 `LazyRoute::data` 里一次建好,SSR 一次取齐。
6//!
7//! [`HomeRoute`] 同时服务 `/`(第 1 页)与 `/page/:page`。
8
9use 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;
38/// 搜索框下「常读」标签数。
39const TOP_TAGS: usize = 6;
40/// 尾部「常去标签」数。
41const TAG_CLOUD: usize = 10;
42/// 尾部「最近笔记」数。
43const RECENT_NOTES: usize = 4;
44
45/// 首页路由,对应 `/`(`SsrMode::InOrder`)与 `/page/:page`(`SsrMode::Async`,无 `page` 参数视为
46/// 第 1 页)。非法页码渲染 NotFound,状态码为 404;此时文章 source 为 None,不取数据。
47///
48/// 文章列表随页码重取;其余四个数据源只有第 1 页用到,翻到后面的页码时 source 为
49/// `false`,fetcher 直接返回 `None`,不再发请求。
50pub struct HomeRoute {
51    /// 页码(至少为 1);`page` 参数不是整数时为 `None`,渲染 404。
52    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// 取数函数随分包加载,见 `hooks::ssr_resource`。`first` 为 false(第 2 页起)时直接返回 None。
61
62#[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
106/// 页码参数 → 页码:缺省为第 1 页;非数字为 `None`(渲染 404);小于 1 钳到 1。
107/// `data()` 与悬停预取共用,保证两边算出同一个 source。
108fn 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        // 标题传 String 而非读 `page` 的闭包:leptos_meta 在注入 <head> 时才求值,Async 模式下
141        // 这时 data() 的 owner 可能已释放,闭包读 Memo 会 panic。换页时由外层闭包重建 Title。
142        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                            // 非法页码只在 `/page/:page`(Async)出现,状态码在渲染完成后才写入。
164                            None => view! { <NotFound/> }.into_any(),
165                            Some(1) => {
166                                // 文章列表失败只把文章区换成错误卡片;其余数据失败退回空值。
167                                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    /// 悬停预取:与 `data()` 相同的取数函数与 source;`page` 是路由参数原文(`/` 为 `None`)。
213    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/// 第 1 页。文章列表失败时只把文章区换成错误卡片,开场照常显示。
225///
226/// `posts` 为 `None` 表示列表请求失败;重试成功后写进本地信号,不重取外层 `Resource`
227/// (否则整页会退回骨架屏)。
228#[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    // 两次读取之间可能有新文章发布;slug 不一致时不显示目录。
241    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/// 第 2 页起:页标题与索引。
341#[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/// 状态行:Clay 呼吸点、最近更新、篇数与标签数。篇数链到下方索引。
382#[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/// 开场搜索框:提交进入全文搜索页。没有脚本时表单按 GET 提交到同一地址。
434#[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/// 最新一篇面板。整卡可点;有小节时右栏列出前几节,否则改为单栏并放大标题。
473#[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/// 按「年 · 月」分组的文章索引与分页。第 1 页传入的列表已去掉最新一篇。
578#[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/// 尾部三格:最近笔记、常去标签、去别处走走。
707#[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(&note.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/// 文章区加载失败。重试在卡片内完成,成功后回填,不触发整页骨架。
810#[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            // 路由切走后本卡片已卸载,信号随之释放,try_* 静默跳过。
823            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    /// 「2026 · 10」。
856    label: String,
857    posts: Vec<PostListItem>,
858}
859
860/// 列表已按发布时间降序;相邻同月的文章归为一组。
861fn 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}