yggdrasil/pages/admin/write/
cover.rs1use super::state::PostDraft;
3use crate::components::assets::{AssetPickerModal, AssetSelection};
4use crate::components::enhance_assets::LightboxAssets;
5use crate::components::forms::FormInput;
6use crate::components::ui::{button_class, ButtonSize, ButtonVariant, Icon, IconName};
7use leptos::ev;
8use leptos::prelude::*;
9
10#[cfg(target_arch = "wasm32")]
12fn first_input_file(event: &ev::Event) -> Option<web_sys::File> {
13 event_target::<web_sys::HtmlInputElement>(event)
14 .files()
15 .and_then(|files| files.item(0))
16}
17
18#[component]
28#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
29pub(super) fn CoverUploader(
30 form: RwSignal<PostDraft>,
31 cover_uploading: RwSignal<bool>,
32) -> impl IntoView {
33 let cover_image = Memo::new(move |_| form.with(|f| f.cover_image.clone()));
34 let has_cover = Memo::new(move |_| !cover_image.with(String::is_empty));
35 let cover_error = RwSignal::new(None::<String>);
37 let cover_url_mode = RwSignal::new(false);
38 let cover_drag_active = RwSignal::new(false);
39 let cover_url_input = RwSignal::new(String::new());
41 let picker_visible = RwSignal::new(false);
43 let cover_img_loaded = RwSignal::new(true);
47
48 #[cfg(target_arch = "wasm32")]
50 let spawn_cover_upload = move |file: web_sys::File| {
51 cover_uploading.set(true);
52 cover_error.set(None);
53 leptos::task::spawn_local(async move {
54 match crate::bridges::tiptap::upload_image_file(file).await {
55 Ok(url) => {
56 let _ = form.try_update(|f| f.cover_image = url);
57 }
58 Err(msg) => {
59 let _ = cover_error.try_set(Some(format!("封面上传失败:{msg}")));
60 }
61 }
62 let _ = cover_uploading.try_set(false);
63 });
64 };
65
66 Effect::new(move |_| {
70 let cv = cover_image.get();
71 if cv.is_empty() {
72 cover_img_loaded.set(true);
73 return;
74 }
75 cover_img_loaded.set(false);
76 #[cfg(target_arch = "wasm32")]
77 {
78 crate::bridges::globals::init_lightbox(&[".cover-preview"]);
79 }
80 });
81
82 let preview_src = move || {
84 let cv = cover_image.get();
85 if cv.starts_with("/uploads/") {
86 let base = cv.split('?').next().unwrap_or(&cv).to_string();
87 if cv.contains('?') {
88 format!("{base}&w=600")
89 } else {
90 format!("{base}?w=600")
91 }
92 } else {
93 cv
94 }
95 };
96 let confirm_url = move || {
97 let v = cover_url_input.get_untracked().trim().to_string();
98 if !v.is_empty() {
99 form.update(|f| f.cover_image = v);
100 cover_error.set(None);
101 cover_url_mode.set(false);
102 }
103 };
104
105 let on_drop = move |evt: ev::DragEvent| {
106 evt.prevent_default();
107 cover_drag_active.set(false);
108 if !cover_uploading.get_untracked() {
109 #[cfg(target_arch = "wasm32")]
110 if let Some(file) = evt
111 .data_transfer()
112 .and_then(|dt| dt.files())
113 .and_then(|list| list.item(0))
114 {
115 spawn_cover_upload(file);
116 }
117 }
118 };
119 let on_paste = move |evt: ev::ClipboardEvent| {
120 evt.prevent_default();
121 if !cover_uploading.get_untracked() {
122 #[cfg(target_arch = "wasm32")]
123 {
124 let file = evt
125 .clipboard_data()
126 .and_then(|dt| dt.files())
127 .and_then(|list| list.item(0));
128 if let Some(file) = file {
129 spawn_cover_upload(file);
130 }
131 }
132 }
133 };
134 let on_file_change = move |evt: ev::Event| {
135 #[cfg(target_arch = "wasm32")]
136 if let Some(file) = first_input_file(&evt) {
137 spawn_cover_upload(file);
138 }
139 };
140
141 let on_pick_cover = move |picks: Vec<AssetSelection>| {
142 if let Some(first) = picks.into_iter().next() {
143 form.update(|f| f.cover_image = first.url);
144 cover_error.set(None);
145 }
146 };
147
148 view! {
149 <LightboxAssets/>
151 <div
154 class="write-cover cover-preview"
155 data-state=move || {
156 if cover_uploading.get() {
157 "uploading"
158 } else if has_cover.get() {
159 "filled"
160 } else {
161 "empty"
162 }
163 }
164 data-drag=move || cover_drag_active.get().then_some("true")
165 on:dragover=move |evt: ev::DragEvent| {
167 evt.prevent_default();
168 if !cover_uploading.get_untracked() {
169 cover_drag_active.set(true);
170 }
171 }
172 on:dragenter=move |evt: ev::DragEvent| evt.prevent_default()
173 on:dragleave=move |_| cover_drag_active.set(false)
174 on:drop=on_drop
175 on:paste=on_paste
176 >
177 {move || {
178 if cover_uploading.get() {
179 view! { <span class="write-cover-note" role="status">"正在上传…"</span> }
180 .into_any()
181 } else if has_cover.get() {
182 view! {
184 <img
185 class="lightbox-single"
186 data-src=move || cover_image.get()
187 src=preview_src
188 alt="封面预览"
189 on:load=move |_| cover_img_loaded.set(true)
190 on:error=move |_| {
191 cover_img_loaded.set(true);
192 cover_error.set(Some("封面图加载失败,请检查链接".to_string()));
193 }
194 />
195 {move || {
196 (!cover_img_loaded.get())
197 .then(|| view! { <span class="write-cover-loading" aria-hidden="true"></span> })
198 }}
199 {move || {
200 cover_drag_active
201 .get()
202 .then(|| {
203 view! {
204 <span class="write-cover-note write-cover-drop">
205 "松开以更换封面"
206 </span>
207 }
208 })
209 }}
210 }
211 .into_any()
212 } else {
213 view! {
215 <label class="write-cover-empty">
216 <Icon name=IconName::Upload size=18/>
217 {move || {
218 if cover_drag_active.get() { "松开以上传" } else { "拖拽、粘贴或点击上传" }
219 }}
220 <input
221 type="file"
222 accept="image/jpeg,image/png,image/gif,image/webp"
223 class="sr-only"
224 on:change=on_file_change
225 />
226 </label>
227 }
228 .into_any()
229 }
230 }}
231 </div>
232
233 {move || {
234 if cover_url_mode.get() && !has_cover.get() {
235 view! {
236 <div class="write-cover-url">
237 <FormInput
238 class="field field-sm"
239 r#type="url"
240 aria_label="封面图片链接"
241 placeholder="粘贴图片链接"
242 value=cover_url_input
243 oninput=move |v: String| cover_url_input.set(v)
244 onkeydown=move |e: ev::KeyboardEvent| {
245 if e.key() == "Enter" && !e.is_composing() {
246 confirm_url();
247 } else if e.key() == "Escape" {
248 cover_url_mode.set(false);
249 }
250 }
251 />
252 <button
253 class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
254 type="button"
255 on:click=move |_| confirm_url()
256 >
257 "确认"
258 </button>
259 <button
260 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
261 type="button"
262 on:click=move |_| {
263 cover_url_mode.set(false);
264 cover_url_input.set(String::new());
265 }
266 >
267 "取消"
268 </button>
269 </div>
270 }
271 .into_any()
272 } else {
273 view! {
274 <div class="write-cover-actions">
275 {move || {
276 has_cover
277 .get()
278 .then(|| {
279 view! {
280 <label class=button_class(
281 ButtonVariant::Secondary,
282 ButtonSize::Sm,
283 )>
284 "更换"
285 <input
286 type="file"
287 accept="image/jpeg,image/png,image/gif,image/webp"
288 class="sr-only"
289 disabled=move || cover_uploading.get()
290 on:change=on_file_change
291 />
292 </label>
293 }
294 })
295 }}
296 <button
297 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
298 type="button"
299 disabled=move || cover_uploading.get()
300 on:click=move |_| picker_visible.set(true)
301 >
302 "素材库"
303 </button>
304 {move || {
305 if has_cover.get() {
306 view! {
307 <button
308 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
309 type="button"
310 on:click=move |_| {
311 form.update(|f| f.cover_image = String::new());
312 cover_error.set(None);
313 }
314 >
315 "移除"
316 </button>
317 }
318 .into_any()
319 } else {
320 view! {
321 <button
322 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
323 type="button"
324 disabled=move || cover_uploading.get()
325 on:click=move |_| {
326 cover_url_input.set(String::new());
327 cover_url_mode.set(true);
328 }
329 >
330 "输入链接"
331 </button>
332 }
333 .into_any()
334 }
335 }}
336 </div>
337 }
338 .into_any()
339 }
340 }}
341
342 {move || {
343 cover_error
344 .get()
345 .map(|err| {
346 view! {
347 <p class="field-hint" data-tone="danger" role="alert">
348 {err}
349 </p>
350 }
351 })
352 }}
353
354 <AssetPickerModal
356 visible=picker_visible
357 cover_uploading=cover_uploading
358 on_select=on_pick_cover
359 />
360 }
361}