yggdrasil/pages/admin/assets/
card.rs1use crate::components::ui::{Icon, IconName};
4use crate::models::asset::{AssetDto, AssetRef};
5use crate::utils::format_bytes;
6use leptos::prelude::*;
7
8pub(super) fn ref_summary(asset: &AssetDto) -> String {
10 let Some(first) = asset.refs.first() else {
11 return "未引用".into();
12 };
13 let kind = ref_kind(first);
14 if asset.ref_count > 1 {
15 format!("{kind}等 {} 处", asset.ref_count)
16 } else {
17 kind.into()
18 }
19}
20
21pub(super) fn ref_kind(r: &AssetRef) -> &'static str {
23 match r {
24 AssetRef::Post { .. } => "文章",
25 AssetRef::Note { .. } => "笔记",
26 AssetRef::Comment { .. } => "评论",
27 AssetRef::UserAvatar { .. } => "头像",
28 AssetRef::FriendAvatar { .. } => "友链",
29 }
30}
31
32#[component]
37pub(super) fn AssetTile(
38 #[prop(into)] asset: Signal<AssetDto>,
39 #[prop(into)] active: Signal<bool>,
40 #[prop(into)] checked: Signal<bool>,
41 #[prop(into)] on_open: Callback<String>,
42 #[prop(into)] on_check: Callback<(String, bool)>,
43) -> impl IntoView {
44 let (id, thumb) = asset.with_untracked(|d| {
46 (
47 d.asset.id.clone(),
48 format!("/uploads/{}?thumb=300x300", d.asset.path),
49 )
50 });
51 let check_id = id.clone();
52 let filename = move || asset.with(|d| d.asset.filename.clone());
53 let orphan = Memo::new(move |_| asset.with(|d| d.ref_count == 0));
54 let dims = Memo::new(move |_| {
55 asset.with(|d| (d.asset.width > 0).then(|| format!("{}×{}", d.asset.width, d.asset.height)))
56 });
57 let no_alt = Memo::new(move |_| {
58 asset.with(|d| d.asset.alt.as_deref().is_none_or(|t| t.trim().is_empty()))
59 });
60 let meta = move || {
61 asset.with(|d| format!("{} · {}", format_bytes(d.asset.size_bytes), ref_summary(d)))
62 };
63 view! {
64 <li
65 class="asset-tile"
66 data-active=move || active.get().then_some("true")
67 data-checked=move || checked.get().then_some("true")
68 >
69 <button
70 class="asset-tile-open"
71 type="button"
72 aria-pressed=move || active.get().to_string()
73 aria-label=move || format!("查看 {}", filename())
74 on:click=move |_| on_open.run(id.clone())
75 >
76 <span class="asset-tile-thumb">
77 <img src=thumb alt="" loading="lazy" decoding="async"/>
78 {move || dims.get().map(|d| view! { <span class="asset-tile-dims">{d}</span> })}
79 {move || orphan.get().then(|| view! { <span class="asset-tile-badge">"未引用"</span> })}
80 {move || {
81 no_alt.get()
82 .then(|| {
83 view! {
84 <span
85 class="asset-tile-noalt"
86 title="未填写替代文本"
87 aria-hidden="true"
88 ></span>
89 }
90 })
91 }}
92 </span>
93 <span class="asset-tile-text">
94 <span class="asset-tile-name">{filename}</span>
95 <span class="asset-tile-meta">{meta}</span>
96 </span>
97 </button>
98 {move || {
99 let check_id = check_id.clone();
100 orphan.get()
101 .then(|| {
102 view! {
103 <label
104 class="asset-tile-check"
105 title=move || if checked.get() { "取消选择" } else { "选择" }
106 >
107 <input
108 type="checkbox"
109 prop:checked=move || checked.get()
110 aria-label=move || format!("选择 {}", filename())
111 on:change=move |e| {
112 on_check.run((check_id.clone(), event_target_checked(&e)))
113 }
114 />
115 <Icon name=IconName::Check size=12/>
116 </label>
117 }
118 })
119 }}
120 </li>
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::models::asset::{Asset, AssetRefPostStatus};
128
129 fn asset(refs: Vec<AssetRef>) -> AssetDto {
130 AssetDto {
131 asset: Asset {
132 id: "a".into(),
133 path: "p.webp".into(),
134 filename: "p.webp".into(),
135 mime: "image/webp".into(),
136 size_bytes: 1,
137 width: 1,
138 height: 1,
139 alt: None,
140 created_at: chrono::Utc::now(),
141 },
142 ref_count: refs.len() as i64,
143 refs,
144 }
145 }
146
147 #[test]
148 fn ref_summary_names_the_first_kind_and_counts_the_rest() {
149 assert_eq!(ref_summary(&asset(vec![])), "未引用");
150 let post = AssetRef::Post {
151 post_id: 1,
152 title: "t".into(),
153 slug: "t".into(),
154 status: AssetRefPostStatus::Published,
155 };
156 let avatar = AssetRef::UserAvatar {
157 user_id: 1,
158 label: "xfy".into(),
159 };
160 assert_eq!(ref_summary(&asset(vec![post.clone()])), "文章");
161 assert_eq!(ref_summary(&asset(vec![post, avatar])), "文章等 2 处");
162 }
163}