1#[allow(unused_imports)]
10use dioxus::prelude::*;
11
12#[cfg(target_arch = "wasm32")]
14use crate::api::posts::{
15 create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
16};
17#[cfg(target_arch = "wasm32")]
18use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
19use crate::components::forms::{FormInput, FormSelect, INPUT_INLINE_CLASS};
21use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
22use crate::components::write_skeleton::WriteSkeleton;
23use crate::models::post::Post;
24use crate::pages::admin::asset_picker::AssetPickerModal;
25use crate::router::Route;
26use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
27#[cfg(target_arch = "wasm32")]
28use wasm_bindgen::closure::Closure;
29#[cfg(target_arch = "wasm32")]
35use dioxus::html::HasFileData;
36#[cfg(target_arch = "wasm32")]
37use dioxus::web::{WebEventExt, WebFileExt};
38
39#[component]
43#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
44pub fn Write() -> Element {
45 write_editor(None)
46}
47
48#[component]
52#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
53pub fn WriteEdit(id: i32) -> Element {
54 write_editor(Some(id))
55}
56
57#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
66fn write_editor(post_id: Option<i32>) -> Element {
67 let is_edit = post_id.is_some();
68
69 let mut title = use_signal(|| "".to_string());
71 let mut summary = use_signal(|| "".to_string());
72 let mut slug = use_signal(|| "".to_string());
73 let mut tags = use_signal(|| "".to_string());
74 let mut cover_image = use_signal(|| "".to_string());
75 let cover_uploading = use_signal(|| false);
79 let mut status = use_signal(|| "published".to_string());
80 let mut content = use_signal(|| "".to_string());
81 let mut loading = use_signal(|| true);
83 let mut saving = use_signal(|| false);
84 let mut error = use_signal(|| None::<String>);
85 let mut editor_content_set = use_signal(|| false);
86 let mut has_backfilled = use_signal(|| false);
87 let mut load_error = use_signal(|| None::<String>);
88
89 let mut edit_post = use_signal(|| None::<Post>);
91
92 #[cfg(target_arch = "wasm32")]
94 let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
95 #[cfg(target_arch = "wasm32")]
97 let ready = use_signal(|| false);
98
99 let uploads_in_flight = use_signal(UploadsInFlight::default);
101 let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
102
103 use_effect(move || {
105 if !is_edit || has_backfilled() {
106 return;
107 }
108 if let Some(ref post) = edit_post() {
109 has_backfilled.set(true);
110 title.set(post.title.clone());
111 summary.set(post.summary.clone().unwrap_or_default());
112 slug.set(post.slug.clone());
113 tags.set(post.tags.join(", "));
114 cover_image.set(post.cover_image.clone().unwrap_or_default());
115 status.set(post.status.as_str().to_string());
116 content.set(post.content_md.clone());
117 }
118 });
119
120 use_effect(move || {
122 if is_edit {
123 #[cfg(target_arch = "wasm32")]
124 if let Some(id) = post_id {
125 spawn(async move {
126 match get_post_by_id(id).await {
127 Ok(SinglePostResponse { post: Some(post) }) => {
128 edit_post.set(Some(post));
129 }
130 Ok(SinglePostResponse { post: None }) => {
131 load_error.set(Some("文章不存在".to_string()));
132 }
133 Err(e) => {
134 load_error.set(Some(format!("加载失败: {}", e)));
135 }
136 }
137 });
138 }
139 }
140 });
141
142 #[cfg(target_arch = "wasm32")]
144 use_drop(move || {
145 editor.set(None);
146 });
147
148 #[cfg(target_arch = "wasm32")]
152 use_effect(move || {
153 if is_edit && edit_post().is_none() {
159 return;
160 }
161 if editor.read().is_some() {
163 return;
164 }
165
166 let on_update = Closure::new({
169 let mut content = content;
170 move |md: String| content.set(md)
171 });
172 let on_ready = Closure::new({
173 let mut ready = ready;
174 move || ready.set(true)
175 });
176 let on_image_upload = crate::tiptap_bridge::make_upload_closure();
177 let on_upload_event = Closure::new({
178 let uploads_in_flight = uploads_in_flight;
179 let upload_errors = upload_errors;
180 move |ev: crate::tiptap_bridge::UploadEventJs| {
181 consume_upload_event(&ev, uploads_in_flight, upload_errors);
182 }
183 });
184 let on_run_code = crate::tiptap_bridge::make_run_code_closure();
186
187 let opts = crate::tiptap_bridge::EditorOptions::new();
189 opts.set_placeholder("在此输入内容...");
190 opts.set_on_update(&on_update);
191 opts.set_on_ready(&on_ready);
192 opts.set_on_image_upload(&on_image_upload);
193 opts.set_on_upload_event(&on_upload_event);
194 opts.set_on_run_code(&on_run_code);
195
196 match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) {
198 Ok(Some(inst)) => {
199 if is_edit && !editor_content_set() {
204 if let Some(post) = edit_post() {
205 let md = &post.content_md;
206 if !md.is_empty() {
207 inst.set_markdown(md);
208 }
209 }
210 editor_content_set.set(true);
211 }
212 let handle = EditorHandle::new(
213 inst,
214 on_update,
215 on_image_upload,
216 on_ready,
217 on_upload_event,
218 on_run_code,
219 );
220 editor.set(Some(handle));
221 }
222 Ok(None) => {
223 load_error.set(Some("编辑器容器未就绪".to_string()));
224 loading.set(false);
225 }
226 Err(e) => {
227 load_error.set(Some(format!("编辑器初始化错误: {:?}", e)));
228 loading.set(false);
229 }
230 }
231 });
232
233 #[cfg(target_arch = "wasm32")]
236 use_effect(move || {
237 if ready() {
238 loading.set(false);
239 }
240 });
241
242 #[cfg(not(target_arch = "wasm32"))]
244 use_effect(move || {
245 loading.set(false);
246 });
247
248 let mut on_submit = move |_| {
250 let in_flight = uploads_in_flight.read();
252 if in_flight.uploading > 0 || in_flight.error > 0 {
253 let msg = if in_flight.uploading > 0 {
254 format!(
255 "有 {} 张图片正在上传,请等待完成后再保存",
256 in_flight.uploading
257 )
258 } else {
259 format!(
260 "有 {} 张图片上传失败,请移除或重试后再保存",
261 in_flight.error
262 )
263 };
264 error.set(Some(msg));
265 return;
266 }
267 drop(in_flight);
268
269 if cover_uploading() {
271 error.set(Some("封面图正在上传,请等待完成后再保存".to_string()));
272 return;
273 }
274
275 if title().trim().is_empty() {
276 error.set(Some("标题不能为空".to_string()));
277 }
278
279 #[cfg(target_arch = "wasm32")]
281 if !title().trim().is_empty() {
282 let md = if let Some(handle) = &*editor.read() {
284 handle.instance().get_markdown()
285 } else {
286 content()
287 };
288
289 if md.contains("](blob:") || md.contains("data-upload-state") {
293 error.set(Some("检测到未完成上传的图片,请处理后保存".to_string()));
294 return;
295 }
296
297 if md.trim().is_empty() {
298 error.set(Some("内容不能为空".to_string()));
299 return;
300 }
301
302 let tags_list: Vec<String> = tags()
305 .split([',', ',', ';', ';'])
306 .map(|t| t.trim().to_string())
307 .filter(|t| !t.is_empty())
308 .collect();
309
310 let slug_opt = if slug().trim().is_empty() {
311 None
312 } else {
313 Some(slug().trim().to_string())
314 };
315
316 let summary_opt = if summary().trim().is_empty() {
317 None
318 } else {
319 Some(summary().trim().to_string())
320 };
321
322 let cover_image_opt = if cover_image().trim().is_empty() {
323 None
324 } else {
325 Some(cover_image().trim().to_string())
326 };
327
328 saving.set(true);
329 error.set(None);
330
331 if let Some(id) = post_id {
332 spawn(async move {
334 match update_post(
335 id,
336 title().trim().to_string(),
337 slug_opt,
338 summary_opt,
339 md,
340 status(),
341 tags_list,
342 cover_image_opt,
343 )
344 .await
345 {
346 Ok(CreatePostResponse { success: true, .. }) => {
347 saving.set(false);
348 let _ = dioxus::router::navigator().push(Route::Posts {});
349 }
350 Ok(CreatePostResponse {
351 success: false,
352 message,
353 ..
354 }) => {
355 saving.set(false);
356 error.set(Some(message));
357 }
358 Err(e) => {
359 saving.set(false);
360 error.set(Some(format!("更新失败: {}", e)));
361 }
362 }
363 });
364 } else {
365 spawn(async move {
367 match create_post(
368 title().trim().to_string(),
369 slug_opt,
370 summary_opt,
371 md,
372 status(),
373 tags_list,
374 cover_image_opt,
375 )
376 .await
377 {
378 Ok(CreatePostResponse { success: true, .. }) => {
379 saving.set(false);
380 let _ = dioxus::router::navigator().push(Route::Posts {});
381 }
382 Ok(CreatePostResponse {
383 success: false,
384 message,
385 ..
386 }) => {
387 saving.set(false);
388 error.set(Some(message));
389 }
390 Err(e) => {
391 saving.set(false);
392 error.set(Some(format!("保存失败: {}", e)));
393 }
394 }
395 });
396 }
397 }
398
399 #[cfg(not(target_arch = "wasm32"))]
401 if !title().trim().is_empty() {
402 error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
403 }
404 };
405
406 rsx! {
407 div { class: "relative flex flex-col w-full min-h-0 flex-1",
411 if loading() {
412 div { class: "absolute inset-0 z-10 bg-paper-theme flex flex-col", WriteSkeleton {} }
413 }
414
415 div { class: "flex-1 min-h-0 flex",
417 div { class: "flex-1 min-w-0 min-h-0 overflow-y-auto px-6 py-8 flex flex-col",
420 input {
422 class: "w-full text-4xl md:text-5xl font-extrabold bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none tracking-tight leading-tight",
423 placeholder: "输入文章标题...",
424 value: "{title}",
425 oninput: move |evt| title.set(evt.value()),
426 }
427
428 if let Some(err) = load_error() {
430 div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
431 "{err}"
432 }
433 }
434
435 if let Some(err) = error() {
436 div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
437 "{err}"
438 }
439 }
440
441 for err in upload_errors().clone() {
443 div {
444 key: "{err.id}",
445 class: "flex-shrink-0 flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
446 span { "图片上传失败: {err.file_name} — {err.message}" }
447 button {
448 class: "{BTN_CLOSE_ICON}",
449 aria_label: "关闭提示",
450 onclick: {
451 let id = err.id.clone();
453 let mut upload_errors = upload_errors;
454 move |_| {
455 #[cfg(target_arch = "wasm32")]
457 if let Some(handle) = &*editor.read() {
458 handle.instance().remove_upload_by_upload_id(&id);
459 }
460 upload_errors.write().retain(|e| e.id != id);
461 }
462 },
463 "×"
464 }
465 }
466 }
467
468 div { class: "flex-1 min-h-[400px] flex flex-col my-4",
472 div {
473 class: "relative group flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-2xl overflow-hidden bg-[var(--color-paper-entry)] shadow-sm",
474 id: "tiptap-editor",
475 img {
476 src: "/images/xiantiaoxiaogou_input_bg.webp",
477 alt: "",
478 class: "absolute bottom-2 right-2 w-24 opacity-10 pointer-events-none z-0",
479 }
480 }
481 }
482 } div { class: "w-80 flex-shrink-0 min-h-0 overflow-y-auto border-l border-[var(--color-paper-border)] flex flex-col",
487 div { class: "p-6 border-b border-[var(--color-paper-border)]",
489 label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
490 "链接"
491 }
492 FormInput {
493 r#type: "text",
494 placeholder: "自动生成",
495 value: slug(),
496 oninput: move |v: String| slug.set(v),
497 }
498 }
499 div { class: "p-6 border-b border-[var(--color-paper-border)]",
501 label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
502 "标签"
503 }
504 FormInput {
505 r#type: "text",
506 placeholder: "逗号分隔...",
507 value: tags(),
508 oninput: move |v: String| tags.set(v),
509 }
510 }
511 div { class: "p-6 border-b border-[var(--color-paper-border)]",
513 label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
514 "摘要"
515 }
516 textarea {
517 class: "w-full text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-3 py-2 transition-all resize-none leading-relaxed",
518 placeholder: "选填...",
519 rows: "3",
520 value: "{summary}",
521 oninput: move |evt| summary.set(evt.value()),
522 }
523 }
524 div { class: "p-6",
526 label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
527 "封面图"
528 }
529 CoverUploader { cover_image, cover_uploading }
530 }
531 } } div { class: "flex-shrink-0 px-6 py-4 flex items-center gap-4 border-t border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
538 button {
539 class: "px-6 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
540 onclick: move |_| {
541 let _ = dioxus::router::navigator().push(Route::Posts {});
542 },
543 "取消"
544 }
545 div { class: "w-px h-5 bg-[var(--color-paper-border)]" }
546 FormSelect {
548 trigger_class: Some(
549 "inline-flex w-auto cursor-pointer select-none text-left text-sm font-medium pl-4 pr-8 py-2 rounded-full border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200",
550 ),
551 value: status(),
552 options: vec![
553 ("draft".to_string(), "存为草稿"),
554 ("published".to_string(), "直接发布"),
555 ],
556 onchange: move |v| status.set(v),
557 }
558 div { class: "w-px h-5 bg-[var(--color-paper-border)]" }
559 LoadingButton {
560 label: if is_edit { "更新文章".to_string() } else { "发布文章".to_string() },
561 loading: saving(),
562 onclick: move |_| on_submit(()),
563 }
564 }
565 }
566 }
567}
568
569#[component]
580#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
581fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) -> Element {
582 let mut cover_error = use_signal(|| None::<String>);
584 let mut cover_url_mode = use_signal(|| false);
585 let mut cover_drag_active = use_signal(|| false);
586 let mut cover_url_input = use_signal(|| "".to_string());
588 let mut picker_visible = use_signal(|| false);
590
591 #[cfg(target_arch = "wasm32")]
596 let mut spawn_cover_upload = move |file: web_sys::File| {
597 cover_uploading.set(true);
598 cover_error.set(None);
599 spawn(async move {
600 match upload_image_file(file).await {
601 Ok(url) => {
602 cover_image.set(url);
603 }
604 Err(msg) => {
605 cover_error.set(Some(msg));
606 }
607 }
608 cover_uploading.set(false);
609 });
610 };
611
612 rsx! {
613 div {
617 class: "relative w-full border border-dashed rounded-2xl overflow-hidden transition-all duration-200 group/cover",
618 class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" },
620 class: if cover_drag_active() { "border-[var(--color-paper-primary)] bg-[var(--color-paper-entry)]" } else if cover_image().is_empty() { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-primary)]" } else { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]" },
621
622 ondragover: move |evt| {
624 evt.prevent_default();
625 if !cover_uploading() && cover_image().is_empty() {
626 cover_drag_active.set(true);
627 }
628 },
629 ondragenter: move |evt| {
630 evt.prevent_default();
631 },
632 ondragleave: move |_| {
633 cover_drag_active.set(false);
634 },
635 ondrop: move |evt| {
636 evt.prevent_default();
637 cover_drag_active.set(false);
638 if !cover_uploading() && cover_image().is_empty() {
639 #[cfg(target_arch = "wasm32")]
640 {
641 if let Some(file) = evt.files().into_iter().next() {
642 if let Some(web_file) = file.get_web_file() {
643 spawn_cover_upload(web_file);
644 }
645 }
646 }
647 }
648 },
649 onpaste: move |evt| {
650 evt.prevent_default();
651 if !cover_uploading() && cover_image().is_empty() {
652 #[cfg(target_arch = "wasm32")]
653 {
654 use wasm_bindgen::JsCast;
655 if let Some(raw) = evt.try_as_web_event() {
656 if let Some(ce) = raw.dyn_ref::<web_sys::ClipboardEvent>() {
657 if let Some(dt) = ce.clipboard_data() {
658 if let Some(file_list) = dt.files() {
659 if let Some(file) = file_list.item(0) {
660 spawn_cover_upload(file);
661 }
662 }
663 }
664 }
665 }
666 }
667 }
668 },
669
670 if cover_uploading() {
672 div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse",
673 span { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
674 "正在上传..."
675 }
676 }
677 }
678
679 if !cover_image().is_empty() && !cover_uploading() {
681 img {
684 class: "absolute inset-0 w-full h-full object-cover",
685 src: {
686 let cv = cover_image();
687 if cv.starts_with("/uploads/") {
688 let base = cv.split('?').next().unwrap_or(&cv);
689 if cv.contains('?') {
690 format!("{}&w=600", base)
691 } else {
692 format!("{}?w=600", base)
693 }
694 } else {
695 cv
696 }
697 },
698 alt: "封面预览",
699 onerror: move |_| {
701 cover_error.set(Some("封面图加载失败,请检查 URL".to_string()));
702 },
703 }
704 button {
706 class: "absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-black/50 text-white opacity-0 group-hover/cover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer",
707 aria_label: "移除封面",
708 onclick: move |_| {
709 cover_image.set(String::new());
710 cover_error.set(None);
711 cover_url_mode.set(false);
712 cover_url_input.set(String::new());
713 },
714 svg {
716 class: "w-4 h-4",
717 xmlns: "http://www.w3.org/2000/svg",
718 view_box: "0 0 24 24",
719 fill: "none",
720 stroke: "currentColor",
721 stroke_width: "2",
722 stroke_linecap: "round",
723 stroke_linejoin: "round",
724 path { d: "M6 6l12 12M6 18L18 6" }
725 }
726 }
727 div { class: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-end justify-center pb-2 pointer-events-none",
729 span { class: "text-sm font-medium text-white/90 drop-shadow-md",
730 "点击更换封面"
731 }
732 }
733 }
734
735 if cover_image().is_empty() && !cover_uploading() {
737 label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left",
739 svg {
741 class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]",
742 xmlns: "http://www.w3.org/2000/svg",
743 view_box: "0 0 24 24",
744 fill: "none",
745 stroke: "currentColor",
746 stroke_width: "1.8",
747 stroke_linecap: "round",
748 stroke_linejoin: "round",
749 path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
750 polyline { points: "17 8 12 3 7 8" }
751 line {
752 x1: "12",
753 y1: "3",
754 x2: "12",
755 y2: "15",
756 }
757 }
758 span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
759 "拖拽 / 粘贴 / 点击上传"
760 }
761 span {
763 class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
764 onclick: move |evt| {
765 evt.prevent_default();
766 evt.stop_propagation();
767 picker_visible.set(true);
768 },
769 "素材库"
770 }
771 span {
773 class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
774 onclick: move |evt| {
775 evt.prevent_default();
776 evt.stop_propagation();
777 cover_url_mode.set(true);
778 cover_url_input.set(cover_image());
779 },
780 "输入链接"
781 }
782 input {
784 r#type: "file",
785 accept: "image/jpeg,image/png,image/gif,image/webp",
786 class: "hidden",
787 onchange: move |evt| {
788 #[cfg(target_arch = "wasm32")]
789 {
790 if let Some(file) = evt.files().into_iter().next() {
791 if let Some(web_file) = file.get_web_file() {
792 spawn_cover_upload(web_file);
793 }
794 }
795 }
796 },
799 }
800 }
801 }
802 }
803
804 if cover_url_mode() && cover_image().is_empty() {
806 div { class: "flex items-center gap-2 mt-2",
807 FormInput {
808 r#type: "url",
809 placeholder: "粘贴图片链接...",
810 value: cover_url_input(),
811 class: INPUT_INLINE_CLASS,
812 oninput: move |v: String| cover_url_input.set(v),
813 onkeydown: move |evt: KeyboardEvent| {
814 if evt.key() == Key::Enter {
815 let v = cover_url_input().trim().to_string();
816 if !v.is_empty() {
817 cover_image.set(v);
818 cover_error.set(None);
819 cover_url_mode.set(false);
820 }
821 }
822 },
823 }
824 button {
825 class: "shrink-0 {BTN_PRIMARY_SM}",
826 onclick: move |_| {
827 let v = cover_url_input().trim().to_string();
828 if !v.is_empty() {
829 cover_image.set(v);
830 cover_error.set(None);
831 cover_url_mode.set(false);
832 }
833 },
834 "确认"
835 }
836 button {
837 class: "shrink-0 px-4 py-1.5 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
838 onclick: move |_| {
839 cover_url_mode.set(false);
840 cover_url_input.set(String::new());
841 },
842 "取消"
843 }
844 }
845 }
846
847 if let Some(err) = cover_error() {
849 div { class: "flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mt-2",
850 span { "封面图: {err}" }
851 button {
852 class: "{BTN_CLOSE_ICON}",
853 aria_label: "关闭提示",
854 onclick: move |_| cover_error.set(None),
855 "×"
856 }
857 }
858 }
859
860 AssetPickerModal {
862 visible: picker_visible,
863 cover_uploading,
864 on_select: move |url: String| {
865 cover_image.set(url);
866 cover_error.set(None);
867 },
868 }
869 }
870}