perf : cache
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s

This commit is contained in:
2026-10-08 23:21:34 +07:00 Unverified
parent 36063fc214
commit 9580407a90
58 changed files with 5797 additions and 5336 deletions
+31 -28
View File
@@ -1,7 +1,10 @@
"use client";
import { useCommentLocale } from "./comment-locale";
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";
@@ -13,7 +16,7 @@ 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";
import { getCommentErrors } from "./client";
const EmojiPicker = dynamic(async () => {
const { default: Picker, EmojiStyle } = await import("emoji-picker-react");
@@ -29,14 +32,14 @@ function FilePreview({ file }: { file: 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" />;
return <Image ref={imageRef} src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" alt={file.name} width={96} height={96} unoptimized 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 { t, locale } = useCommentLocale();
const commentErrors = getCommentErrors(locale);
const id = useId();
const [text, setText] = useState(editing?.text ?? "");
const [kept, setKept] = useState<CommentImage[]>(editing?.images ?? []);
@@ -58,7 +61,7 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
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 ตัวอักษร");
if (next.length > 4000) return setError(t("ความคิดเห็นต้องไม่เกิน 4,000 ตัวอักษร", "Comments must not exceed 4,000 characters"));
setText(next);
setExpanded(true);
setEmojiOpen(false);
@@ -69,10 +72,10 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
}
function addImages(incoming: File[]) {
if (incoming.length + files.length + kept.length > 5) return setError("แนบรูปภาพได้สูงสุด 5 รูปต่อความคิดเห็น");
if (incoming.length + files.length + kept.length > 5) return setError(t("แนบรูปภาพได้สูงสุด 5 รูปต่อความคิดเห็น", "Attach up to 5 images per comment"));
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"]);
return setError(commentErrors["unsupported-image-type"]);
if (incoming.some((file) => !file.size || file.size > 10 * 1024 * 1024)) return setError(commentErrors["image-too-large"]);
setFiles((current) => [...current, ...incoming]);
setError("");
}
@@ -96,7 +99,7 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
async function submit(event: FormEvent) {
event.preventDefault();
if (pending) return;
if (!text.trim() && !files.length && !kept.length) return setError(COMMENT_ERRORS["empty-comment"]);
if (!text.trim() && !files.length && !kept.length) return setError(commentErrors["empty-comment"]);
setPending(true);
setError("");
setProgress(0);
@@ -114,25 +117,25 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
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. */ }
let message = t("ไม่สามารถบันทึกความคิดเห็นได้ กรุณาลองใหม่", "Could not save your comment. Please try again.");
try { message = commentErrors[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.onerror = () => reject(new Error(t("การเชื่อมต่อขัดข้อง ข้อความร่างของคุณยังอยู่", "Connection failed. Your draft has been kept.")));
xhr.onabort = () => reject(new Error(t("ยกเลิกการอัปโหลดแล้ว", "Upload cancelled")));
xhr.send(form);
});
setText(""); setFiles([]); setKept([]); setExpanded(false);
toast.success(editing ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว");
toast.success(editing ? t("แก้ไขความคิดเห็นแล้ว", "Comment updated") : t("ส่งความคิดเห็นแล้ว", "Comment posted"));
onSaved();
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); }
} catch (cause) { setError(cause instanceof Error ? cause.message : t("ไม่สามารถบันทึกความคิดเห็นได้", "Could not save your comment")); }
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>
<FieldLabel htmlFor={`${id}-text`} className="sr-only">{editing ? t("แก้ไขความคิดเห็น", "Edit comment") : replyTo ? t(`ตอบกลับ ${replyTo.authorName}`, `Reply to ${replyTo.authorName}`) : t("เพิ่มความคิดเห็น", "Add a comment")}</FieldLabel>
<Textarea ref={textRef} 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) {
@@ -141,39 +144,39 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
}
}}
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>}
disabled={pending} aria-invalid={Boolean(error)} placeholder={t("เพิ่มความคิดเห็น…", "Add a comment…")} />
{expanded && <FieldDescription>{text.length.toLocaleString(locale === "en" ? "en-US" : "th-TH")} / 4,000</FieldDescription>}
</Field>
<Field className="hidden">
<FieldLabel htmlFor={`${id}-images`} className="sr-only">แนบรูปภาพ</FieldLabel>
<FieldLabel htmlFor={`${id}-images`} className="sr-only">{t("แนบรูปภาพ", "Attach images")}</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>
<CommentImageView image={image} alt={t("รูปภาพที่แนบ", "Attached image")} 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" />{t("นำออก", "Remove")}</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>
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setFiles((current) => current.filter((_, position) => position !== index))}><XIcon data-icon="inline-start" />{t("นำออก", "Remove")}</Button>
</div>)}
</div>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{pending && <Progress value={progress}><ProgressLabel>{progress === 100 ? "กำลังบันทึกความคิดเห็น…" : "กำลังอัปโหลด…"}</ProgressLabel><ProgressValue /></Progress>}
{pending && <Progress value={progress}><ProgressLabel>{progress === 100 ? t("กำลังบันทึกความคิดเห็น…", "Saving comment…") : t("กำลังอัปโหลด…", "Uploading…")}</ProgressLabel><ProgressValue /></Progress>}
<div className="flex flex-wrap items-center justify-end gap-2">
<div className="mr-auto flex items-center gap-1">
<Button type="button" variant="ghost" className="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>
<Button type="button" variant="ghost" className="rounded-full" aria-label={t("แนบรูปภาพ", "Attach images")} disabled={pending || files.length + kept.length >= 5} onClick={() => fileRef.current?.click()}><ImagePlusIcon data-icon="inline-start" /><span className="hidden sm:inline">{t("รูปภาพ", "Images")}</span></Button>
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label="เพิ่มอีโมจิ" disabled={pending} />}><SmilePlusIcon /></PopoverTrigger>
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label={t("เพิ่มอีโมจิ", "Add emoji")} disabled={pending} />}><SmilePlusIcon /></PopoverTrigger>
<PopoverContent align="start" className="w-80 max-w-[calc(100vw-2rem)] p-0" finalFocus={textRef}>
<PopoverTitle className="sr-only">เลือกอีโมจิ</PopoverTitle>
<PopoverTitle className="sr-only">{t("เลือกอีโมจิ", "Choose an emoji")}</PopoverTitle>
{emojiOpen && <EmojiPicker width="100%" height={350} lazyLoadEmojis onEmojiClick={(value) => insertEmoji(value.emoji)} />}
</PopoverContent>
</Popover>
</div>
{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>
{expanded && <Button type="button" variant="ghost" disabled={pending} onClick={cancel}>{t("ยกเลิก", "Cancel")}</Button>}
<Button type="submit" disabled={pending || (!text.trim() && !files.length && !kept.length)}>{pending ? t("กำลังบันทึก…", "Saving…") : editing ? t("บันทึกการแก้ไข", "Save changes") : replyTo ? t("ตอบกลับ", "Reply") : t("แสดงความคิดเห็น", "Post comment")}</Button>
</div>
</form>;
}