Skip to main content

yggdrasil/pages/admin/write/
cover.rs

1//! 封面上传、素材选择与预览,上传临时状态仅在本组件中持有。
2use 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/// 从 `<input type="file">` 的 change 事件里取第一个文件。
11#[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/// 封面上传子组件。
19///
20/// 封装封面图的全部状态与交互:拖拽 / 粘贴 / 选择文件上传、输入链接、素材库、预览、移除。
21/// 最终 URL 写入共享表单,上传状态单独用于保存拦截:
22/// - `form.cover_image`:子组件写入最终 URL,父组件读取用于保存;
23/// - `cover_uploading`:子组件上传时置 true,父组件在保存时读取以拦截
24///   上传中的保存(避免封面 URL 尚未落定就提交导致封面丢失)。
25///
26/// 其余上传中间态(error/url/drag)对本组件私有。
27#[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    // 封面图上传状态:错误消息、URL 输入框展开、拖拽高亮(cover_uploading 由父组件传入)。
36    let cover_error = RwSignal::new(None::<String>);
37    let cover_url_mode = RwSignal::new(false);
38    let cover_drag_active = RwSignal::new(false);
39    // 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
40    let cover_url_input = RwSignal::new(String::new());
41    // 素材选择 modal 显隐。
42    let picker_visible = RwSignal::new(false);
43    // 封面预览图加载状态:cover_image 变化时置 false,img onload 置 true。
44    // 从素材库选择/拖拽替换/URL 输入后,预览图 ?w=600 版本未缓存需重新加载,
45    // 加载期间显示骨架占位,避免空白无反馈(与上传中的骨架语义独立)。
46    let cover_img_loaded = RwSignal::new(true);
47
48    // 封面图上传:三条入口(file input / drop / paste)拿到 File 后统一调用。
49    #[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    // 灯箱绑定 + 预览图加载占位:封面有图时点击放大查看。
67    // 订阅 cover_image:图片出现/更换后重跑,data-lb-bound 守卫保证幂等。
68    // 新 src 待加载时置 cover_img_loaded=false,由 img onload 回填 true。
69    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    // 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。
83    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        // P5 §8.2:封面预览灯箱按需注入(脚本未就绪时迟到调用入队兜底)。
150        <LightboxAssets/>
151        // 封面区:21:9;空态斜纹底加「拖拽、粘贴或点击上传」,有图时显示预览(点击放大)。
152        // 整个区域接收拖拽与粘贴;下方一行按钮放素材库、输入链接或更换、移除。
153        <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            // dragover 必须 prevent_default,否则浏览器直接打开文件。
166            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                    // lightbox-single:由全局 lightbox.js 接管点击放大,data-src 为原图。
183                    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                    // label 包裹空态:点击直接触发隐藏的 file input。
214                    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        // 素材选择:选中回填封面 URL(在弹窗里上传新图成功同样回填)。
355        <AssetPickerModal
356            visible=picker_visible
357            cover_uploading=cover_uploading
358            on_select=on_pick_cover
359        />
360    }
361}