yggdrasil/components/comments/
form.rs1use dioxus::prelude::*;
10
11use crate::api::comments::create_comment;
12use crate::bridges::library::{library_ready, use_browser_library, LibraryLoadError};
13use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
14use crate::components::comments::section::CommentContext;
15use crate::components::forms::{AlertBox, FormInput};
16use crate::components::ui::{UserAvatar, BTN_PRIMARY_SM, SPINNER_SVG};
17use crate::utils::comment_storage::{self, AuthorInfo, PendingComment};
18#[cfg(target_arch = "wasm32")]
19use wasm_bindgen::closure::Closure;
20
21#[component]
33#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
36pub fn CommentForm(post_id: i32, parent_id: Option<i64>, parent_indent: Option<i32>) -> Element {
37 let ctx: CommentContext = use_context();
38 let mut active_reply = ctx.active_reply;
39 let mut refresh_trigger = ctx.refresh_trigger;
40 let mut pending_comments = ctx.pending_comments;
41 let viewer = ctx.current_user;
42
43 let mut author_name = use_signal(String::new);
44 let mut author_email = use_signal(String::new);
45 let mut author_url = use_signal(String::new);
46 let mut content_md = use_signal(String::new);
47 let mut honeypot = use_signal(String::new);
48 let mut submitting = use_signal(|| false);
49 let mut message = use_signal(|| Option::<(String, &'static str)>::None);
50 let mut loaded = use_signal(|| false);
51 let editor_library = use_browser_library("tiptap", move || {
52 parent_id.is_none_or(|pid| active_reply() == Some(pid))
53 });
54 let uploads_in_flight = use_signal(UploadsInFlight::default);
57 let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
58
59 use_effect(move || {
61 if loaded() {
62 return;
63 }
64 loaded.set(true);
65 if let Some(info) = comment_storage::load_author() {
66 author_name.set(info.name);
67 author_email.set(info.email);
68 author_url.set(info.url);
69 }
70 });
71
72 if let Some(pid) = parent_id {
74 if active_reply() != Some(pid) {
75 return rsx! {};
76 }
77 }
78
79 let is_reply = parent_id.is_some();
80
81 let id_suffix = match parent_id {
83 Some(pid) => pid.to_string(),
84 None => "root".to_string(),
85 };
86 let image_input_dom_id = format!("comment-image-{id_suffix}");
88
89 let negative_margin = match (is_reply, parent_indent) {
91 (true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"),
92 _ => String::new(),
93 };
94
95 let editor_dom_id = format!("comment-editor-{id_suffix}");
97
98 #[cfg(target_arch = "wasm32")]
100 let mut editor_handle: Signal<Option<crate::bridges::tiptap::EditorHandle>> =
101 use_signal(|| None);
102
103 #[cfg(target_arch = "wasm32")]
107 let editor_dom_id_for_mount = editor_dom_id.clone();
108 #[cfg(target_arch = "wasm32")]
109 use_effect(move || {
110 let editor_dom_id = editor_dom_id_for_mount.clone();
111 let active = match parent_id {
112 Some(pid) => active_reply() == Some(pid),
113 None => true,
114 };
115 if !active {
116 if editor_handle.peek().is_some() {
118 editor_handle.set(None);
119 }
120 return;
121 }
122 if editor_handle.peek().is_some() {
124 return;
125 }
126 if !library_ready(editor_library) {
127 return;
128 }
129
130 let on_update = Closure::new({
132 let mut content_md = content_md;
133 move |md: String| content_md.set(md)
134 });
135 let on_ready = Closure::new(|| {});
136 let on_image_upload = crate::bridges::tiptap::make_comment_upload_closure();
137 let on_upload_event = Closure::new({
138 let mut message = message;
139 move |ev: crate::bridges::tiptap::UploadEventJs| {
140 match ev.kind().as_str() {
143 "error" => {
144 let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
145 message.set(Some((format!("图片上传失败:{msg}"), "error")));
146 }
147 "success" | "removed"
148 if message
149 .peek()
150 .as_ref()
151 .is_some_and(|(m, _)| m.starts_with("图片上传失败")) =>
152 {
153 message.set(None);
154 }
155 "success" | "removed" => {}
156 _ => {}
157 }
158 crate::bridges::tiptap::consume_upload_event(&ev, uploads_in_flight, upload_errors);
159 }
160 });
161
162 let opts = crate::bridges::tiptap::EditorOptions::new();
163 opts.set_variant("comment");
164 opts.set_placeholder(if is_reply {
165 "写下你的回复..."
166 } else {
167 "写下你的想法..."
168 });
169 opts.set_on_update(&on_update);
170 opts.set_on_ready(&on_ready);
171 opts.set_on_image_upload(&on_image_upload);
172 opts.set_on_upload_event(&on_upload_event);
173
174 match crate::bridges::tiptap::get_module().create(&editor_dom_id, &opts) {
176 Ok(Some(inst)) => {
177 let draft = content_md.peek().clone();
180 if !draft.is_empty() {
181 inst.set_markdown(&draft);
182 }
183 let handle = crate::bridges::tiptap::EditorHandle::new_comment(
184 inst,
185 on_update,
186 on_image_upload,
187 on_ready,
188 on_upload_event,
189 );
190 editor_handle.set(Some(handle));
191 }
192 Ok(None) => {
193 web_sys::console::warn_1(&format!("评论编辑器容器未找到: #{editor_dom_id}").into());
194 }
195 Err(e) => {
196 message.set(Some((format!("编辑器初始化失败: {e:?}"), "error")));
197 }
198 }
199 });
200
201 let mut do_submit = move || {
202 if submitting() || !library_ready(editor_library) {
203 return;
204 }
205
206 let post_id = post_id;
207 let parent_id = parent_id;
208 let is_anon = viewer().is_none();
209 let (name, email, url_val) = if is_anon {
211 (author_name(), author_email(), author_url())
212 } else {
213 (String::new(), String::new(), String::new())
214 };
215 let content = content_md();
216 let hp = honeypot();
217
218 let in_flight = uploads_in_flight();
221 if in_flight.uploading > 0 || in_flight.error > 0 {
222 let msg = if in_flight.uploading > 0 {
223 format!(
224 "有 {} 张图片正在上传,请等待完成后再发表",
225 in_flight.uploading
226 )
227 } else {
228 format!(
229 "有 {} 张图片上传失败,请重试或移除后再发表",
230 in_flight.error
231 )
232 };
233 message.set(Some((msg, "error")));
234 return;
235 }
236 if content.contains("](blob:") {
239 message.set(Some((
240 "检测到未完成上传的图片,请处理后再发表".to_string(),
241 "error",
242 )));
243 return;
244 }
245
246 if !hp.is_empty() {
248 return;
249 }
250
251 if content.trim().is_empty() {
252 message.set(Some(("请填写评论内容".to_string(), "error")));
253 return;
254 }
255 if is_anon && (name.trim().is_empty() || email.trim().is_empty()) {
256 message.set(Some(("请填写昵称和邮箱".to_string(), "error")));
257 return;
258 }
259 submitting.set(true);
260 message.set(None);
261 spawn(async move {
262 let result = create_comment(
263 post_id,
264 parent_id,
265 name.clone(),
266 email.clone(),
267 if url_val.trim().is_empty() {
268 None
269 } else {
270 Some(url_val.clone())
271 },
272 content.clone(),
273 hp.clone(),
274 )
275 .await;
276 submitting.set(false);
277 match result {
278 Ok(resp) => {
279 if resp.success {
280 if is_anon {
283 comment_storage::save_author(&AuthorInfo {
284 name: name.clone(),
285 email: email.clone(),
286 url: url_val.clone(),
287 });
288 if let Some(comment_id) = resp.comment_id {
289 let avatar_url = resp.avatar_url.unwrap_or_default();
290 let depth = resp.depth.unwrap_or(0);
291 let now = chrono::Utc::now().to_rfc3339();
292 let pending = PendingComment {
293 id: comment_id,
294 parent_id,
295 depth,
296 author_name: name.clone(),
297 author_url: if url_val.trim().is_empty() {
298 None
299 } else {
300 Some(url_val)
301 },
302 avatar_url,
303 content_md: content,
304 created_at: now.clone(),
305 stored_at: now,
306 };
307 comment_storage::save_pending_comment(post_id, pending.clone());
308 pending_comments.write().push(pending);
309 }
310 }
311 content_md.set(String::new());
312 #[cfg(target_arch = "wasm32")]
314 if let Some(handle) = &*editor_handle.peek() {
315 handle.instance().set_markdown("");
316 }
317 message.set(Some((resp.message, "success")));
318 if parent_id.is_some() {
319 active_reply.set(None);
320 }
321 refresh_trigger.set(!refresh_trigger());
322 } else {
323 message.set(Some((resp.message, "error")));
324 }
325 }
326 Err(_) => {
327 message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
328 }
329 }
330 });
331 };
332
333 rsx! {
334 div {
335 class: if is_reply { "mt-3" } else { "" },
336 style: "{negative_margin}",
337 role: "form",
338 aria_label: if is_reply { "回复评论" } else { "发表评论" },
339 onkeydown: move |e: KeyboardEvent| {
342 if (e.modifiers().ctrl() || e.modifiers().meta()) && e.key() == Key::Enter {
343 do_submit();
344 }
345 },
346
347 if let Some((msg, variant)) = message() {
348 div { class: "mb-3", aria_live: "polite",
349 AlertBox { message: msg, variant }
350 }
351 }
352 LibraryLoadError { library: editor_library }
353
354 div { class: "rounded-2xl bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)]/60 shadow-xs focus-within:border-[var(--color-paper-accent)]/60 focus-within:ring-2 focus-within:ring-[var(--color-paper-accent)]/20 transition-all duration-200 overflow-hidden",
356 if let Some(user) = viewer() {
358 {
359 let label = user.display_label().to_string();
360 let action = if is_reply { "回复" } else { "发表评论" };
361 rsx! {
362 div { class: "flex items-center justify-between px-4 py-2.5 bg-[var(--color-paper-theme)]/40 border-b border-[var(--color-paper-border)]/40 text-xs text-paper-secondary",
363 div { class: "flex items-center gap-2.5",
364 UserAvatar {
365 name: label.clone(),
366 avatar_url: user.avatar_url.clone(),
367 class: "w-6 h-6 rounded-full text-xs ring-1 ring-[var(--color-paper-border)]/60 shrink-0",
368 }
369 span { class: "text-paper-secondary",
370 "以 "
371 span { class: "font-semibold text-paper-primary", "{label}" }
372 " 的身份{action}"
373 }
374 span { class: "hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)]",
375 "已登录"
376 }
377 }
378 div { class: "flex items-center gap-1 text-paper-tertiary",
379 span { class: "hidden sm:inline text-[11px]", "Markdown 语法就绪" }
380 }
381 }
382 }
383 }
384 } else {
385 div { class: "grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-[var(--color-paper-border)]/40 bg-[var(--color-paper-theme)]/30 border-b border-[var(--color-paper-border)]/40",
386 div { class: "relative",
387 FormInput {
388 id: Some(format!("comment-name-{id_suffix}")),
389 r#type: "text",
390 placeholder: "昵称 *",
391 title: Some("昵称"),
394 value: author_name(),
395 disabled: submitting(),
396 class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
397 oninput: move |v| author_name.set(v),
398 }
399 }
400 div { class: "relative",
401 FormInput {
402 id: Some(format!("comment-email-{id_suffix}")),
403 r#type: "email",
404 placeholder: "邮箱 * (保密)",
405 title: Some("邮箱"),
406 value: author_email(),
407 disabled: submitting(),
408 class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
409 oninput: move |v| author_email.set(v),
410 }
411 }
412 div { class: "relative",
413 FormInput {
414 id: Some(format!("comment-url-{id_suffix}")),
415 r#type: "url",
416 placeholder: "网站 (https://,可选)",
417 title: Some("网站"),
418 value: author_url(),
419 disabled: submitting(),
420 class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
421 oninput: move |v| author_url.set(v),
422 }
423 }
424 }
425 }
426
427 div { class: "relative bg-transparent",
431 div {
432 id: "{editor_dom_id}",
433 class: "comment-editor-mount min-h-[96px]",
434 }
435 if !library_ready(editor_library) {
436 span { class: "absolute top-4 left-4 text-sm text-[var(--color-paper-tertiary)]", "正在加载编辑器…" }
437 }
438 img {
439 src: "/images/xiantiaoxiaogou_input_bg.webp",
440 alt: "",
441 class: "absolute bottom-2 right-2 w-20 sm:w-24 opacity-15 dark:opacity-20 pointer-events-none select-none z-0",
442 }
443 }
444
445 if viewer().is_none() {
448 textarea {
449 class: "hidden",
450 aria_hidden: "true",
451 tabindex: "-1",
452 value: "{honeypot}",
453 oninput: move |e| honeypot.set(e.value()),
454 }
455 }
456
457 div { class: "flex items-center justify-between px-3.5 py-2.5 bg-[var(--color-paper-theme)]/40 border-t border-[var(--color-paper-border)]/40 text-xs",
459 div { class: "flex items-center gap-1.5 text-paper-tertiary",
461 label {
463 r#for: "{image_input_dom_id}",
464 class: "p-1.5 rounded-md hover:text-paper-primary hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
465 title: "上传图片(也可直接粘贴到输入框)",
466 aria_label: "上传图片",
467 svg {
468 class: "w-3.5 h-3.5",
469 fill: "currentColor",
470 view_box: "0 -960 960 960",
471 path { d: "M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm56-97h489L578-473 446-302l-93-127-117 152Zm-56 97v-600 600Z" }
472 }
473 }
474 input {
475 id: "{image_input_dom_id}",
476 class: "hidden",
477 r#type: "file",
478 accept: "image/jpeg,image/png,image/gif,image/webp",
479 multiple: true,
480 onchange: {
481 #[cfg(target_arch = "wasm32")]
483 let input_dom_id = image_input_dom_id.clone();
484 move |e| {
485 #[cfg(target_arch = "wasm32")]
486 {
487 use dioxus::web::WebFileExt;
488 for f in e.files() {
490 if let Some(web_file) = f.get_web_file() {
491 if let Some(handle) = &*editor_handle.peek() {
492 handle.instance().insert_uploading(web_file);
493 }
494 }
495 }
496 if let Some(el) = web_sys::window()
498 .and_then(|w| w.document())
499 .and_then(|d| d.get_element_by_id(&input_dom_id))
500 {
501 use wasm_bindgen::JsCast;
502 if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
503 input.set_value("");
504 }
505 }
506 }
507 }
508 },
509 }
510 if uploads_in_flight().uploading > 0 {
512 span { class: "inline-flex items-center gap-1.5 text-[11px] text-paper-secondary ml-1",
513 span { class: "inline-block", dangerous_inner_html: "{SPINNER_SVG}" }
514 "图片上传中…"
515 }
516 }
517 }
518
519 div { class: "flex items-center gap-2",
521 span { class: "hidden sm:inline-block text-[11px] text-paper-tertiary font-mono mr-1", "Ctrl + ↵" }
522 if is_reply {
523 button {
524 r#type: "button",
525 class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary hover:bg-[var(--color-paper-entry)] rounded-full transition-colors cursor-pointer",
526 onclick: move |_| active_reply.set(None),
527 "取消"
528 }
529 }
530 button {
531 r#type: "button",
532 class: "{BTN_PRIMARY_SM}",
533 class: if submitting() || uploads_in_flight().uploading > 0 || uploads_in_flight().error > 0 { "opacity-60 cursor-not-allowed pointer-events-none" } else { "" },
536 disabled: !library_ready(editor_library) || submitting() || uploads_in_flight().uploading > 0 || uploads_in_flight().error > 0,
537 onclick: move |_| {
538 do_submit();
539 },
540 if submitting() {
541 span { class: "inline-flex items-center gap-1.5",
542 span { class: "inline-block", dangerous_inner_html: "{SPINNER_SVG}" }
543 "提交中…"
544 }
545 } else if is_reply {
546 "回复"
547 } else {
548 "发表评论"
549 }
550 }
551 }
552 }
553 }
554 }
555 }
556}