"use client"; import { useEffect, useId, useRef, useState, type ClipboardEvent, type ComponentProps, type FormEvent } from "react"; import dynamic from "next/dynamic"; import Image from "@/components/ui/resilient-image"; import { ImagePlusIcon, SmilePlusIcon, XIcon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; import type { CommentImage, CommentItem } from "@/lib/comments/types"; import { CommentImageView } from "./comment-image"; import { COMMENT_ERRORS } from "./client"; const EmojiPicker = dynamic(async () => { const { default: Picker, EmojiStyle } = await import("emoji-picker-react"); return function NativeEmojiPicker(props: ComponentProps) { return ; }; }, { ssr: false }); function FilePreview({ file }: { file: File }) { const imageRef = useRef(null); useEffect(() => { const url = URL.createObjectURL(file); if (imageRef.current) imageRef.current.src = url; return () => URL.revokeObjectURL(url); }, [file]); return {file.name}; } export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }: { target: string; replyTo?: CommentItem; editing?: CommentItem; onSaved: () => void; onCancel?: () => void; }) { const id = useId(); const [text, setText] = useState(editing?.text ?? ""); const [kept, setKept] = useState(editing?.images ?? []); const [files, setFiles] = useState([]); const [error, setError] = useState(""); const [pending, setPending] = useState(false); const [expanded, setExpanded] = useState(Boolean(editing || replyTo)); const [progress, setProgress] = useState(0); const [emojiOpen, setEmojiOpen] = useState(false); const uploadRef = useRef(null); const fileRef = useRef(null); const textRef = useRef(null); const isEditing = Boolean(editing); useEffect(() => () => uploadRef.current?.abort(), []); useEffect(() => { if (isEditing) textRef.current?.focus(); }, [isEditing]); function insertEmoji(emoji: string) { if (pending) return; const start = textRef.current?.selectionStart ?? text.length; const end = textRef.current?.selectionEnd ?? start; const next = text.slice(0, start) + emoji + text.slice(end); if (next.length > 4000) return setError("ความคิดเห็นต้องไม่เกิน 4,000 ตัวอักษร"); setText(next); setExpanded(true); setEmojiOpen(false); requestAnimationFrame(() => { textRef.current?.focus(); textRef.current?.setSelectionRange(start + emoji.length, start + emoji.length); }); } function addImages(incoming: File[]) { if (incoming.length + files.length + kept.length > 5) return setError("แนบรูปภาพได้สูงสุด 5 รูปต่อความคิดเห็น"); if (incoming.some((file) => !["image/jpeg", "image/png", "image/webp"].includes(file.type))) return setError(COMMENT_ERRORS["unsupported-image-type"]); if (incoming.some((file) => !file.size || file.size > 10 * 1024 * 1024)) return setError(COMMENT_ERRORS["image-too-large"]); setFiles((current) => [...current, ...incoming]); setError(""); } function pasteImages(event: ClipboardEvent) { if (pending) return; const images = Array.from(event.clipboardData.items) .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()).filter((file): file is File => file !== null); if (!images.length) return; event.preventDefault(); setExpanded(true); addImages(images); } function cancel() { setText(""); setFiles([]); setKept([]); setError(""); setExpanded(false); onCancel?.(); } async function submit(event: FormEvent) { event.preventDefault(); if (pending) return; if (!text.trim() && !files.length && !kept.length) return setError(COMMENT_ERRORS["empty-comment"]); setPending(true); setError(""); setProgress(0); const form = new FormData(); form.set("text", text); if (replyTo) form.set("replyToId", replyTo.id); if (editing) form.set("version", String(editing.version)); for (const image of kept) form.append("keepImageId", image.id); for (const file of files) form.append("image", file); try { await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); uploadRef.current = xhr; xhr.open(editing ? "PATCH" : "POST", editing ? `/api/comments/${editing.id}` : `/api/comments?target=${encodeURIComponent(target)}`); xhr.upload.onprogress = (event) => { if (event.lengthComputable) setProgress(Math.round(event.loaded / event.total * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) return resolve(); let message = "ไม่สามารถบันทึกความคิดเห็นได้ กรุณาลองใหม่"; try { message = COMMENT_ERRORS[JSON.parse(xhr.responseText).error] ?? message; } catch { /* Keep the draft if the server returned a non-JSON error. */ } reject(new Error(message)); }; xhr.onerror = () => reject(new Error("การเชื่อมต่อขัดข้อง ข้อความร่างของคุณยังอยู่")); xhr.onabort = () => reject(new Error("ยกเลิกการอัปโหลดแล้ว")); xhr.send(form); }); setText(""); setFiles([]); setKept([]); setExpanded(false); toast.success(editing ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว"); onSaved(); } catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); } finally { setPending(false); uploadRef.current = null; } } return
{editing ? "แก้ไขความคิดเห็น" : replyTo ? `ตอบกลับ ${replyTo.authorName}` : "เพิ่มความคิดเห็น"}