yggdrasil/pages/
friends.rs1use 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#[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#[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#[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 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}