Skip to main content

yggdrasil/pages/
about.rs

1//! 关于页:单栏编辑排版。站点简介、常写标签条形图、每年篇数柱状图、技术栈与版本、
2//! 入口卡片与订阅。统计全部来自已缓存的公开接口,在 `LazyRoute::data` 里一次创建
3//! (`hooks::ssr_resource::route_data`)。
4
5use leptos::prelude::*;
6use leptos_meta::Title;
7use leptos_router::{lazy_route, LazyRoute};
8
9use crate::api::changelog::latest_release;
10use crate::api::posts::{posts_per_year, TagListResponse, YearCount};
11use crate::components::skeletons::atoms::SkeletonBox;
12use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
13use crate::components::ui::{Icon, IconName};
14use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
15use crate::models::post::Tag;
16use crate::pages::archives::{fetch_tags, ranked_tags};
17use crate::routes::Route;
18
19/// 条形图列出的标签数。
20const TOP_TAGS: usize = 8;
21/// 柱状图最多显示的年份数(取最近的)。
22const MAX_YEARS: usize = 8;
23/// 柱子的最大高度(px),与样式里的绘图区一致。
24const BAR_MAX_PX: f64 = 96.0;
25
26/// 最新发布的版本号与可选日期。
27type Release = Option<(String, Option<String>)>;
28
29/// 关于页路由,对应 `/about`(`SsrMode::InOrder`)。
30pub struct AboutRoute {
31    years: RouteData<Vec<YearCount>>,
32    tags: RouteData<TagListResponse>,
33    release: RouteData<Release>,
34}
35
36#[leptos::lazy]
37async fn fetch_years() -> PageJson<Vec<YearCount>> {
38    PageJson::new(posts_per_year().await)
39}
40
41#[leptos::lazy]
42async fn fetch_release() -> PageJson<Release> {
43    PageJson::new(latest_release().await)
44}
45
46#[lazy_route]
47impl LazyRoute for AboutRoute {
48    fn data() -> Self {
49        Self {
50            years: route_data(|| (), unit(fetch_years)),
51            tags: route_data(|| (), unit(fetch_tags)),
52            release: route_data(|| (), unit(fetch_release)),
53        }
54    }
55
56    fn view(this: Self) -> AnyView {
57        let AboutRoute {
58            years,
59            tags,
60            release,
61        } = this;
62        view! {
63            <Title text="关于 · Yggdrasil"/>
64            <article class="about">
65                <div class="about-head page-enter">
66                    <h1 class="about-title">"关于"</h1>
67                    <span class="about-mascot">
68                        <img
69                            class="about-mascot-img"
70                            src="/images/about/claude-girl.webp"
71                            alt="橙色长发的像素风女孩,双手捧着一只橙色小动物"
72                            width="256"
73                            height="256"
74                            decoding="async"
75                        />
76                        <img
77                            class="about-mascot-img about-mascot-hover"
78                            src="/images/about/claude-girl-hover.webp"
79                            alt=""
80                            aria-hidden="true"
81                            width="256"
82                            height="256"
83                            decoding="async"
84                        />
85                    </span>
86                </div>
87                <div class="about-prose page-enter" style="--enter-index: 1">
88                    <p>
89                        "这里是 xfy 的个人站点。大多数文章在拆解一个具体的实现:一台 6502 的时钟、"
90                        "一个聊天系统的断线续传、一个黑洞渲染器的积分器。也有一些学习笔记,记下读书和踩坑时的发现。"
91                    </p>
92                </div>
93                <Suspense fallback=|| {
94                    view! {
95                        <DelayedSkeleton>
96                            <AboutStatsSkeleton/>
97                        </DelayedSkeleton>
98                    }
99                }>
100                    {move || Suspend::new(async move {
101                        // 统计失败只是不显示对应图表,页面其余部分照常。
102                        let years = years.await.unwrap_or_default();
103                        let tags = tags
104                            .await
105                            .map(|r| ranked_tags(r.tags))
106                            .unwrap_or_default();
107                        let release = release.await.ok().flatten();
108                        view! { <AboutStats years=years tags=tags release=release/> }
109                    })}
110                </Suspense>
111            </article>
112        }
113        .into_any()
114    }
115}
116
117impl AboutRoute {
118    /// 悬停预取:与 `data()` 相同的取数函数与 source。
119    pub fn prefetch_data() {
120        prefetch_route_data((), unit(fetch_years));
121        prefetch_route_data((), unit(fetch_tags));
122        prefetch_route_data((), unit(fetch_release));
123    }
124}
125
126#[component]
127fn AboutStats(years: Vec<YearCount>, tags: Vec<Tag>, release: Release) -> impl IntoView {
128    let total: i64 = years.iter().map(|y| y.count).sum();
129    let version = release
130        .as_ref()
131        .map(|(v, _)| v.clone())
132        .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
133    let version_fact = match release.as_ref().and_then(|(_, date)| date.clone()) {
134        Some(date) => format!("{version} · {date}"),
135        None => version.clone(),
136    };
137    let facts = [
138        ("前端", "Leptos 0.8 · WebAssembly".to_string()),
139        ("服务端", "Axum · 服务端渲染".to_string()),
140        ("数据", "PostgreSQL".to_string()),
141        ("AI 接入", "MCP:读写文章与笔记".to_string()),
142        ("正文", "可运行代码 · Mermaid · 公式".to_string()),
143        ("当前版本", version_fact),
144    ]
145    .into_iter()
146    .map(|(label, value)| {
147        view! {
148            <div>
149                <dt>{label}</dt>
150                <dd class="tabular-nums">{value}</dd>
151            </div>
152        }
153    })
154    .collect_view();
155
156    let intro = years.first().filter(|_| total > 0).map(|first| {
157        view! {
158            <p
159                class="about-prose about-prose-muted tabular-nums page-enter"
160                style="--enter-index: 1"
161            >
162                {format!("从 {} 年的第一篇写到现在,一共 {total} 篇。", first.year)}
163            </p>
164        }
165    });
166    let tag_bars = (!tags.is_empty()).then(|| {
167        let top = tags.into_iter().take(TOP_TAGS).collect::<Vec<_>>();
168        view! { <TagBars tags=top/> }
169    });
170    let year_bars = (total > 0).then(|| view! { <YearBars years=years/> });
171
172    view! {
173        {intro} {tag_bars} {year_bars}
174        <section class="about-site" aria-labelledby="about-site-title">
175            <h2 id="about-site-title" class="about-site-title">
176                "这个站点"
177            </h2>
178            <div class="about-prose about-prose-small">
179                <p>
180                    "Yggdrasil 既是站名,也是它的程序:一份 Rust 代码,同时编译出浏览器里的 WebAssembly "
181                    "前端和服务器上的 Axum 后端。实现细节写在"
182                    <a href="/doc/yggdrasil/index.html" target="_blank" rel="noopener external">
183                        "站点文档"
184                    </a> "里。"
185                </p>
186            </div>
187            <dl class="about-facts">{facts}</dl>
188        </section>
189        <section class="about-entries" aria-label="更多">
190            <AboutEntry
191                href=Route::WritingGuide.to_string()
192                title="写作指南"
193                description="把这里的写作方式交给你的 AI:SKILL.md、MCP 接入与正文语法。"
194                meta="/about/writing".to_string()
195            />
196            <AboutEntry
197                href=Route::ComponentShowcase.to_string()
198                title="组件图鉴"
199                description="组成这个站点的界面组件,每一个都能直接操作。"
200                meta="/about/components".to_string()
201            />
202            <AboutEntry
203                href=Route::Changelog.to_string()
204                title="更新日志"
205                description="每个版本改了什么、修了什么。"
206                meta=format!("最新 {version}")
207            />
208        </section>
209        <section class="about-subscribe" aria-labelledby="about-subscribe-title">
210            <div>
211                <h2 id="about-subscribe-title">"订阅"</h2>
212                <p>"新文章发布后,阅读器会自动收到。"</p>
213            </div>
214            <div class="about-subscribe-links">
215                <a class="btn btn-secondary btn-sm" href="/feed.xml" rel="external">
216                    <Icon name=IconName::Rss size=15/>
217                    "RSS"
218                </a>
219                <a class="btn btn-secondary btn-sm" href="/feed.json" rel="external">
220                    "JSON Feed"
221                </a>
222            </div>
223        </section>
224    }
225}
226
227/// 写得最多的标签:横向条形,长度按篇数相对最多的标签。
228#[component]
229fn TagBars(tags: Vec<Tag>) -> impl IntoView {
230    let max = tags.iter().map(|t| t.post_count).max().unwrap_or(1).max(1);
231    let items = tags
232        .into_iter()
233        .map(|tag| {
234            view! {
235                <li>
236                    <a
237                        class="about-tag-name"
238                        href=Route::TagDetail {
239                            tag: tag.name.clone(),
240                        }
241                            .to_string()
242                    >
243                        {tag.name.clone()}
244                    </a>
245                    <span class="about-tag-track" aria-hidden="true">
246                        <span
247                            class="about-tag-bar"
248                            style=format!("width: {:.1}%", bar_percent(tag.post_count, max))
249                        ></span>
250                    </span>
251                    <span class="about-tag-count tabular-nums">{tag.post_count}</span>
252                </li>
253            }
254        })
255        .collect_view();
256    view! {
257        <section class="about-chart" aria-labelledby="about-tags-title">
258            <h2 id="about-tags-title" class="section-label">
259                "写得最多的"
260            </h2>
261            <ol class="about-tag-bars">{items}</ol>
262        </section>
263    }
264}
265
266/// 每年写下的篇数:纵向柱子,今年(最后一年)用 Clay 标出;数值直接写在柱顶。
267#[component]
268fn YearBars(years: Vec<YearCount>) -> impl IntoView {
269    let shown: Vec<YearCount> = years
270        .iter()
271        .skip(years.len().saturating_sub(MAX_YEARS))
272        .cloned()
273        .collect();
274    let max = shown.iter().map(|y| y.count).max().unwrap_or(1).max(1);
275    let label = shown
276        .iter()
277        .map(|y| format!("{} 年 {} 篇", y.year, y.count))
278        .collect::<Vec<_>>()
279        .join(",");
280    let last = shown.last().map(|y| y.year);
281    let columns = shown.len();
282    let bars = shown
283        .into_iter()
284        .map(|year| {
285            let bar_class = if Some(year.year) == last {
286                "about-year-bar is-current"
287            } else if year.count == 0 {
288                "about-year-bar is-empty"
289            } else {
290                "about-year-bar"
291            };
292            let value = if year.count > 0 {
293                year.count.to_string()
294            } else {
295                "—".to_string()
296            };
297            view! {
298                <div class="about-year" title=format!("{} 年 {} 篇", year.year, year.count)>
299                    <span class="about-year-value tabular-nums">{value}</span>
300                    <span
301                        class=bar_class
302                        style=format!("height: {:.0}px", bar_height(year.count, max))
303                    ></span>
304                    <span class="about-year-label tabular-nums">{year.year}</span>
305                </div>
306            }
307        })
308        .collect_view();
309    view! {
310        <section class="about-chart" aria-labelledby="about-years-title">
311            <h2 id="about-years-title" class="section-label">
312                "每年写下的篇数"
313            </h2>
314            <div
315                class="about-year-bars"
316                role="img"
317                aria-label=label
318                style=format!("--years: {columns}")
319            >
320                {bars}
321            </div>
322        </section>
323    }
324}
325
326fn bar_percent(count: i64, max: i64) -> f64 {
327    count as f64 / max as f64 * 100.0
328}
329
330/// 柱高至少 2px,0 篇的年份也留一条基线。
331fn bar_height(count: i64, max: i64) -> f64 {
332    (count as f64 / max as f64 * BAR_MAX_PX).max(2.0)
333}
334
335/// 入口卡片:站内路由的普通链接,点击由路由协调器接管做客户端导航。
336#[component]
337fn AboutEntry(
338    href: String,
339    title: &'static str,
340    description: &'static str,
341    meta: String,
342) -> impl IntoView {
343    view! {
344        <a class="about-entry" href=href>
345            <span class="about-entry-title">
346                {title}
347                <span class="about-entry-arrow" aria-hidden="true">
348                    <Icon name=IconName::ArrowRight size=15/>
349                </span>
350            </span>
351            <span class="about-entry-description">{description}</span>
352            <span class="about-entry-meta tabular-nums">{meta}</span>
353        </a>
354    }
355}
356
357#[component]
358fn AboutStatsSkeleton() -> impl IntoView {
359    view! {
360        <div role="status" aria-label="正在加载统计">
361            <div aria-hidden="true">
362                <SkeletonBox class="h-4 mt-4 w-64"/>
363                <div class="about-chart">
364                    <SkeletonBox class="h-3 w-20 mb-4"/>
365                    {(0..6)
366                        .map(|_| view! { <SkeletonBox class="h-2 mt-4 w-full"/> })
367                        .collect_view()}
368                </div>
369            </div>
370        </div>
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn bars_scale_to_the_largest_value_and_keep_a_baseline() {
380        assert_eq!(bar_percent(8, 16), 50.0);
381        assert_eq!(bar_height(15, 15), BAR_MAX_PX);
382        assert_eq!(bar_height(0, 15), 2.0);
383    }
384}