yggdrasil/pages/
changelog.rs1use dioxus::prelude::*;
17
18use crate::api::changelog::{get_changelog, ChangeGroup, ChangelogData, VersionEntry};
19use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
20use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
21
22#[component]
26pub fn Changelog() -> Element {
27 let response = use_server_future(get_changelog)?;
28
29 let data = response.read().as_ref().map(|r| match r {
31 Ok(resp) => Ok(resp.clone()),
32 Err(e) => Err(e.clone()),
33 });
34
35 let ChangelogData { versions } = match data {
36 Some(Ok(resp)) => resp,
37 Some(Err(err)) => return Err(err.into()),
38 None => {
39 return rsx! {
40 DelayedSkeleton { PostDetailSkeleton {} }
41 };
42 }
43 };
44
45 let total = versions
46 .iter()
47 .filter(|v| v.version != "Unreleased")
48 .count();
49 let latest = versions.iter().find(|v| v.is_latest);
50
51 rsx! {
52 div { class: "animate-page-enter",
53 header { class: "page-header mb-6",
54 h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
55 "更新日志"
56 }
57 }
58
59 div { class: "flex flex-wrap items-center gap-x-4 gap-y-1 mb-8 text-sm text-paper-tertiary",
61 if let Some(v) = latest {
62 div { class: "flex items-center gap-1.5",
63 span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
64 span { "最新版本 " }
65 span { class: "font-medium text-paper-primary", "v{v.version}" }
66 }
67 }
68 span { "共 {total} 个版本" }
69 }
70
71 div { class: "flex gap-8",
73 if versions.len() > 1 {
75 VersionNav { versions: versions.clone() }
76 }
77
78 div { class: "flex-1 min-w-0 space-y-6",
80 for v in versions.iter() {
81 VersionCard { key: "{v.version}", version: v.clone() }
82 }
83 }
84 }
85 }
86 }
87}
88
89#[component]
95fn VersionNav(versions: Vec<VersionEntry>) -> Element {
96 let active = use_signal(|| None::<String>);
99
100 #[cfg(target_arch = "wasm32")]
104 {
105 use dioxus::prelude::{use_drop, use_effect, use_hook};
106 use std::cell::RefCell;
107 use std::rc::Rc;
108 use wasm_bindgen::JsCast;
109
110 let mut active = active;
111
112 type ScrollState = Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut()>>>>;
117 let state: ScrollState = use_hook(|| Rc::new(RefCell::new(None)));
118 let state_for_effect = state.clone();
119 let state_for_drop = state.clone();
120
121 use_effect(move || {
123 let Some(window) = web_sys::window() else {
124 return;
125 };
126 let Some(document) = window.document() else {
127 return;
128 };
129 let Ok(list) = document.query_selector_all("article.changelog-version") else {
130 return;
131 };
132 let mut cards: Vec<web_sys::Element> = Vec::with_capacity(list.length() as usize);
133 for i in 0..list.length() {
134 if let Some(el) = list
135 .item(i)
136 .and_then(|n| n.dyn_into::<web_sys::Element>().ok())
137 {
138 cards.push(el);
139 }
140 }
141 if cards.is_empty() {
142 return;
143 }
144
145 let window_for_compute = window.clone();
147 let mut compute = move || {
148 const THRESHOLD_PX: f64 = 120.0;
150
151 let mut current: Option<String> = None;
154 for el in &cards {
155 if el.get_bounding_client_rect().top() <= THRESHOLD_PX {
156 current = el.id().strip_prefix('v').map(str::to_owned);
157 } else {
158 break;
159 }
160 }
161 let mut ver = current.or_else(|| {
163 cards
164 .first()
165 .and_then(|e| e.id().strip_prefix('v').map(str::to_owned))
166 });
167
168 let at_bottom = window_for_compute
170 .inner_height()
171 .ok()
172 .and_then(|v| v.as_f64())
173 .zip(window_for_compute.scroll_y().ok())
174 .zip(
175 document
176 .document_element()
177 .map(|e| f64::from(e.scroll_height())),
178 )
179 .is_some_and(|((vh, sy), sh)| vh + sy >= sh - 4.0);
180 if at_bottom {
181 ver = cards
182 .last()
183 .and_then(|e| e.id().strip_prefix('v').map(str::to_owned));
184 }
185
186 if let Some(ver) = ver {
187 if active.peek().as_deref() != Some(ver.as_str()) {
188 active.set(Some(ver));
189 }
190 }
191 };
192
193 compute();
194
195 let closure =
196 wasm_bindgen::prelude::Closure::wrap(Box::new(compute) as Box<dyn FnMut()>);
197 let _ =
198 window.add_event_listener_with_callback("scroll", closure.as_ref().unchecked_ref());
199 *state_for_effect.borrow_mut() = Some(closure);
200 });
201
202 use_drop(move || {
203 if let Some(closure) = state_for_drop.borrow_mut().take() {
204 if let Some(window) = web_sys::window() {
205 let _ = window.remove_event_listener_with_callback(
206 "scroll",
207 closure.as_ref().unchecked_ref(),
208 );
209 }
210 }
211 });
212 }
213
214 let fallback = versions.first().map(|v| v.version.clone());
216 let active_ver = active.read().clone().or(fallback);
217
218 rsx! {
219 nav { class: "hidden lg:block w-40 shrink-0",
220 div { class: "sticky top-20",
221 for v in versions.iter() {
222 VersionNavItem {
223 key: "{v.version}",
224 version: v.version.clone(),
225 active: active_ver.as_deref() == Some(v.version.as_str()),
226 }
227 }
228 }
229 }
230 }
231}
232
233#[component]
237fn VersionNavItem(version: String, active: bool) -> Element {
238 let base = "group flex items-center gap-2 py-1.5 text-sm transition-colors";
239 let text_class = if active {
240 "font-medium text-paper-accent"
241 } else {
242 "text-paper-secondary group-hover:text-paper-primary"
243 };
244 let dot_class = if active {
245 "bg-[var(--color-paper-accent)]"
246 } else {
247 "bg-[var(--color-paper-border)]"
248 };
249
250 rsx! {
251 a { href: "#v{version}", class: "{base}",
252 span { class: "w-1.5 h-1.5 rounded-full shrink-0 {dot_class}" }
253 span { class: "{text_class}", "{version}" }
254 }
255 }
256}
257
258#[component]
263fn VersionCard(version: VersionEntry) -> Element {
264 let VersionEntry {
265 version: ver,
266 date,
267 is_latest,
268 intro_html,
269 groups,
270 } = version;
271
272 rsx! {
273 article {
274 id: "v{ver}",
275 class: "changelog-version scroll-mt-20 rounded-[2rem] bg-[var(--color-paper-entry)] border border-transparent hover:border-[var(--color-paper-border)] transition-colors p-6 md:p-8",
276
277 div { class: "flex items-baseline gap-3",
279 h2 { class: "text-xl md:text-2xl font-bold text-paper-primary tracking-tight",
280 "v{ver}"
281 }
282 if is_latest {
283 span { class: "changelog-badge changelog-badge--latest", "最新" }
284 }
285 if let Some(d) = &date {
286 span { class: "text-sm text-paper-tertiary ml-auto", "{d}" }
287 }
288 }
289
290 if !intro_html.is_empty() {
292 div {
293 class: "md-content text-sm text-paper-secondary mt-2",
294 dangerous_inner_html: "{intro_html}",
295 }
296 }
297
298 if !groups.is_empty() {
300 div { class: "mt-4 space-y-5",
301 for group in groups.iter() {
302 ChangeGroupView { key: "{group.category:?}", group: group.clone() }
303 }
304 }
305 }
306 }
307 }
308}
309
310#[component]
312fn ChangeGroupView(group: ChangeGroup) -> Element {
313 let ChangeGroup {
314 category,
315 items_html,
316 } = group;
317 let badge_class = format!("changelog-badge changelog-badge--{}", category.css_class());
318
319 rsx! {
320 div {
321 div { class: "mb-2",
323 span { class: "{badge_class}", "{category.label()}" }
324 }
325 div {
327 class: "md-content changelog-items",
328 dangerous_inner_html: "{items_html}",
329 }
330 }
331 }
332}