yggdrasil/pages/
friends.rs1use leptos::prelude::*;
4use leptos_meta::Title;
5use leptos_router::{lazy_route, LazyRoute};
6
7use crate::api::friends::list_friend_links;
8use crate::api::settings::{get_site_card, SiteCard};
9use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
10use crate::components::skeletons::friends_skeleton::FriendsSkeleton;
11use crate::components::ui::{Icon, IconName, LoadError};
12use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
13use crate::models::friend_link::FriendLink;
14
15pub struct FriendsRoute {
20 links: RouteData<Vec<FriendLink>>,
21 card: RouteData<SiteCard>,
22}
23
24#[leptos::lazy]
25async fn fetch_links() -> PageJson<Vec<FriendLink>> {
26 PageJson::new(list_friend_links().await)
27}
28
29#[leptos::lazy]
30async fn fetch_card() -> PageJson<SiteCard> {
31 PageJson::new(get_site_card().await)
32}
33
34#[lazy_route]
35impl LazyRoute for FriendsRoute {
36 fn data() -> Self {
37 Self {
38 links: route_data(|| (), unit(fetch_links)),
39 card: route_data(|| (), unit(fetch_card)),
40 }
41 }
42
43 fn view(this: Self) -> AnyView {
44 let FriendsRoute { links, card } = this;
45 view! {
46 <Title text="友链 · Yggdrasil"/>
47 <div class="friends">
48 <Suspense fallback=|| {
49 view! {
50 <DelayedSkeleton>
51 <FriendsSkeleton/>
52 </DelayedSkeleton>
53 }
54 }>
55 {move || Suspend::new(async move {
56 let links = links.await.ok();
58 let card = card.await.ok();
59 view! { <FriendsBody initial=links card=card/> }
60 })}
61 </Suspense>
62 </div>
63 }
64 .into_any()
65 }
66}
67
68impl FriendsRoute {
69 pub fn prefetch_data() {
71 prefetch_route_data((), unit(fetch_links));
72 prefetch_route_data((), unit(fetch_card));
73 }
74}
75
76#[component]
80pub fn FriendsHeader(#[prop(optional, into)] count: MaybeProp<usize>) -> impl IntoView {
81 view! {
82 <header class="friends-header page-enter">
83 <div>
84 <h1 class="friends-title">"友链"</h1>
85 <p class="friends-intro">"一些认真记录、持续创造的朋友。"</p>
86 </div>
87 {move || {
88 count
89 .get()
90 .map(|count| {
91 view! {
92 <p class="friends-count tabular-nums">
93 {format!("{count} 位朋友 · 按添加顺序")}
94 </p>
95 }
96 })
97 }}
98 </header>
99 }
100}
101
102#[component]
105fn FriendsBody(initial: Option<Vec<FriendLink>>, card: Option<SiteCard>) -> impl IntoView {
106 let listing = RwSignal::new(initial);
107 let retrying = RwSignal::new(false);
108 let retry_failed = RwSignal::new(false);
109
110 let count = move || {
111 listing.with(|listing| {
112 listing
113 .as_ref()
114 .filter(|links| !links.is_empty())
115 .map(Vec::len)
116 })
117 };
118
119 let on_retry = Callback::new(move |()| {
120 retrying.set(true);
121 retry_failed.set(false);
122 leptos::task::spawn_local(async move {
123 let result = list_friend_links().await;
124 let _ = retrying.try_set(false);
125 match result {
126 Ok(list) => {
127 let _ = listing.try_set(Some(list));
128 }
129 Err(_) => {
130 let _ = retry_failed.try_set(true);
131 }
132 }
133 });
134 });
135
136 view! {
137 <FriendsHeader count=Signal::derive(count)/>
138 {move || match listing.get() {
139 Some(links) if !links.is_empty() => {
140 view! {
141 <ul class="friends-grid page-enter" style="--enter-index: 1">
142 {links
143 .into_iter()
144 .map(|link| view! { <li><FriendCard link=link/></li> })
145 .collect_view()}
146 </ul>
147 }
148 .into_any()
149 }
150 Some(_) => {
151 view! {
152 <div class="friends-empty page-enter">
153 <span class="friends-empty-icon" aria-hidden="true">
154 <Icon name=IconName::Users size=20/>
155 </span>
156 <p class="friends-empty-title">"还没有友链"</p>
157 <p class="friends-empty-description">
158 "想交换链接的话,可以先看看下面这里的信息。"
159 </p>
160 </div>
161 }
162 .into_any()
163 }
164 None => {
165 view! {
166 <LoadError
167 title="友链暂时没能加载"
168 class="friends-error"
169 retrying=retrying
170 retry_failed=retry_failed
171 on_retry=on_retry
172 />
173 }
174 .into_any()
175 }
176 }}
177 {card.map(|card| view! { <SwapInfo card=card/> })}
178 }
179}
180
181fn display_host(url: &str) -> &str {
183 url.trim_start_matches("https://")
184 .trim_start_matches("http://")
185 .trim_end_matches('/')
186}
187
188#[component]
190fn FriendCard(link: FriendLink) -> impl IntoView {
191 let img_loaded = RwSignal::new(false);
192 let id = link.id;
193 let initial = link
194 .name
195 .chars()
196 .next()
197 .map(|c| c.to_uppercase().collect::<String>())
198 .unwrap_or_else(|| "?".to_string());
199
200 Effect::new(move |_| {
202 #[cfg(target_arch = "wasm32")]
203 {
204 use wasm_bindgen::JsCast;
205 if let Some(img) = web_sys::window()
206 .and_then(|window| window.document())
207 .and_then(|document| document.get_element_by_id(&format!("friend-avatar-{id}")))
208 .and_then(|element| element.dyn_into::<web_sys::HtmlImageElement>().ok())
209 {
210 if img.complete() {
211 img_loaded.set(img.natural_width() > 0);
212 }
213 }
214 }
215 });
216
217 let avatar = link
218 .avatar_url
219 .as_ref()
220 .filter(|url| !url.trim().is_empty())
221 .map(|avatar_url| {
222 view! {
223 <img
224 id=format!("friend-avatar-{id}")
225 class=move || {
226 if img_loaded.get() {
227 "friend-avatar-image is-loaded"
228 } else {
229 "friend-avatar-image"
230 }
231 }
232 src=avatar_url.clone()
233 alt=""
234 width="44"
235 height="44"
236 loading="lazy"
237 decoding="async"
238 on:load=move |_| img_loaded.set(true)
239 on:error=move |_| img_loaded.set(false)
240 />
241 }
242 });
243 let description = (!link.description.trim().is_empty())
244 .then(|| view! { <span class="friend-description">{link.description.clone()}</span> });
245
246 view! {
247 <a
248 class="friend-card"
249 href=link.url.clone()
250 target="_blank"
251 rel="noopener noreferrer"
252 aria-label=format!("访问 {}(在新标签页打开)", link.name)
253 >
254 <span class="friend-avatar" aria-hidden="true">
255 <span>{initial}</span>
256 {avatar}
257 </span>
258 <span class="friend-copy">
259 <span class="friend-name-row">
260 <span class="friend-name">{link.name.clone()}</span>
261 <span class="friend-go" aria-hidden="true">
262 <Icon name=IconName::ArrowUpRight size=15/>
263 </span>
264 </span>
265 <span class="friend-host">{display_host(&link.url).to_string()}</span>
266 {description}
267 </span>
268 </a>
269 }
270}
271
272#[component]
274fn SwapInfo(card: SiteCard) -> impl IntoView {
275 let mut rows = vec![("名称", card.name.clone(), false)];
276 if !card.url.is_empty() {
277 rows.push(("地址", card.url.clone(), true));
278 }
279 rows.push(("简介", card.description.clone(), false));
280 if !card.url.is_empty() {
281 rows.push(("订阅", card.feed_url(), true));
282 }
283 view! {
284 <section
285 class="friends-swap page-enter"
286 style="--enter-index: 2"
287 aria-labelledby="friends-swap-title"
288 >
289 <h2 id="friends-swap-title" class="friends-swap-title">
290 "交换友链"
291 </h2>
292 <p class="friends-swap-intro">"欢迎持续写作的个人站点。这是本站的信息,点击即可复制。"</p>
293 <dl class="friends-swap-grid">
294 {rows
295 .into_iter()
296 .map(|(label, value, mono)| view! { <SwapRow label=label value=value mono=mono/> })
297 .collect_view()}
298 </dl>
299 </section>
300 }
301}
302
303#[derive(Clone, Copy, PartialEq)]
304#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
305enum CopyState {
306 Idle,
307 Copied,
308 Failed,
309}
310
311#[component]
312fn SwapRow(label: &'static str, value: String, mono: bool) -> impl IntoView {
313 let state = RwSignal::new(CopyState::Idle);
314 let on_click = {
315 let value = value.clone();
316 move |_| {
317 #[cfg(target_arch = "wasm32")]
318 {
319 let value = value.clone();
320 leptos::task::spawn_local(async move {
321 let next = if crate::utils::js::copy_text(&value).await {
322 CopyState::Copied
323 } else {
324 CopyState::Failed
325 };
326 let _ = state.try_set(next);
327 crate::utils::time::sleep_ms(1600).await;
328 if state.try_get() == Some(CopyState::Copied) {
329 let _ = state.try_set(CopyState::Idle);
330 }
331 });
332 }
333 #[cfg(not(target_arch = "wasm32"))]
334 let _ = &value;
335 }
336 };
337 view! {
338 <div class="friends-swap-row">
339 <dt>{label}</dt>
340 <dd
342 class=if mono { "is-mono" } else { "" }
343 data-selectable=move || (state.get() == CopyState::Failed).then_some("true")
344 >
345 {value}
346 </dd>
347 <button
348 type="button"
349 class="friends-swap-copy"
350 data-state=move || match state.get() {
351 CopyState::Idle => "idle",
352 CopyState::Copied => "copied",
353 CopyState::Failed => "failed",
354 }
355 aria-label=format!("复制{label}")
356 on:click=on_click
357 >
358 {move || match state.get() {
359 CopyState::Idle => "复制",
360 CopyState::Copied => "已复制",
361 CopyState::Failed => "手动复制",
362 }}
363 </button>
364 <span class="sr-only" role="status" aria-live="polite">
365 {move || match state.get() {
366 CopyState::Copied => format!("已复制{label}"),
367 CopyState::Failed => format!("复制失败,请手动选择{label}复制"),
368 CopyState::Idle => String::new(),
369 }}
370 </span>
371 </div>
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::display_host;
378
379 #[test]
380 fn host_drops_scheme_and_trailing_slash() {
381 assert_eq!(display_host("https://example.com/"), "example.com");
382 assert_eq!(
383 display_host("http://blog.example.dev/notes"),
384 "blog.example.dev/notes"
385 );
386 }
387}