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