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    rsx! {
69        FooterView {
70            github_url,
71            top_visible: visible(),
72            on_top: move |_| scroll_to_top(),
73        }
74    }
75}
76
77/// 页脚共用展示;数据读取与滚动策略由调用方负责。
78#[component]
79pub(crate) fn FooterView(
80    github_url: Option<String>,
81    top_visible: bool,
82    on_top: EventHandler<()>,
83    #[props(default = true)] page_shell: bool,
84    #[props(default)] inline_top: bool,
85) -> Element {
86    let btn_class = if inline_top {
87        "w-9 h-9 rounded-full bg-paper-entry border border-paper-border shadow-sm flex items-center justify-center cursor-pointer text-paper-secondary hover:text-paper-accent"
88    } else if top_visible {
89        "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 opacity-100 translate-y-0"
90    } else {
91        "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 opacity-0 translate-y-2 pointer-events-none"
92    };
93    rsx! {
94        footer { "data-vt-shell": page_shell.then_some("frontend-footer"), class: "w-full border-t border-paper-border mt-auto",
95            div { class: "max-w-4xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-paper-secondary",
96                span { "© 2026 Yggdrasil" }
97                if let Some(url) = github_url.as_ref() {
98                    a {
99                        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",
100                        href: "{url}",
101                        target: "_blank",
102                        rel: "noopener noreferrer",
103                        aria_label: "GitHub",
104                        title: "GitHub",
105                        svg {
106                            xmlns: "http://www.w3.org/2000/svg",
107                            width: "20",
108                            height: "20",
109                            view_box: "0 0 24 24",
110                            fill: "currentColor",
111                            path {
112                                fill_rule: "evenodd",
113                                clip_rule: "evenodd",
114                                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",
115                            }
116                        }
117                    }
118                }
119                if inline_top {
120                    button {
121                        class: "{btn_class}",
122                        r#type: "button",
123                        aria_label: "go to top",
124                        title: "回到演示顶部",
125                        onclick: move |_| on_top.call(()),
126                        TopIcon {}
127                    }
128                }
129            }
130        }
131        if !inline_top {
132            a {
133                class: "{btn_class}",
134                href: "#top",
135                aria_label: "go to top",
136                title: "Go to Top (Alt + G)",
137                accesskey: "g",
138                onclick: move |event| {
139                    event.prevent_default();
140                    on_top.call(());
141                },
142                TopIcon {}
143            }
144        }
145    }
146}
147
148#[component]
149fn TopIcon() -> Element {
150    rsx! {
151        svg {
152            xmlns: "http://www.w3.org/2000/svg",
153            height: "24px",
154            view_box: "0 -960 960 960",
155            width: "24px",
156            fill: "currentColor",
157            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" }
158        }
159    }
160}
161
162/// 平滑滚动到页面顶部,并清理 history 中的 `#` 哈希。
163///
164/// 仅在 `target_arch = "wasm32"` 下执行实际滚动,SSR 环境中为空操作。
165fn scroll_to_top() {
166    #[cfg(target_arch = "wasm32")]
167    {
168        if let Some(window) = web_sys::window() {
169            let options = web_sys::ScrollToOptions::new();
170            options.set_top(0.0);
171            options.set_behavior(web_sys::ScrollBehavior::Smooth);
172            window.scroll_to_with_scroll_to_options(&options);
173
174            if let Ok(history) = window.history() {
175                let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(" "));
176            }
177        }
178    }
179}