Skip to main content

yggdrasil/components/
footer.rs

1//! 页脚组件
2//!
3//! 提供站点版权信息,并在用户向下滚动超过一屏后显示"回到顶部"悬浮按钮。
4//! 回到顶部的滚动监听与平滑滚动逻辑仅在 WASM 前端生效。
5
6use crate::api::settings::get_site_settings;
7#[cfg(target_arch = "wasm32")]
8use crate::hooks::event_listener::use_event_listener;
9use dioxus::prelude::*;
10
11/// 页脚与回到顶部按钮组件。
12///
13/// Props:无。
14/// 关键行为:
15/// - 监听窗口滚动,超过一屏时显示回到顶部按钮
16/// - 点击按钮平滑滚动到顶部,并清理 URL 中的 `#`
17/// - 滚动监听与平滑滚动仅在 `target_arch = "wasm32"` 下执行
18#[component]
19#[allow(unused_mut)]
20pub fn Footer() -> Element {
21    let mut visible = use_signal(|| false);
22
23    // 读取站点公开配置(页脚 GitHub 链接)。公开接口,SSR 与 WASM 均可调用;
24    // 服务端有 moka 缓存兜底,解析极快。`?` 在 pending 时向上抛 Suspense,
25    // 页脚复用前台布局的渲染流程——版权与回到顶部按钮随之短暂延后(通常仅首屏一次)。
26    let settings_res = use_server_future(get_site_settings)?;
27    let github_url: Option<String> = settings_res
28        .read()
29        .as_ref()
30        .and_then(|r| r.as_ref().ok())
31        .map(|s| s.github_url.clone())
32        .filter(|u| !u.is_empty());
33
34    // 根据 window 当前滚动位置同步 visible(注册监听后立即调用一次,避免首屏漏判)。
35    // 滚动事件回调里也复用同一份判断逻辑。
36    let mut sync_visible = move || {
37        #[cfg(target_arch = "wasm32")]
38        {
39            if let Some(w) = web_sys::window() {
40                let threshold = w
41                    .inner_height()
42                    .ok()
43                    .and_then(|h| h.as_f64())
44                    .unwrap_or(0.0);
45                let scroll_y = w.scroll_y().unwrap_or(0.0);
46                visible.set(scroll_y > threshold);
47            }
48        }
49    };
50
51    // 注册 scroll 监听:注册 / 卸载清理由 use_event_listener 负责。
52    // 仅 WASM 端调用(server 端 use_event_listener 是 noop,但 acquire 闭包内的
53    // web_sys 在非 wasm 下不可解析,故整块 cfg;hook 数量在 server build 中不影响,
54    // 因为 server 端该组件只跑一次 SSR)。
55    #[cfg(target_arch = "wasm32")]
56    use_event_listener(
57        web_sys::window,
58        "scroll",
59        // 滚动事件触发时复用同样的阈值判断。
60        sync_visible,
61    );
62
63    // 挂载时根据当前滚动位置初始化一次按钮可见性。
64    use_effect(move || {
65        sync_visible();
66    });
67
68    // 根据 visible 动态切换按钮显示/隐藏样式
69    let btn_class = use_memo(move || {
70        let base = "fixed bottom-16 right-8 z-50 w-10 h-10 rounded-full bg-paper-entry border border-paper-border shadow-sm flex items-center justify-center cursor-pointer transition-all duration-300 text-paper-secondary hover:text-paper-accent";
71        if visible() {
72            format!("{} opacity-100 translate-y-0", base)
73        } else {
74            format!("{} opacity-0 translate-y-2 pointer-events-none", base)
75        }
76    });
77
78    rsx! {
79        footer { "data-vt-shell": "frontend-footer", class: "w-full border-t border-paper-border mt-auto",
80            div { class: "max-w-4xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-paper-secondary",
81                span { "© 2026 Yggdrasil" }
82                if let Some(url) = github_url.as_ref() {
83                    a {
84                        class: "inline-flex items-center justify-center w-8 h-8 rounded-full text-paper-secondary hover:text-paper-primary hover:bg-paper-entry transition-colors",
85                        href: "{url}",
86                        target: "_blank",
87                        rel: "noopener noreferrer",
88                        aria_label: "GitHub",
89                        title: "GitHub",
90                        svg {
91                            xmlns: "http://www.w3.org/2000/svg",
92                            width: "20",
93                            height: "20",
94                            view_box: "0 0 24 24",
95                            fill: "currentColor",
96                            path {
97                                fill_rule: "evenodd",
98                                clip_rule: "evenodd",
99                                d: "M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z",
100                            }
101                        }
102                    }
103                }
104            }
105        }
106        a {
107            class: "{btn_class}",
108            href: "#top",
109            aria_label: "go to top",
110            title: "Go to Top (Alt + G)",
111            accesskey: "g",
112            onclick: move |evt| {
113                evt.prevent_default();
114                scroll_to_top();
115            },
116            svg {
117                xmlns: "http://www.w3.org/2000/svg",
118                height: "24px",
119                view_box: "0 -960 960 960",
120                width: "24px",
121                fill: "currentColor",
122                path { d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z" }
123            }
124        }
125    }
126}
127
128/// 平滑滚动到页面顶部,并清理 history 中的 `#` 哈希。
129///
130/// 仅在 `target_arch = "wasm32"` 下执行实际滚动,SSR 环境中为空操作。
131fn scroll_to_top() {
132    #[cfg(target_arch = "wasm32")]
133    {
134        if let Some(window) = web_sys::window() {
135            let options = web_sys::ScrollToOptions::new();
136            options.set_top(0.0);
137            options.set_behavior(web_sys::ScrollBehavior::Smooth);
138            window.scroll_to_with_scroll_to_options(&options);
139
140            if let Ok(history) = window.history() {
141                let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(" "));
142            }
143        }
144    }
145}