yggdrasil/pages/
changelog.rs1use leptos::prelude::*;
17use leptos_meta::Title;
18use leptos_router::{lazy_route, LazyRoute};
19
20use crate::api::changelog::{get_changelog, ChangeGroup, ChangelogData, VersionEntry};
21use crate::components::skeletons::changelog_skeleton::ChangelogSkeleton;
22use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
23use crate::components::ui::{Icon, IconName, StatusBadge};
24use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
25use crate::pages::not_found::{NotFound, PageLoadFailed};
26use crate::routes::Route;
27use crate::utils::error::is_not_found;
28
29pub struct ChangelogRoute {
31 changelog: RouteData<ChangelogData>,
32}
33
34#[leptos::lazy]
35async fn fetch_changelog() -> PageJson<ChangelogData> {
36 PageJson::new(get_changelog().await)
37}
38
39#[lazy_route]
40impl LazyRoute for ChangelogRoute {
41 fn data() -> Self {
42 Self {
43 changelog: route_data(|| (), unit(fetch_changelog)),
44 }
45 }
46
47 fn view(this: Self) -> AnyView {
48 let changelog = this.changelog;
49 view! {
50 <Suspense fallback=|| {
51 view! {
52 <DelayedSkeleton>
53 <ChangelogSkeleton/>
54 </DelayedSkeleton>
55 }
56 }>
57 {move || Suspend::new(async move {
58 match changelog.await {
59 Ok(ChangelogData { versions }) => {
60 view! { <Changelog versions=versions/> }.into_any()
61 }
62 Err(err) if is_not_found(&err) => view! { <NotFound/> }.into_any(),
63 Err(_) => view! { <PageLoadFailed/> }.into_any(),
64 }
65 })}
66 </Suspense>
67 }
68 .into_any()
69 }
70}
71
72impl ChangelogRoute {
73 pub fn prefetch_data() {
75 prefetch_route_data((), unit(fetch_changelog));
76 }
77}
78
79#[component]
80fn Changelog(versions: Vec<VersionEntry>) -> impl IntoView {
81 let total = versions.len();
82 let since = versions
84 .iter()
85 .rev()
86 .find_map(|v| v.date.as_deref())
87 .map(month_label);
88
89 let nav = (versions.len() > 1).then(|| view! { <VersionNav versions=versions.clone()/> });
90 let cards = versions
91 .into_iter()
92 .map(|version| view! { <VersionCard version=version/> })
93 .collect_view();
94
95 view! {
96 <Title text="更新日志 · Yggdrasil"/>
97 <div class="changelog">
98 <ChangelogHeader total=total since=since/>
99 <div class="changelog-layout">
100 {nav} <div class="changelog-releases">{cards}</div>
101 </div>
102 </div>
103 }
104}
105
106fn month_label(date: &str) -> String {
108 date.get(..7).unwrap_or(date).replace('-', ".")
109}
110
111fn date_label(date: &str) -> String {
113 date.replace('-', ".")
114}
115
116#[component]
120pub fn ChangelogHeader(
121 #[prop(optional, into)] total: MaybeProp<usize>,
122 #[prop(optional, into)] since: MaybeProp<String>,
123) -> impl IntoView {
124 view! {
125 <a class="changelog-back" href=Route::About.to_string()>
126 <Icon name=IconName::ArrowLeft size=14/>
127 "关于"
128 </a>
129 <header class="changelog-header page-enter">
130 <div>
131 <h1 class="changelog-title">"更新日志"</h1>
132 <p class="changelog-intro">"每个版本新增、改进和修复了什么。"</p>
133 </div>
134 {move || {
135 total
136 .get()
137 .map(|total| {
138 view! {
139 <p class="changelog-count tabular-nums">
140 {format!("{total} 个版本")}
141 {move || since.get().map(|since| format!(" · {since} 起"))}
142 </p>
143 }
144 })
145 }}
146 </header>
147 }
148}
149
150#[component]
153fn VersionNav(versions: Vec<VersionEntry>) -> impl IntoView {
154 #[cfg(target_arch = "wasm32")]
155 {
156 Effect::new(move |_| {
157 crate::bridges::globals::init_changelog_nav();
158 });
159 on_cleanup(move || {
160 crate::bridges::globals::dispose_changelog_nav();
161 });
162 }
163
164 let count = versions.len();
165 let progress = 1.0 / count as f64;
166 let items = versions
167 .iter()
168 .enumerate()
169 .map(|(index, v)| {
170 view! {
171 <VersionNavItem
172 version=v.version.clone()
173 date=v.date.as_deref().map(|d| d.get(5..).unwrap_or(d).replace('-', "."))
174 active=index == 0
175 />
176 }
177 })
178 .collect_view();
179
180 view! {
181 <crate::components::enhance_assets::EnhanceScript/>
184 <nav class="changelog-nav" aria-label="版本目录">
185 <div class="changelog-nav-panel" style=format!("--nav-progress: {progress}")>
186 <div class="changelog-nav-head">
187 <span>"版本"</span>
188 <span class="tabular-nums" data-nav-position="">
189 {format!("01 / {count:02}")}
190 </span>
191 </div>
192 <div class="changelog-nav-scroll" tabindex="0" aria-label="滚动浏览所有版本">
193 <div class="changelog-nav-items">
194 <div class="changelog-nav-indicator" aria-hidden="true"></div>
195 {items}
196 </div>
197 </div>
198 <div class="changelog-nav-progress" aria-hidden="true">
199 <span></span>
200 </div>
201 </div>
202 </nav>
203 }
204}
205
206#[component]
208fn VersionNavItem(version: String, date: Option<String>, active: bool) -> impl IntoView {
209 let href = format!("#v{version}");
210 view! {
211 <a
212 href=href
213 class="changelog-nav-link"
214 aria-current=active.then_some("location")
215 >
216 <span class="changelog-nav-label">{version}</span>
217 {date.map(|date| view! { <span class="changelog-nav-date tabular-nums">{date}</span> })}
218 </a>
219 }
220}
221
222#[component]
224fn VersionCard(version: VersionEntry) -> impl IntoView {
225 let VersionEntry {
226 version: ver,
227 date,
228 is_latest,
229 intro_html,
230 groups,
231 } = version;
232
233 let date = date.map(|d| {
234 view! {
235 <time class="changelog-version-date tabular-nums" datetime=d.clone()>
236 {date_label(&d)}
237 </time>
238 }
239 });
240 let latest = is_latest.then(|| view! { <StatusBadge color_class="badge-clay" label="最新"/> });
241 let intro = (!intro_html.is_empty())
242 .then(|| view! { <div class="changelog-intro-html" inner_html=intro_html></div> });
243 let groups = groups
244 .into_iter()
245 .map(|group| view! { <ChangeGroupView group=group/> })
246 .collect_view();
247
248 view! {
249 <section id=format!("v{ver}") class="changelog-version" aria-labelledby=format!("v{ver}-title")>
250 <div class="changelog-version-head">
251 <h2 id=format!("v{ver}-title") class="changelog-version-title">
252 {ver.clone()}
253 </h2>
254 {date} {latest}
255 </div>
256 {intro} {groups}
257 </section>
258 }
259}
260
261#[component]
263fn ChangeGroupView(group: ChangeGroup) -> impl IntoView {
264 let ChangeGroup {
265 category,
266 items_html,
267 } = group;
268
269 view! {
270 <div class="changelog-group">
271 <h3 class="changelog-category" data-category=category.css_class()>
272 {category.label()}
273 </h3>
274 <div class="changelog-items" inner_html=items_html></div>
275 </div>
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn dates_use_dots() {
285 assert_eq!(month_label("2026-06-02"), "2026.06");
286 assert_eq!(date_label("2026-10-20"), "2026.10.20");
287 }
288}