yggdrasil/components/assets/
asset_upload.rs1use dioxus::prelude::*;
19
20use crate::components::ui::{ModalShell, BTN_ICON, EXIT_ANIM_MS, SPINNER_SVG};
21
22#[cfg(target_arch = "wasm32")]
23use super::upload_pool::{enqueue_files, set_status, UploadPool};
24pub(crate) use super::upload_pool::{UploadItem, UploadStatus};
25
26#[cfg(target_arch = "wasm32")]
27use crate::bridges::tiptap::upload_image_file;
28#[cfg(target_arch = "wasm32")]
32use dioxus::html::HasFileData;
33#[cfg(target_arch = "wasm32")]
34use dioxus::web::WebFileExt;
35
36#[component]
41#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
42pub fn AssetUploadModal(mut visible: Signal<bool>, on_uploaded: EventHandler<()>) -> Element {
43 let mut items: Signal<Vec<UploadItem>> = use_signal(Vec::new);
44 let mut drag_active = use_signal(|| false);
45 let mut closing = use_signal(|| false);
47 #[cfg(target_arch = "wasm32")]
49 let pool = use_hook(|| std::rc::Rc::new(UploadPool::new()));
50 let mut concurrency: Signal<i32> =
53 use_signal(|| crate::models::settings::DEFAULT_UPLOAD_CONCURRENCY);
54
55 #[cfg(target_arch = "wasm32")]
57 {
58 use_effect(move || {
59 spawn(async move {
60 if let Ok(s) = crate::api::settings::get_upload_settings().await {
61 concurrency.set(s.concurrency);
62 }
63 });
64 });
65 }
66
67 #[cfg(target_arch = "wasm32")]
71 {
72 use std::cell::RefCell;
73 use std::rc::Rc;
74 type KeyClosure = wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>;
75 type PasteClosure = wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::ClipboardEvent)>;
76 type Listeners = Rc<RefCell<Option<(KeyClosure, PasteClosure)>>>;
77 let listeners: Listeners = use_hook(|| Rc::new(RefCell::new(None)));
78 let listeners_for_drop = listeners.clone();
79 let scope_id = dioxus::core::Runtime::current()
83 .try_current_scope_id()
84 .unwrap_or(dioxus::core::ScopeId::ROOT);
85 let pool_for_paste = pool.clone();
87 use_effect(move || {
88 let Some(window) = web_sys::window() else {
89 return;
90 };
91 let on_keydown =
92 wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
93 if !*visible.peek() {
94 return;
95 }
96 if ev.key() == "Escape" {
97 closing.set(true);
98 visible.set(false);
99 }
100 })
101 as Box<dyn FnMut(web_sys::KeyboardEvent)>);
102 let pool_in_paste = pool_for_paste.clone();
104 let on_paste =
105 wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::ClipboardEvent| {
106 if !*visible.peek() {
107 return;
108 }
109 let Some(dt) = ev.clipboard_data() else {
110 return;
111 };
112 let Some(list) = dt.files() else {
113 return;
114 };
115 if list.length() == 0 {
117 return;
118 }
119 ev.prevent_default();
120 let collected: Vec<web_sys::File> =
121 (0..list.length()).filter_map(|i| list.item(i)).collect();
122 if !collected.is_empty() {
123 let pool_call = pool_in_paste.clone();
128 dioxus::core::Runtime::current().in_scope(scope_id, move || {
129 enqueue_files(items, pool_call, concurrency, on_uploaded, collected);
130 });
131 }
132 })
133 as Box<dyn FnMut(web_sys::ClipboardEvent)>);
134 use wasm_bindgen::JsCast;
135 let _ = window
136 .add_event_listener_with_callback("keydown", on_keydown.as_ref().unchecked_ref());
137 let _ =
138 window.add_event_listener_with_callback("paste", on_paste.as_ref().unchecked_ref());
139 *listeners.borrow_mut() = Some((on_keydown, on_paste));
140 });
141 use_drop(move || {
142 if let Some((on_keydown, on_paste)) = listeners_for_drop.borrow_mut().take() {
143 if let Some(window) = web_sys::window() {
144 use wasm_bindgen::JsCast;
145 let _ = window.remove_event_listener_with_callback(
146 "keydown",
147 on_keydown.as_ref().unchecked_ref(),
148 );
149 let _ = window.remove_event_listener_with_callback(
150 "paste",
151 on_paste.as_ref().unchecked_ref(),
152 );
153 }
154 }
155 });
156 }
157
158 #[cfg(target_arch = "wasm32")]
160 let (pool_for_drop, pool_for_change) = (pool.clone(), pool.clone());
161 #[cfg(target_arch = "wasm32")]
164 let (pool_for_rows, pool_for_remove) = (pool.clone(), pool.clone());
165
166 let items_snapshot = items.read().clone();
168
169 rsx! {
170 ModalShell {
171 visible,
172 closing,
173 title: "上传素材",
174
175 div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
177 h2 { class: "text-lg font-bold text-[var(--color-paper-primary)]",
178 "上传素材"
179 }
180 div { class: "flex-1" }
181 button {
182 class: "{BTN_ICON} shrink-0 rounded-full",
183 aria_label: "关闭",
184 onclick: move |_| {
185 closing.set(true);
186 visible.set(false);
187 },
188 "×"
189 }
190 }
191
192 UploadPanel {
194 items: items_snapshot,
195 on_retry: move |item_id: u64| {
196 #[cfg(target_arch = "wasm32")]
197 if let Some(file) = pool_for_rows.find_file(item_id) {
198 set_status(&mut items, item_id, UploadStatus::Uploading);
199 spawn(async move {
200 match upload_image_file(file).await {
201 Ok(_) => {
202 set_status(&mut items, item_id, UploadStatus::Done);
203 on_uploaded.call(());
204 }
205 Err(msg) => set_status(&mut items, item_id, UploadStatus::Failed(msg)),
206 }
207 });
208 }
209 },
210 on_remove: move |item_id: u64| {
211 if let Some(item) = items.write().iter_mut().find(|item| item.id == item_id) {
212 item.removing = true;
213 }
214 #[cfg(target_arch = "wasm32")]
215 pool_for_remove.remove_file(item_id);
216 spawn(async move {
217 crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
218 items.write().retain(|item| item.id != item_id);
219 });
220 },
221
222 label {
225 class: "flex flex-col items-center justify-center gap-2 px-6 py-10 border border-dashed rounded-2xl cursor-pointer transition-colors",
226 class: if drag_active() { "border-[var(--color-paper-primary)] bg-[var(--color-paper-theme)]" } else { "border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] hover:border-[var(--color-paper-primary)]" },
227 ondragover: move |evt| {
229 evt.prevent_default();
230 drag_active.set(true);
231 },
232 ondragenter: move |evt| {
233 evt.prevent_default();
234 },
235 ondragleave: move |_| {
236 drag_active.set(false);
237 },
238 ondrop: move |evt| {
239 evt.prevent_default();
240 drag_active.set(false);
241 #[cfg(target_arch = "wasm32")]
242 {
243 let collected: Vec<web_sys::File> = evt
244 .files()
245 .into_iter()
246 .filter_map(|f| f.get_web_file())
247 .collect();
248 if !collected.is_empty() {
249 enqueue_files(
250 items,
251 pool_for_drop.clone(),
252 concurrency,
253 on_uploaded,
254 collected,
255 );
256 }
257 }
258 },
259 UploadDropZoneContent {}
260 input {
261 r#type: "file",
262 accept: "image/jpeg,image/png,image/gif,image/webp",
263 multiple: true,
264 class: "hidden",
265 onchange: move |evt| {
266 #[cfg(target_arch = "wasm32")]
267 {
268 let collected: Vec<web_sys::File> = evt
269 .files()
270 .into_iter()
271 .filter_map(|f| f.get_web_file())
272 .collect();
273 if !collected.is_empty() {
274 enqueue_files(
275 items,
276 pool_for_change.clone(),
277 concurrency,
278 on_uploaded,
279 collected,
280 );
281 }
282 }
283 },
284 }
285 }
286
287
288
289 }
290 }
291 }
292}
293
294#[component]
296pub(crate) fn UploadPanel(
297 items: Vec<UploadItem>,
298 on_retry: EventHandler<u64>,
299 on_remove: EventHandler<u64>,
300 children: Element,
301) -> Element {
302 rsx! {
303 div { class: "flex-1 overflow-y-auto p-6 flex flex-col gap-4",
304 {children}
305 UploadRows { items, on_retry, on_remove }
306 }
307 }
308}
309
310#[component]
312pub(crate) fn UploadDropZoneContent(
313 #[props(default = "拖拽图片到这里,或点击选择")] title: &'static str,
314 #[props(default = "支持 Ctrl/⌘+V 粘贴 · JPEG / PNG / GIF / WebP · 单张 ≤ 5MB")]
315 hint: &'static str,
316) -> Element {
317 rsx! {
318 div { class: "pointer-events-none flex flex-col items-center gap-2",
319 svg { class: "w-8 h-8 text-[var(--color-paper-tertiary)]", xmlns: "http://www.w3.org/2000/svg",
320 view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "1.8",
321 stroke_linecap: "round", stroke_linejoin: "round",
322 path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
323 polyline { points: "17 8 12 3 7 8" }
324 line { x1: "12", y1: "3", x2: "12", y2: "15" }
325 }
326 p { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" }
327 p { class: "text-xs text-[var(--color-paper-tertiary)]", "{hint}" }
328 }
329 }
330}
331
332#[component]
334pub(crate) fn UploadRows(
335 items: Vec<UploadItem>,
336 on_retry: EventHandler<u64>,
337 on_remove: EventHandler<u64>,
338) -> Element {
339 rsx! {
340 div { class: "flex flex-col gap-2",
341 for (index, item) in items.iter().enumerate() {
342 {
343 let id = item.id;
344 let status_key = match item.status {
345 UploadStatus::Queued => "q",
346 UploadStatus::Uploading => "u",
347 UploadStatus::Done => "d",
348 UploadStatus::Failed(_) => "f",
349 };
350 rsx! {
351 div { key: "{id}",
352 class: "flex items-center gap-3 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] px-4 py-3",
353 class: if item.removing { "animate-row-leave" } else { "animate-row-enter" },
354 style: if item.removing { String::new() } else { format!("animation-delay:{}ms", index * 30) },
355 div { class: "flex-1 min-w-0",
356 p { class: "text-sm truncate text-[var(--color-paper-primary)]", title: "{item.name}", "{item.name}" }
357 p { class: "text-xs font-mono text-[var(--color-paper-tertiary)]", "{item.size}" }
358 }
359 for status_key in std::iter::once(status_key) {
360 div { key: "{status_key}", class: "flex items-center gap-2 shrink-0 animate-status-pop",
361 match &item.status {
362 UploadStatus::Queued => rsx! { span { class: "text-xs text-[var(--color-paper-tertiary)] shrink-0", "等待中" } },
363 UploadStatus::Uploading => rsx! {
364 span { class: "flex items-center gap-1.5 text-xs text-[var(--color-paper-secondary)] shrink-0",
365 span { class: "inline-block w-3.5 h-3.5", dangerous_inner_html: SPINNER_SVG }
366 "上传中"
367 }
368 },
369 UploadStatus::Done => rsx! { span { class: "text-xs text-emerald-600 dark:text-emerald-400 shrink-0", "✓ 已上传" } },
370 UploadStatus::Failed(message) => rsx! {
371 span { class: "text-xs text-red-500 shrink-0 max-w-56 truncate", title: "{message}", "{message}" }
372 button { r#type: "button", class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] shrink-0",
373 onclick: move |_| on_retry.call(id), "重试" }
374 button { r#type: "button", class: "text-xs cursor-pointer text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
375 aria_label: "移除", onclick: move |_| on_remove.call(id), "×" }
376 },
377 }
378 }
379 }
380 }
381 }
382 }
383 }
384 }
385 }
386}