yggdrasil/pages/admin/assets/
references.rs1use super::card::ref_kind;
4use crate::components::forms::FormInput;
5use crate::components::ui::{
6 button_class, use_toast, ButtonSize, ButtonVariant, IconButton, IconName,
7};
8use crate::models::asset::{AssetDto, AssetRef, AssetRefPostStatus};
9use crate::routes::Route;
10use crate::utils::error::failure_message;
11use crate::utils::format_bytes;
12use leptos::prelude::*;
13
14fn ref_row(r: &AssetRef) -> AnyView {
17 let kind = ref_kind(r);
18 match r {
19 AssetRef::Post {
20 post_id,
21 title,
22 slug,
23 status,
24 } => {
25 let note = status.note();
26 if *status == AssetRefPostStatus::Published {
27 view! {
28 <a
29 class="asset-ref"
30 href=format!("/post/{slug}")
31 target="_blank"
32 rel="noopener"
33 >
34 <span class="asset-ref-kind">{kind}</span>
35 <span class="asset-ref-label">{title.clone()}</span>
36 </a>
37 }
38 .into_any()
39 } else {
40 view! {
41 <a class="asset-ref" href=Route::WriteEdit { id: *post_id }.to_string()>
42 <span class="asset-ref-kind">
43 {kind}
44 {note.map(|note| format!(" · {note}"))}
45 </span>
46 <span class="asset-ref-label">{title.clone()}</span>
47 </a>
48 }
49 .into_any()
50 }
51 }
52 AssetRef::Comment {
53 author_name,
54 post_id,
55 post_title,
56 post_slug,
57 post_status,
58 ..
59 } => {
60 let label = format!("{author_name} 于《{post_title}》");
61 if *post_status == AssetRefPostStatus::Published {
62 view! {
63 <a
64 class="asset-ref"
65 href=format!("/post/{post_slug}")
66 target="_blank"
67 rel="noopener"
68 >
69 <span class="asset-ref-kind">{kind}</span>
70 <span class="asset-ref-label">{label}</span>
71 </a>
72 }
73 .into_any()
74 } else {
75 view! {
76 <a class="asset-ref" href=Route::WriteEdit { id: *post_id }.to_string()>
77 <span class="asset-ref-kind">{kind}</span>
78 <span class="asset-ref-label">{label}</span>
79 </a>
80 }
81 .into_any()
82 }
83 }
84 AssetRef::Note { note_id, title } => view! {
85 <a class="asset-ref" href=Route::EditNote { id: *note_id }.to_string()>
86 <span class="asset-ref-kind">{format!("{kind} · 含历史版本")}</span>
87 <span class="asset-ref-label">{title.clone()}</span>
88 </a>
89 }
90 .into_any(),
91 AssetRef::UserAvatar { label, .. } => view! {
92 <div class="asset-ref">
93 <span class="asset-ref-kind">{kind}</span>
94 <span class="asset-ref-label">{label.clone()}</span>
95 </div>
96 }
97 .into_any(),
98 AssetRef::FriendAvatar { name, .. } => view! {
99 <a class="asset-ref" href=Route::FriendsAdmin.to_string()>
100 <span class="asset-ref-kind">{kind}</span>
101 <span class="asset-ref-label">{name.clone()}</span>
102 </a>
103 }
104 .into_any(),
105 }
106}
107
108#[component]
111#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
112pub(super) fn AssetDetail(
113 #[prop(into)] asset: Signal<AssetDto>,
114 #[prop(into)] on_changed: Callback<()>,
115 #[prop(into)] on_delete: Callback<()>,
116 #[prop(into)] on_close: Callback<()>,
117) -> impl IntoView {
118 let toast = use_toast();
119 let a = asset.with_untracked(|d| d.asset.clone());
121 let id = a.id.clone();
122 let alt = RwSignal::new(a.alt.clone().unwrap_or_default());
123 let saving = RwSignal::new(false);
124 let used = Memo::new(move |_| asset.with(|d| d.ref_count > 0));
125 let ref_count = Memo::new(move |_| asset.with(|d| d.ref_count));
126 let saved_alt = Memo::new(move |_| asset.with(|d| d.asset.alt.clone().unwrap_or_default()));
127 let refs = Memo::new(move |_| asset.with(|d| d.refs.clone()));
128 let missing_alt = Memo::new(move |_| saved_alt.with(|s| s.trim().is_empty()));
129 let preview = format!("/uploads/{}?w=640", a.path);
130 let url = format!("/uploads/{}", a.path);
131
132 let save_alt = Callback::new(move |()| {
134 let next = alt.get_untracked().trim().to_string();
135 if next == saved_alt.with_untracked(|s| s.trim().to_string()) || saving.get_untracked() {
136 return;
137 }
138 saving.set(true);
139 let id = id.clone();
140 leptos::task::spawn_local(async move {
141 match crate::api::assets::update_asset_alt(id, next).await {
142 Ok(res) if res.success => {
143 toast.success("替代文本已保存");
144 on_changed.try_run(());
145 }
146 Ok(res) => toast.error(res.message),
147 Err(e) => toast.error(failure_message("保存失败", &e)),
148 }
149 saving.try_set(false);
150 });
151 });
152 let copy = {
153 let url = url.clone();
154 move |_| {
155 #[cfg(target_arch = "wasm32")]
156 {
157 let url = url.clone();
158 leptos::task::spawn_local(async move {
159 let origin = web_sys::window()
160 .and_then(|w| w.location().origin().ok())
161 .unwrap_or_default();
162 if crate::utils::js::copy_text(&format!("{origin}{url}")).await {
163 toast.success("已复制图片链接");
164 } else {
165 toast.error("复制失败,请手动复制路径");
166 }
167 });
168 }
169 }
170 };
171
172 view! {
173 <aside class="asset-detail" aria-label="素材详情">
174 <div class="asset-detail-head">
175 <h2>"详情"</h2>
176 <IconButton
177 icon=IconName::X
178 label="关闭详情"
179 size=ButtonSize::Sm
180 onclick=move |_| on_close.run(())
181 />
182 </div>
183 <div class="asset-detail-preview assets-lightbox">
185 <img
186 class="lightbox-single"
187 src=preview
188 alt=move || saved_alt.get()
189 data-error-text="本地文件已丢失"
190 />
191 </div>
192 <div class="asset-detail-file">
193 <p class="asset-detail-name">{a.filename.clone()}</p>
194 <p class="asset-detail-meta">
195 {(a.width > 0).then(|| format!("{}×{} · ", a.width, a.height))}
196 {format!("{} · {}", format_bytes(a.size_bytes), a.mime)}
197 </p>
198 <p class="asset-detail-path">{format!("/uploads/{}", a.path)}</p>
199 </div>
200 <div>
201 <label class="asset-detail-label" for="asset-alt">
202 <span>"替代文本"</span>
203 {move || {
204 missing_alt.get().then(|| view! { <span class="asset-detail-warn">"未填写"</span> })
205 }}
206 </label>
207 <FormInput
208 id="asset-alt"
209 placeholder="描述这张图片,供读屏与搜索使用"
210 maxlength=500
211 value=alt
212 disabled=saving
213 oninput=move |v: String| alt.set(v)
214 onblur=move |_| save_alt.run(())
215 onkeydown=move |e: leptos::ev::KeyboardEvent| {
216 if e.key() == "Enter" && !e.is_composing() {
217 e.prevent_default();
218 save_alt.run(());
219 }
220 }
221 />
222 </div>
223 <div>
224 <p class="asset-detail-label">"引用"</p>
225 {move || {
226 refs
227 .with(|refs| {
228 if refs.is_empty() {
229 view! {
230 <p class="asset-detail-empty">
231 "没有文章、笔记、评论或头像引用这张图片。"
232 </p>
233 }
234 .into_any()
235 } else {
236 view! {
237 <div class="asset-refs">
238 {refs
239 .iter()
240 .map(|r| view! { <div>{ref_row(r)}</div> })
241 .collect_view()}
242 </div>
243 }
244 .into_any()
245 }
246 })
247 }}
248 </div>
249 <div class="asset-detail-actions">
250 <button
251 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
252 type="button"
253 on:click=copy
254 >
255 "复制链接"
256 </button>
257 <button
258 class=button_class(ButtonVariant::Danger, ButtonSize::Md)
259 type="button"
260 disabled=move || used.get()
261 on:click=move |_| on_delete.run(())
262 >
263 "删除"
264 </button>
265 </div>
266 {move || {
267 used.get()
268 .then(|| {
269 view! {
270 <p class="asset-detail-note">
271 {move || format!("被 {} 处引用,移除引用后才能删除。", ref_count.get())}
272 </p>
273 }
274 })
275 }}
276 </aside>
277 }
278}