Skip to main content

yggdrasil/pages/
friends.rs

1//! 友链页面模块。
2//!
3//! 对应路由 `/friends`,展示通过 `list_friend_links` server function 获取的活跃友链
4//! 卡片网格。数据获取与三态渲染结构照 `archives.rs`;卡片视觉语言融合「极简名片」
5//! 设计:无实线边框 + 大圆角(`rounded-card` 32px)+ hover 上浮 + 名称转全站强调色。
6//! 头像磁贴内置兜底:无头像或图片加载失败时显示名称首字符。
7
8use dioxus::prelude::*;
9
10use crate::api::friends::list_friend_links;
11use crate::components::empty_state::EmptyState;
12use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
13use crate::components::skeletons::friends_skeleton::FriendsSkeleton;
14use crate::models::friend_link::FriendLink;
15
16/// 友链页面组件,对应路由 `/friends`。
17///
18/// 渲染页面标题,并委托给 `FriendsContent` 展示友链卡片网格。
19#[component]
20pub fn Friends() -> Element {
21    rsx! {
22        div { class: "animate-page-enter",
23            header { class: "page-header mb-6",
24                h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
25                    "友链"
26                }
27                p { class: "text-paper-secondary mt-2", "交换过链接的伙伴们" }
28            }
29            FriendsContent {}
30        }
31    }
32}
33
34/// 友链页面内容组件。
35///
36/// 通过 `use_server_future` 获取全部活跃友链;加载中显示骨架屏,失败显示错误提示。
37#[component]
38fn FriendsContent() -> Element {
39    let links_res = use_server_future(list_friend_links)?;
40
41    let links_data = links_res.read();
42    match &*links_data {
43        Some(Ok(links)) => {
44            if links.is_empty() {
45                rsx! {
46                    EmptyState {
47                        title: "还没有友链",
48                        description: "在后台「友链」中添加第一位伙伴吧。",
49                    }
50                }
51            } else {
52                rsx! {
53                    div { class: "mt-2 text-base text-paper-secondary",
54                        "共 "
55                        span { class: "font-medium text-paper-primary", "{links.len()}" }
56                        " 位伙伴"
57                    }
58                    div { class: "grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6",
59                        for link in links.iter() {
60                            FriendCard { key: "{link.id}", link: link.clone() }
61                        }
62                    }
63                }
64            }
65        }
66        Some(Err(e)) => {
67            rsx! {
68                div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败: {e}" }
69            }
70        }
71        None => {
72            rsx! {
73                DelayedSkeleton { FriendsSkeleton {} }
74            }
75        }
76    }
77}
78
79/// 单张友链名片卡。
80///
81/// 头像区为 56px 圆角磁贴:有 `avatar_url` 且未加载失败时渲染 `<img>` 覆盖磁贴,
82/// 否则显示名称首字符兜底。整卡被 `<a target="_blank">` 覆盖链接到对方站点。
83#[component]
84fn FriendCard(link: FriendLink) -> Element {
85    // 图片加载失败时置位,之后不再渲染 <img>(保持首字符磁贴兜底)。
86    let mut img_failed = use_signal(|| false);
87    let initial: String = link
88        .name
89        .chars()
90        .next()
91        .map(|c| c.to_uppercase().collect())
92        .unwrap_or_else(|| "?".to_string());
93
94    rsx! {
95        div { class: "group relative bg-paper-entry rounded-card p-8 shadow-sm border border-transparent hover:border-paper-border hover:-translate-y-0.5 hover:shadow-md transition-all duration-300",
96            div { class: "flex items-start gap-5",
97                div { class: "relative h-14 w-14 shrink-0 rounded-2xl bg-paper-code-bg flex items-center justify-center overflow-hidden",
98                    span { class: "text-xl font-semibold text-paper-primary select-none",
99                        "{initial}"
100                    }
101                    if let Some(avatar_url) = &link.avatar_url {
102                        if !img_failed() {
103                            img {
104                                class: "absolute inset-0 w-full h-full object-cover rounded-2xl",
105                                src: "{avatar_url}",
106                                alt: "{link.name} 的头像",
107                                onerror: move |_| img_failed.set(true),
108                            }
109                        }
110                    }
111                }
112                div { class: "flex-1 min-w-0",
113                    h3 { class: "text-lg font-semibold text-paper-primary group-hover:text-paper-accent transition-colors truncate",
114                        "{link.name}"
115                    }
116                    if !link.description.is_empty() {
117                        p { class: "text-sm text-paper-secondary mt-1.5 leading-relaxed line-clamp-2",
118                            "{link.description}"
119                        }
120                    }
121                }
122            }
123            div { class: "mt-6 flex items-center gap-1",
124                span { class: "text-sm text-paper-tertiary group-hover:text-paper-secondary transition-colors",
125                    "访问站点"
126                }
127                span { class: "text-sm text-paper-tertiary group-hover:text-paper-secondary group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform inline-block",
128                    "↗"
129                }
130            }
131            a {
132                class: "absolute inset-0 z-10",
133                href: "{link.url}",
134                target: "_blank",
135                rel: "noopener noreferrer",
136                aria_label: "访问 {link.name}",
137            }
138        }
139    }
140}