feat : update i guess
This commit is contained in:
@@ -81,6 +81,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
|
||||
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
|
||||
import { keepCommissionTitle } from "./keep-title";
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
@@ -89,7 +90,7 @@ type ChatMessage = {
|
||||
text: string | null;
|
||||
authorImage: string | null;
|
||||
replyToId: string | null;
|
||||
imageUrl: string | null;
|
||||
imageUrls: string[];
|
||||
createdAt: Date;
|
||||
reactions: { userId: string; emoji: string }[];
|
||||
};
|
||||
@@ -97,7 +98,7 @@ type OptimisticMessage = {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
text: string;
|
||||
imageUrl: string | null;
|
||||
imageUrls: string[];
|
||||
replyToId: string | null;
|
||||
status: "sending" | "sent";
|
||||
};
|
||||
@@ -105,6 +106,9 @@ type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
|
||||
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
|
||||
const reactionKey = (messageId: string, emoji: string) =>
|
||||
`${messageId}:${emoji}`;
|
||||
const padTime = (value: number) => String(value).padStart(2, "0");
|
||||
const imageUrlsOf = (message: { imageUrls?: string[]; imageUrl?: string | null }) =>
|
||||
Array.isArray(message.imageUrls) ? message.imageUrls : message.imageUrl ? [message.imageUrl] : [];
|
||||
|
||||
function ReactionEmoji({ emoji }: { emoji: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -132,13 +136,13 @@ function ReplyPreview({ message }: { message: ChatMessage }) {
|
||||
<div className="max-w-full rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||
<span className="font-medium">{message.authorName}</span>
|
||||
<p className="truncate text-muted-foreground">
|
||||
{message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}
|
||||
{message.text || (imageUrlsOf(message).length ? "รูปภาพ" : "ข้อความ")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommissionMessageImage({ src }: { src: string }) {
|
||||
function CommissionMessageImage({ src, compact = false }: { src: string; compact?: boolean }) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
|
||||
@@ -154,19 +158,21 @@ function CommissionMessageImage({ src }: { src: string }) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={state !== "loaded"}
|
||||
className="block max-w-full cursor-zoom-in overflow-hidden rounded-lg disabled:cursor-default"
|
||||
className={cn("block cursor-zoom-in overflow-hidden rounded-md disabled:cursor-default",
|
||||
compact ? "relative aspect-square w-full" : "max-w-full")}
|
||||
aria-label="ขยายรูปภาพที่แนบ"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{state === "loading" && (
|
||||
<Skeleton
|
||||
className="h-40 w-48 max-w-full rounded-lg"
|
||||
className={compact ? "absolute inset-0 size-full rounded-none" : "h-40 w-48 max-w-full rounded-lg"}
|
||||
aria-label="กำลังโหลดรูปภาพ"
|
||||
/>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<span className="block px-4 py-2.5 text-xs">โหลดรูปภาพไม่สำเร็จ</span>
|
||||
<span className={cn("block px-4 py-2.5 text-xs", compact &&
|
||||
"absolute inset-0 flex items-center justify-center p-1 text-center")}>โหลดรูปภาพไม่สำเร็จ</span>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -176,7 +182,7 @@ function CommissionMessageImage({ src }: { src: string }) {
|
||||
onLoad={() => setState("loaded")}
|
||||
onError={() => setState("error")}
|
||||
className={cn(
|
||||
"max-h-72 max-w-full object-contain",
|
||||
compact ? "absolute inset-0 size-full object-cover" : "max-h-72 max-w-full object-contain",
|
||||
state === "loaded" ? "block" : "hidden",
|
||||
)}
|
||||
/>
|
||||
@@ -194,6 +200,17 @@ function CommissionMessageImage({ src }: { src: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CommissionMediaGrid({ urls, pending = false }: { urls: string[]; pending?: boolean }) {
|
||||
if (!urls.length) return null;
|
||||
if (urls.length === 1)
|
||||
return <div className={cn(pending && "opacity-50")}><CommissionMessageImage src={urls[0]} /></div>;
|
||||
return <div className={cn("grid max-w-full gap-1 overflow-hidden rounded-lg",
|
||||
urls.length === 2 ? "w-[min(72vw,24rem)] grid-cols-2" : "w-[min(72vw,36rem)] grid-cols-3",
|
||||
pending && "opacity-50")}>
|
||||
{urls.map((url) => <CommissionMessageImage key={url} src={url} compact />)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CommissionTicketChat({
|
||||
ticketId,
|
||||
ticketTitle,
|
||||
@@ -216,9 +233,9 @@ export function CommissionTicketChat({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const imagePreviewRef = useRef<string | null>(null);
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const imagePreviewsRef = useRef<string[]>([]);
|
||||
const optimisticImageUrls = useRef(new Set<string>());
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<
|
||||
OptimisticMessage[]
|
||||
@@ -249,10 +266,10 @@ export function CommissionTicketChat({
|
||||
(message) =>
|
||||
!message.serverId ||
|
||||
!messages.some((saved) => saved.id === message.serverId),
|
||||
);
|
||||
).map((message) => ({ ...message, imageUrls: imageUrlsOf(message) }));
|
||||
const visibleMessages = messages.filter(
|
||||
(message) => !deletedMessageIds.has(message.id),
|
||||
);
|
||||
).map((message) => ({ ...message, imageUrls: imageUrlsOf(message) }));
|
||||
const visibleMessageById = new Map(
|
||||
visibleMessages.map((message) => [message.id, message]),
|
||||
);
|
||||
@@ -262,7 +279,7 @@ export function CommissionTicketChat({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
|
||||
for (const url of imagePreviewsRef.current) URL.revokeObjectURL(url);
|
||||
for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url);
|
||||
},
|
||||
[],
|
||||
@@ -270,14 +287,15 @@ export function CommissionTicketChat({
|
||||
|
||||
useEffect(() => {
|
||||
for (const message of optimisticMessages) {
|
||||
const urls = imageUrlsOf(message);
|
||||
if (
|
||||
message.serverId &&
|
||||
messages.some((saved) => saved.id === message.serverId) &&
|
||||
message.imageUrl &&
|
||||
optimisticImageUrls.current.has(message.imageUrl)
|
||||
urls.some((url) => optimisticImageUrls.current.has(url))
|
||||
) {
|
||||
URL.revokeObjectURL(message.imageUrl);
|
||||
optimisticImageUrls.current.delete(message.imageUrl);
|
||||
for (const url of urls) {
|
||||
if (optimisticImageUrls.current.delete(url)) URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [messages, optimisticMessages]);
|
||||
@@ -286,7 +304,7 @@ export function CommissionTicketChat({
|
||||
const originalTitle = document.title;
|
||||
let managedTitle = ticketTitle;
|
||||
let unread = 0;
|
||||
document.title = ticketTitle;
|
||||
const stopKeepingTitle = keepCommissionTitle(() => managedTitle);
|
||||
const markRead = () => {
|
||||
if (document.hidden || !document.hasFocus()) return;
|
||||
unread = 0;
|
||||
@@ -353,6 +371,7 @@ export function CommissionTicketChat({
|
||||
source.close();
|
||||
document.removeEventListener("visibilitychange", markRead);
|
||||
window.removeEventListener("focus", markRead);
|
||||
stopKeepingTitle();
|
||||
if (document.title === managedTitle) document.title = originalTitle;
|
||||
};
|
||||
}, [ticketId, ticketTitle, userId, admin, router]);
|
||||
@@ -389,49 +408,50 @@ export function CommissionTicketChat({
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
function setAttachment(file: File | null) {
|
||||
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
|
||||
const preview = file ? URL.createObjectURL(file) : null;
|
||||
imagePreviewRef.current = preview;
|
||||
setImage(file);
|
||||
setImagePreview(preview);
|
||||
function setAttachments(files: File[]) {
|
||||
for (const url of imagePreviewsRef.current) URL.revokeObjectURL(url);
|
||||
const previews = files.map((file) => URL.createObjectURL(file));
|
||||
imagePreviewsRef.current = previews;
|
||||
setImages(files);
|
||||
setImagePreviews(previews);
|
||||
}
|
||||
|
||||
function chooseAttachment(file: File | null) {
|
||||
if (
|
||||
file &&
|
||||
(file.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))
|
||||
) {
|
||||
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
function chooseAttachments(files: File[]) {
|
||||
if (images.length + files.length > 10) {
|
||||
setError("แนบรูปภาพได้ไม่เกิน 10 รูปต่อข้อความ");
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.size === 0 || file.size > 10 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))) {
|
||||
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 10 MiB ต่อรูป");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setAttachment(file);
|
||||
setAttachments([...images, ...files]);
|
||||
}
|
||||
|
||||
function handleComposerPaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
||||
const pastedImage = Array.from(event.clipboardData.items)
|
||||
.find((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
?.getAsFile();
|
||||
if (!pastedImage) return;
|
||||
const pastedImages = 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 (!pastedImages.length) return;
|
||||
event.preventDefault();
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
chooseAttachment(pastedImage);
|
||||
chooseAttachments(pastedImages);
|
||||
}
|
||||
|
||||
async function send(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy || (!text.trim() && !image)) return;
|
||||
if (busy || (!text.trim() && !images.length)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
sendingRef.current = true;
|
||||
const form = event.currentTarget;
|
||||
const originalText = text;
|
||||
const originalImage = image;
|
||||
const originalImages = images;
|
||||
const localId = String(++nextOptimisticId.current);
|
||||
const imageUrl = image ? URL.createObjectURL(image) : null;
|
||||
if (imageUrl) optimisticImageUrls.current.add(imageUrl);
|
||||
const imageUrls = images.map((file) => URL.createObjectURL(file));
|
||||
for (const url of imageUrls) optimisticImageUrls.current.add(url);
|
||||
setOptimisticMessages((current) => [
|
||||
...current.filter(
|
||||
(message) =>
|
||||
@@ -442,7 +462,7 @@ export function CommissionTicketChat({
|
||||
localId,
|
||||
serverId: null,
|
||||
text: text.trim(),
|
||||
imageUrl,
|
||||
imageUrls,
|
||||
replyToId: replyTarget?.id ?? null,
|
||||
status: "sending",
|
||||
},
|
||||
@@ -453,11 +473,11 @@ export function CommissionTicketChat({
|
||||
data.set("text", text.trim());
|
||||
if (replyTarget) data.set("replyToId", replyTarget.id);
|
||||
else data.delete("replyToId");
|
||||
if (image) data.set("image", image);
|
||||
else data.delete("image");
|
||||
data.delete("image");
|
||||
for (const image of images) data.append("image", image);
|
||||
form.reset();
|
||||
setText("");
|
||||
setAttachment(null);
|
||||
setAttachments([]);
|
||||
setReplyToId(null);
|
||||
sendTyping(false);
|
||||
const response = await fetch(
|
||||
@@ -478,15 +498,15 @@ export function CommissionTicketChat({
|
||||
deferredRefresh.current = false;
|
||||
router.refresh();
|
||||
} catch (cause) {
|
||||
if (imageUrl) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
optimisticImageUrls.current.delete(imageUrl);
|
||||
for (const url of imageUrls) {
|
||||
URL.revokeObjectURL(url);
|
||||
optimisticImageUrls.current.delete(url);
|
||||
}
|
||||
setOptimisticMessages((current) =>
|
||||
current.filter((message) => message.localId !== localId),
|
||||
);
|
||||
setText(originalText);
|
||||
setAttachment(originalImage);
|
||||
setAttachments(originalImages);
|
||||
setReplyToId(replyTarget?.id ?? null);
|
||||
setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ");
|
||||
sendingRef.current = false;
|
||||
@@ -712,9 +732,17 @@ export function CommissionTicketChat({
|
||||
</Empty>
|
||||
) : (
|
||||
<MessageGroup className="gap-3">
|
||||
{visibleMessages.map((message) => {
|
||||
{visibleMessages.map((message, index) => {
|
||||
const mine = message.authorId === userId;
|
||||
const sentAt = new Date(message.createdAt);
|
||||
const previous = visibleMessages[index - 1];
|
||||
const showAuthor = !previous || previous.authorId !== message.authorId;
|
||||
const next = visibleMessages[index + 1];
|
||||
const showAvatar = !next || next.authorId !== message.authorId;
|
||||
const previousAt = previous ? new Date(previous.createdAt) : null;
|
||||
const showTime = !previousAt || sentAt.getFullYear() !== previousAt.getFullYear() ||
|
||||
sentAt.getMonth() !== previousAt.getMonth() || sentAt.getDate() !== previousAt.getDate() ||
|
||||
sentAt.getHours() !== previousAt.getHours();
|
||||
const parent = message.replyToId
|
||||
? visibleMessageById.get(message.replyToId)
|
||||
: null;
|
||||
@@ -759,16 +787,17 @@ export function CommissionTicketChat({
|
||||
.filter(({ count }) => count > 0);
|
||||
return (
|
||||
<Message key={message.id} align={mine ? "end" : "start"}>
|
||||
<MessageAvatar>
|
||||
<Avatar size="sm">
|
||||
{message.authorImage && (
|
||||
<AvatarImage src={message.authorImage} alt="" />
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{message.authorName.trim().charAt(0).toUpperCase() ||
|
||||
"?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<MessageAvatar className={showAvatar ? undefined : "bg-transparent"}>
|
||||
{showAvatar && (
|
||||
<Avatar size="sm">
|
||||
{message.authorImage && (
|
||||
<AvatarImage src={message.authorImage} alt="" />
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</MessageAvatar>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
@@ -776,25 +805,21 @@ export function CommissionTicketChat({
|
||||
mine ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-1 text-center text-xs font-medium text-muted-foreground",
|
||||
mine ? "translate-x-5" : "-translate-x-5",
|
||||
)}
|
||||
>
|
||||
<time
|
||||
dateTime={sentAt.toISOString()}
|
||||
title={sentAt.toLocaleString("th-TH")}
|
||||
{showTime && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-1 text-center text-xs font-medium text-muted-foreground",
|
||||
mine ? "translate-x-5" : "-translate-x-5",
|
||||
)}
|
||||
>
|
||||
{sentAt.toLocaleTimeString("th-TH", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<MessageHeader className="px-1">
|
||||
{message.authorName}
|
||||
</MessageHeader>
|
||||
<time dateTime={sentAt.toISOString()}>
|
||||
{`${padTime(sentAt.getDate())}/${padTime(sentAt.getMonth() + 1)}/${sentAt.getFullYear()} ${padTime(sentAt.getHours())}:${padTime(sentAt.getMinutes())}`}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
{!mine && showAuthor && (
|
||||
<MessageHeader className="px-1">{message.authorName}</MessageHeader>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-[min(82%,36rem)] items-center gap-1",
|
||||
@@ -803,10 +828,10 @@ export function CommissionTicketChat({
|
||||
>
|
||||
<Bubble
|
||||
variant={
|
||||
message.imageUrl
|
||||
message.imageUrls.length
|
||||
? "ghost"
|
||||
: mine
|
||||
? "muted"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className="min-w-0 max-w-full"
|
||||
@@ -814,7 +839,7 @@ export function CommissionTicketChat({
|
||||
<BubbleContent
|
||||
className={cn(
|
||||
"max-w-full",
|
||||
message.imageUrl
|
||||
message.imageUrls.length
|
||||
? "border-0 bg-transparent p-0 shadow-none"
|
||||
: "px-4 py-2.5 shadow-sm",
|
||||
mine ? "rounded-br-md" : "rounded-bl-md",
|
||||
@@ -825,16 +850,14 @@ export function CommissionTicketChat({
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
mine && "text-foreground",
|
||||
message.imageUrl && "pb-2",
|
||||
mine && message.imageUrls.length && "text-foreground",
|
||||
message.imageUrls.length > 0 && "pb-2",
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{message.imageUrl && (
|
||||
<CommissionMessageImage src={message.imageUrl} />
|
||||
)}
|
||||
<CommissionMediaGrid urls={message.imageUrls} />
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-card/90 opacity-0 transition-opacity pointer-events-none group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
|
||||
@@ -971,13 +994,13 @@ export function CommissionTicketChat({
|
||||
<Message key={message.localId} align="end">
|
||||
<MessageContent className="w-full items-end gap-1">
|
||||
<Bubble
|
||||
variant={message.imageUrl ? "ghost" : "muted"}
|
||||
variant={message.imageUrls.length ? "ghost" : "default"}
|
||||
className="max-w-[min(82%,36rem)]"
|
||||
>
|
||||
<BubbleContent
|
||||
className={cn(
|
||||
"max-w-full",
|
||||
message.imageUrl
|
||||
message.imageUrls.length
|
||||
? "border-0 bg-transparent p-0 shadow-none"
|
||||
: "px-4 py-2.5 shadow-sm",
|
||||
)}
|
||||
@@ -994,29 +1017,21 @@ export function CommissionTicketChat({
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
message.status === "sending"
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground",
|
||||
message.imageUrl && "pb-2",
|
||||
message.status === "sending" &&
|
||||
(message.imageUrls.length ? "text-muted-foreground" : "text-primary-foreground/70"),
|
||||
message.imageUrls.length > 0 && message.status !== "sending" && "text-foreground",
|
||||
message.imageUrls.length > 0 && "pb-2",
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{message.imageUrl && (
|
||||
<div
|
||||
className={cn(
|
||||
message.status === "sending" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<CommissionMessageImage src={message.imageUrl} />
|
||||
</div>
|
||||
)}
|
||||
<CommissionMediaGrid urls={message.imageUrls} pending={message.status === "sending"} />
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter className="w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center">
|
||||
{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}
|
||||
</MessageFooter>
|
||||
{message.status === "sending" && <MessageFooter className="w-full justify-end gap-1 px-1">
|
||||
กำลังส่ง...
|
||||
</MessageFooter>}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
@@ -1048,29 +1063,24 @@ export function CommissionTicketChat({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{image && (
|
||||
<div className="relative w-fit rounded-lg border bg-muted/50 p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{imagePreview && (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="ตัวอย่างรูปภาพที่จะแนบ"
|
||||
className="size-28 rounded-md bg-muted object-contain"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon-xs"
|
||||
aria-label="นำรูปภาพออก"
|
||||
className="absolute top-1 right-1 shadow-sm"
|
||||
onClick={() => {
|
||||
setAttachment(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
{images.length > 0 && (
|
||||
<div>
|
||||
<div className="grid max-w-md grid-cols-5 gap-1.5" aria-label={`รูปภาพที่แนบ ${images.length} รูป`}>
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<div key={preview} className="relative aspect-square overflow-hidden rounded-md bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={preview} alt={`ตัวอย่างรูปภาพที่แนบ ${index + 1}`}
|
||||
className="absolute inset-0 size-full object-cover" />
|
||||
<Button type="button" variant="secondary" size="icon-xs"
|
||||
aria-label={`นำรูปภาพที่ ${index + 1} ออก`}
|
||||
className="absolute top-0.5 right-0.5 shadow-sm"
|
||||
onClick={() => setAttachments(images.filter((_, position) => position !== index))}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{images.length}/10 รูป · สูงสุด 10 MiB ต่อรูป</p>
|
||||
</div>
|
||||
)}
|
||||
<Field>
|
||||
@@ -1086,7 +1096,7 @@ export function CommissionTicketChat({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={busy}
|
||||
disabled={busy || images.length >= 10}
|
||||
aria-label="แนบรูปภาพ"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
@@ -1120,7 +1130,7 @@ export function CommissionTicketChat({
|
||||
variant="default"
|
||||
size="sm"
|
||||
aria-label={busy ? "กำลังส่งข้อความ" : "ส่งข้อความ"}
|
||||
disabled={busy || (!text.trim() && !image)}
|
||||
disabled={busy || (!text.trim() && !images.length)}
|
||||
>
|
||||
<SendHorizontal data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">
|
||||
@@ -1141,17 +1151,10 @@ export function CommissionTicketChat({
|
||||
aria-label="แนบรูปภาพ"
|
||||
className="sr-only"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const selected = event.target.files?.[0] ?? null;
|
||||
chooseAttachment(selected);
|
||||
if (
|
||||
selected &&
|
||||
(selected.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(
|
||||
selected.type,
|
||||
))
|
||||
)
|
||||
event.target.value = "";
|
||||
chooseAttachments(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
Reference in New Issue
Block a user