1use 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
19const TOP_TAGS: usize = 8;
21const MAX_YEARS: usize = 8;
23const BAR_MAX_PX: f64 = 96.0;
25
26type Release = Option<(String, Option<String>)>;
28
29pub 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 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 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#[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#[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
330fn bar_height(count: i64, max: i64) -> f64 {
332 (count as f64 / max as f64 * BAR_MAX_PX).max(2.0)
333}
334
335#[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}