yggdrasil/pages/admin/
asset_picker.rs1use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
8use dioxus::prelude::*;
9
10#[cfg(target_arch = "wasm32")]
11use crate::api::assets::list_assets;
12use crate::models::asset::AssetDto;
13#[cfg(target_arch = "wasm32")]
14use crate::models::asset::{AssetFilter, AssetSort};
15
16#[component]
22#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
23pub fn AssetPickerModal(
24 mut visible: Signal<bool>,
25 on_select: EventHandler<String>,
26 cover_uploading: Signal<bool>,
27) -> Element {
28 #[allow(unused_mut)]
29 let mut assets: Signal<Vec<AssetDto>> = use_signal(Vec::new);
30 #[allow(unused_mut)]
31 let mut loading = use_signal(|| false);
32 let mut query = use_signal(String::new);
33 #[allow(unused_mut)]
34 let mut error = use_signal(|| None::<String>);
35
36 use_effect(move || {
38 let open = visible();
39 let q = query();
40 if open {
41 #[cfg(target_arch = "wasm32")]
42 spawn(async move {
43 loading.set(true);
44 match list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, 1).await {
45 Ok(resp) => {
46 assets.set(resp.assets);
47 error.set(None);
48 }
49 Err(e) => error.set(Some(e.to_string())),
50 }
51 loading.set(false);
52 });
53 }
54 });
55
56 if !visible() {
57 return rsx! {};
58 }
59
60 rsx! {
61 div {
63 class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-6",
64 onclick: move |_| visible.set(false),
65 div {
67 class: "w-full max-w-3xl max-h-[80vh] flex flex-col rounded-[2rem] bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)] shadow-xl overflow-hidden",
68 onclick: move |evt| evt.stop_propagation(),
69
70 div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
72 h2 { class: "text-lg font-bold text-[var(--color-paper-primary)] shrink-0",
73 "选择封面图"
74 }
75 FormInput {
76 r#type: "search",
77 placeholder: "搜索文件名 / alt",
78 value: query(),
79 class: INPUT_INLINE_CLASS,
80 oninput: move |v: String| query.set(v),
81 }
82 label { class: "shrink-0 text-sm font-medium px-4 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity cursor-pointer",
84 "上传新图"
85 input {
86 r#type: "file",
87 accept: "image/jpeg,image/png,image/gif,image/webp",
88 class: "hidden",
89 onchange: move |evt| {
90 #[cfg(target_arch = "wasm32")]
91 {
92 use dioxus::html::HasFileData;
93 use dioxus::web::WebFileExt;
94 if let Some(file) = evt.files().into_iter().next() {
95 if let Some(web_file) = file.get_web_file() {
96 cover_uploading.set(true);
97 spawn(async move {
98 match crate::tiptap_bridge::upload_image_file(web_file).await {
99 Ok(url) => {
100 on_select.call(url);
101 visible.set(false);
102 }
103 Err(msg) => error.set(Some(msg)),
105 }
106 cover_uploading.set(false);
107 });
108 }
109 }
110 }
111 },
112 }
113 }
114 button {
115 class: "shrink-0 w-8 h-8 flex items-center justify-center rounded-full text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
116 aria_label: "关闭",
117 onclick: move |_| visible.set(false),
118 "×"
119 }
120 }
121
122 div { class: "flex-1 overflow-y-auto p-4",
124 if let Some(err) = error() {
125 div { class: "py-12 text-center text-sm text-red-500", "加载失败:{err}" }
126 } else if loading() && assets.read().is_empty() {
127 div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
128 "加载中..."
129 }
130 } else if assets.read().is_empty() {
131 div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
132 "素材库为空,点击「上传新图」添加"
133 }
134 } else {
135 div { class: "grid grid-cols-3 sm:grid-cols-4 gap-3",
136 for asset in assets.read().iter() {
137 {
138 let url = format!("/uploads/{}", asset.asset.path);
139 let thumb = format!("{}?thumb=300x300", url);
140 rsx! {
141 button {
142 key: "{asset.asset.id}",
143 class: "group relative aspect-square rounded-2xl overflow-hidden border border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)] hover:shadow-md transition-all cursor-pointer",
144 title: "{asset.asset.filename}",
145 onclick: {
146 let url = url.clone();
147 move |_| {
148 on_select.call(url.clone());
149 visible.set(false);
150 }
151 },
152 img {
153 class: "w-full h-full object-cover",
154 src: "{thumb}",
155 alt: asset.asset.alt.clone().unwrap_or_else(|| { asset.asset.filename.clone() }),
156 loading: "lazy",
157 }
158 }
159 }
160 }
161 }
162 }
163 }
164 }
165 }
166 }
167 }
168}