feat : comment feature
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState, type ClipboardEvent, type FormEvent } from "react";
|
||||
import { ImagePlusIcon, 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 type { CommentImage, CommentItem } from "@/lib/comments/types";
|
||||
import { CommentImageView } from "./comment-image";
|
||||
import { COMMENT_ERRORS } from "./client";
|
||||
|
||||
function FilePreview({ file }: { file: File }) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
if (imageRef.current) imageRef.current.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
// Local blob previews and S3 CDN URLs must not use the image proxy.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img ref={imageRef} alt={file.name} className="size-24 rounded-lg object-cover" />;
|
||||
}
|
||||
|
||||
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<CommentImage[]>(editing?.images ?? []);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [expanded, setExpanded] = useState(Boolean(editing || replyTo));
|
||||
const [progress, setProgress] = useState(0);
|
||||
const uploadRef = useRef<XMLHttpRequest | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => () => uploadRef.current?.abort(), []);
|
||||
|
||||
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<HTMLTextAreaElement>) {
|
||||
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<void>((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 ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว");
|
||||
window.dispatchEvent(new Event("comment:posted"));
|
||||
onSaved();
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); }
|
||||
finally { setPending(false); uploadRef.current = null; }
|
||||
}
|
||||
|
||||
return <form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(error)}>
|
||||
<FieldLabel htmlFor={`${id}-text`} className="sr-only">{editing ? "แก้ไขความคิดเห็น" : replyTo ? `ตอบกลับ ${replyTo.authorName}` : "เพิ่มความคิดเห็น"}</FieldLabel>
|
||||
<Textarea id={`${id}-text`} value={text} onChange={(event) => setText(event.target.value)} maxLength={4000} rows={expanded ? 2 : 1} onFocus={() => setExpanded(true)} onPaste={pasteImages}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) {
|
||||
event.preventDefault();
|
||||
if (!pending && !event.repeat && (text.trim() || files.length || kept.length)) event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
className="min-h-10 resize-none rounded-none border-x-0 border-t-0 px-0 focus-visible:ring-0"
|
||||
disabled={pending} aria-invalid={Boolean(error)} placeholder="เพิ่มความคิดเห็น…" />
|
||||
{expanded && <FieldDescription>{text.length.toLocaleString("th-TH")} / 4,000</FieldDescription>}
|
||||
</Field>
|
||||
<Field className="hidden">
|
||||
<FieldLabel htmlFor={`${id}-images`} className="sr-only">แนบรูปภาพ</FieldLabel>
|
||||
<Input ref={fileRef} id={`${id}-images`} type="file" tabIndex={-1} multiple accept="image/jpeg,image/png,image/webp" className="hidden" disabled={pending}
|
||||
onChange={(event) => { addImages(Array.from(event.target.files ?? [])); event.target.value = ""; }} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
{(files.length > 0 || kept.length > 0) && <div className="flex flex-wrap gap-3">
|
||||
{kept.map((image) => <div key={image.id} className="flex flex-col gap-1">
|
||||
<CommentImageView image={image} alt="รูปภาพที่แนบ" className="size-24 rounded-lg object-cover" />
|
||||
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setKept((current) => current.filter((entry) => entry.id !== image.id))}><XIcon data-icon="inline-start" />นำออก</Button>
|
||||
</div>)}
|
||||
{files.map((file, index) => <div key={`${file.name}-${index}`} className="flex flex-col gap-1"><FilePreview file={file} />
|
||||
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setFiles((current) => current.filter((_, position) => position !== index))}><XIcon data-icon="inline-start" />นำออก</Button>
|
||||
</div>)}
|
||||
</div>}
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
{pending && <Progress value={progress}><ProgressLabel>{progress === 100 ? "กำลังบันทึกความคิดเห็น…" : "กำลังอัปโหลด…"}</ProgressLabel><ProgressValue /></Progress>}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" className="mr-auto rounded-full" aria-label="แนบรูปภาพ" disabled={pending || files.length + kept.length >= 5} onClick={() => fileRef.current?.click()}><ImagePlusIcon data-icon="inline-start" /><span className="hidden sm:inline">รูปภาพ</span></Button>
|
||||
{expanded && <Button type="button" variant="ghost" disabled={pending} onClick={cancel}>ยกเลิก</Button>}
|
||||
<Button type="submit" disabled={pending || (!text.trim() && !files.length && !kept.length)}>{pending ? "กำลังบันทึก…" : editing ? "บันทึกการแก้ไข" : replyTo ? "ตอบกลับ" : "แสดงความคิดเห็น"}</Button>
|
||||
</div>
|
||||
</form>;
|
||||
}
|
||||
Reference in New Issue
Block a user