Skip to main content

yggdrasil/pages/
friends.rs

1//! 友链页:两列名片、空状态与加载失败,以及可一键复制的「交换友链」信息。
2
3use 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
15/// 友链路由,对应 `/friends`(`SsrMode::InOrder`)。
16///
17/// 友链列表与站点名片都在 `data()` 里创建;列表失败只让列表区块显示重试卡片,
18/// 名片失败则直接不显示「交换友链」,页面其余部分照常。
19pub 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                        // 逐个等待,保持 SSR 唤醒顺序确定。
57                        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    /// 悬停预取:与 `data()` 相同的取数函数与 source。
70    pub fn prefetch_data() {
71        prefetch_route_data((), unit(fetch_links));
72        prefetch_route_data((), unit(fetch_card));
73    }
74}
75
76/// 页头标题与说明;骨架屏复用,数据到达前后不跳动。
77///
78/// `count` 缺省时不渲染人数行(骨架屏);可传值、`Option` 或信号。
79#[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/// 友链主体。`initial` 为 `None` 表示列表加载失败;重试结果写进本地信号,
103/// 不重取外层 `Resource`(否则整段 DOM 会退回 `Suspense` 的骨架屏)。
104#[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
181/// 去掉协议与末尾斜杠的主机名,用作名片副标题。
182fn display_host(url: &str) -> &str {
183    url.trim_start_matches("https://")
184        .trim_start_matches("http://")
185        .trim_end_matches('/')
186}
187
188/// 整张名片是原生链接,在新标签页打开;头像加载失败时显示站名首字。
189#[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    // SSR 图片可能在 hydration 前已完成加载,补查缓存状态;失败时始终显示首字。
201    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/// 「交换友链」:本站名称、地址、简介与订阅,点击复制;复制失败时提示手动选择原文。
273#[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            // 复制失败时整段可一次选中,方便手动复制。
341            <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}