Skip to main content

yggdrasil/pages/
friends.rs

1//! 友链页:纸面名片、交错入场与完整的加载 / 空 / 错误状态。
2
3use dioxus::prelude::*;
4
5use crate::api::friends::list_friend_links;
6use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
7use crate::components::skeletons::friends_skeleton::FriendsSkeleton;
8use crate::models::friend_link::FriendLink;
9use crate::router::Route;
10
11/// 友链页面组件,对应路由 `/friends`。
12#[component]
13pub fn Friends() -> Element {
14    rsx! {
15        div { class: "friends-page",
16            header { class: "friends-intro",
17                div { class: "friends-intro-copy",
18                    p { class: "friends-eyebrow", span {} "FRIENDS & NEIGHBORS" }
19                    h1 { "友链" span { class: "friends-title-dot", "。" } }
20                    p { class: "friends-intro-lead", "循着链接,去看看另一片风景。" }
21                    p { class: "friends-intro-note", "一些认真记录、持续创造的朋友。" }
22                }
23                div { class: "friends-orbit", aria_hidden: "true",
24                    div { class: "friends-orbit-ring friends-orbit-ring-one" }
25                    div { class: "friends-orbit-ring friends-orbit-ring-two" }
26                    span { class: "friends-orbit-node friends-orbit-node-one", "✳" }
27                    span { class: "friends-orbit-node friends-orbit-node-two", "↗" }
28                    span { class: "friends-orbit-dot friends-orbit-dot-one" }
29                    span { class: "friends-orbit-dot friends-orbit-dot-two" }
30                    span { class: "friends-orbit-caption", "小小链接,大大世界" }
31                }
32            }
33            SuspenseBoundary {
34                fallback: |_| rsx! { DelayedSkeleton { FriendsSkeleton { with_header: false } } },
35                FriendsContent {}
36            }
37            footer { class: "friends-colophon",
38                span { aria_hidden: "true", "✳" }
39                p { "链接相邻,灵感相通。" }
40            }
41        }
42    }
43}
44
45/// 保留服务端顺序,局部重试不会让标题与整页一起消失。
46#[component]
47fn FriendsContent() -> Element {
48    let mut links_res = use_server_future(list_friend_links)?;
49    let links_data = links_res.read();
50
51    match &*links_data {
52        Some(Ok(links)) if !links.is_empty() => rsx! {
53            section { aria_labelledby: "friends-list-title",
54                div { class: "friends-section-heading",
55                    h2 { id: "friends-list-title", "我的友邻" span { class: "friends-count", "{links.len():02}" } }
56                    span { class: "friends-section-hint", "每一扇门,都通往一个有趣的世界" }
57                }
58                ul { class: "friends-grid",
59                    for (index, link) in links.iter().enumerate() {
60                        li {
61                            key: "{link.id}",
62                            class: "friends-card-enter",
63                            style: "--friend-delay: {index.min(7) * 65 + 100}ms",
64                            FriendCard { link: link.clone(), number: index + 1 }
65                        }
66                    }
67                }
68            }
69        },
70        Some(Ok(_)) => rsx! {
71            section { class: "friends-state", role: "status",
72                span { class: "friends-state-symbol", aria_hidden: "true", "✳" }
73                h2 { "留一扇门,等一位新朋友" }
74                p { "新的相遇正在路上,先去读读这里的故事吧。" }
75                Link { class: "friends-action", to: Route::Home {}, "去读文章" span { aria_hidden: "true", "↗" } }
76            }
77        },
78        Some(Err(_)) => rsx! {
79            section { class: "friends-state", role: "alert",
80                span { class: "friends-state-symbol", aria_hidden: "true", "↻" }
81                h2 { "暂时没能遇见朋友们" }
82                p { "友链加载失败,请稍后再试。" }
83                button {
84                    class: "friends-action",
85                    r#type: "button",
86                    onclick: move |_| links_res.restart(),
87                    "重新加载" span { aria_hidden: "true", "↻" }
88                }
89            }
90        },
91        None => rsx! { DelayedSkeleton { FriendsSkeleton { with_header: false } } },
92    }
93}
94
95/// 整张名片是原生链接,鼠标、触屏和键盘共享同一交互入口。
96#[component]
97fn FriendCard(link: FriendLink, number: usize) -> Element {
98    let mut img_loaded = use_signal(|| false);
99    let id = link.id;
100    let initial = link
101        .name
102        .chars()
103        .next()
104        .map(|c| c.to_uppercase().collect::<String>())
105        .unwrap_or_else(|| "?".to_string());
106    let address = link
107        .url
108        .trim_start_matches("https://")
109        .trim_start_matches("http://")
110        .trim_end_matches('/');
111
112    // SSR 图片可能在 hydration 前已完成加载,补查缓存状态;失败时始终显示首字。
113    use_effect(move || {
114        #[cfg(target_arch = "wasm32")]
115        {
116            use wasm_bindgen::JsCast;
117            if let Some(img) = web_sys::window()
118                .and_then(|window| window.document())
119                .and_then(|document| document.get_element_by_id(&format!("friend-avatar-{id}")))
120                .and_then(|element| element.dyn_into::<web_sys::HtmlImageElement>().ok())
121            {
122                if img.complete() {
123                    img_loaded.set(img.natural_width() > 0);
124                }
125            }
126        }
127    });
128
129    rsx! {
130        a {
131            class: "friend-card",
132            href: "{link.url}",
133            target: "_blank",
134            rel: "noopener noreferrer",
135            aria_label: "访问 {link.name}(在新标签页打开)",
136            div { class: "friend-card-top",
137                div { class: "friend-avatar", aria_hidden: "true",
138                    span { "{initial}" }
139                    if let Some(avatar_url) = link.avatar_url.as_ref().filter(|url| !url.trim().is_empty()) {
140                        img {
141                            id: "friend-avatar-{id}",
142                            class: if img_loaded() { "friend-avatar-image is-loaded" } else { "friend-avatar-image" },
143                            src: "{avatar_url}",
144                            alt: "",
145                            width: "52",
146                            height: "52",
147                            loading: "lazy",
148                            decoding: "async",
149                            onload: move |_| img_loaded.set(true),
150                            onerror: move |_| img_loaded.set(false),
151                        }
152                    }
153                }
154                span { class: "friend-number", aria_hidden: "true", "{number:02}" }
155            }
156            div { class: "friend-card-copy",
157                h3 { "{link.name}" }
158                p { class: "friend-description",
159                    if link.description.trim().is_empty() {
160                        "故事就在门的另一边,去看看吧。"
161                    } else {
162                        "{link.description}"
163                    }
164                }
165            }
166            div { class: "friend-card-bottom",
167                span { class: "friend-address", "{address}" }
168                span { class: "friend-visit",
169                    span { class: "friend-visit-label", "去串个门" }
170                    span { class: "friend-arrow", aria_hidden: "true", span { "↗" } }
171                }
172            }
173        }
174    }
175}