feat : add emoji picker
This commit is contained in:
@@ -237,6 +237,8 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|||||||
</div>
|
</div>
|
||||||
<div className={cn("contents", fullWidthMobile && "max-sm:col-span-2 max-sm:flex max-sm:min-w-0 max-sm:flex-col max-sm:gap-2")}>
|
<div className={cn("contents", fullWidthMobile && "max-sm:col-span-2 max-sm:flex max-sm:min-w-0 max-sm:flex-col max-sm:gap-2")}>
|
||||||
{item.replyToName && <p className="text-xs text-muted-foreground">ตอบกลับ {item.replyToName}</p>}
|
{item.replyToName && <p className="text-xs text-muted-foreground">ตอบกลับ {item.replyToName}</p>}
|
||||||
|
{mode === "edit" ? <CommentComposer key="edit" target={item.target} editing={item}
|
||||||
|
onCancel={() => setMode(null)} onSaved={() => { setMode(null); onChange(); }} /> : <>
|
||||||
{(item.deleted || item.hidden) && !viewer?.admin ? <p className="text-sm text-muted-foreground">{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}</p> : <CommentText text={item.text} />}
|
{(item.deleted || item.hidden) && !viewer?.admin ? <p className="text-sm text-muted-foreground">{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}</p> : <CommentText text={item.text} />}
|
||||||
<CommentImages images={item.images} />
|
<CommentImages images={item.images} />
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
@@ -250,7 +252,8 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|||||||
: optimistic.hearted && !item.deleted && !item.hidden && <Badge variant="secondary"><HeartIcon data-icon="inline-start" className="text-destructive" fill="currentColor" />ผู้ดูแลถูกใจ</Badge>}
|
: optimistic.hearted && !item.deleted && !item.hidden && <Badge variant="secondary"><HeartIcon data-icon="inline-start" className="text-destructive" fill="currentColor" />ผู้ดูแลถูกใจ</Badge>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{mode && <CommentComposer key={mode} target={item.target} replyTo={mode === "reply" ? item : undefined} editing={mode === "edit" ? item : undefined}
|
</>}
|
||||||
|
{mode === "reply" && <CommentComposer key="reply" target={item.target} replyTo={item}
|
||||||
onCancel={() => setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
|
onCancel={() => setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
|
||||||
{replies && !item.rootId && (item.replyCount > 0 || expanded) && <Accordion value={expanded ? ["replies"] : []} onValueChange={(value) => expandReplies(value.includes("replies"))}>
|
{replies && !item.rootId && (item.replyCount > 0 || expanded) && <Accordion value={expanded ? ["replies"] : []} onValueChange={(value) => expandReplies(value.includes("replies"))}>
|
||||||
<AccordionItem value="replies"><AccordionTrigger ref={replyToggleRef} className="flex-none gap-2 rounded-full px-3" headerClassName={expanded ? "hidden" : "justify-start"}>{`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`}</AccordionTrigger>
|
<AccordionItem value="replies"><AccordionTrigger ref={replyToggleRef} className="flex-none gap-2 rounded-full px-3" headerClassName={expanded ? "hidden" : "justify-start"}>{`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`}</AccordionTrigger>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useId, useRef, useState, type ClipboardEvent, type FormEvent } from "react";
|
import { useEffect, useId, useRef, useState, type ClipboardEvent, type FormEvent } from "react";
|
||||||
import { ImagePlusIcon, XIcon } from "lucide-react";
|
import dynamic from "next/dynamic";
|
||||||
|
import { ImagePlusIcon, SmilePlusIcon, XIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
@@ -9,10 +10,13 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
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 type { CommentImage, CommentItem } from "@/lib/comments/types";
|
||||||
import { CommentImageView } from "./comment-image";
|
import { CommentImageView } from "./comment-image";
|
||||||
import { COMMENT_ERRORS } from "./client";
|
import { COMMENT_ERRORS } from "./client";
|
||||||
|
|
||||||
|
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
|
||||||
|
|
||||||
function FilePreview({ file }: { file: File }) {
|
function FilePreview({ file }: { file: File }) {
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,9 +40,28 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
|
|||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(Boolean(editing || replyTo));
|
const [expanded, setExpanded] = useState(Boolean(editing || replyTo));
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
const uploadRef = useRef<XMLHttpRequest | null>(null);
|
const uploadRef = useRef<XMLHttpRequest | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const isEditing = Boolean(editing);
|
||||||
useEffect(() => () => uploadRef.current?.abort(), []);
|
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[]) {
|
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("แนบรูปภาพได้สูงสุด 5 รูปต่อความคิดเห็น");
|
||||||
@@ -105,7 +128,7 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
|
|||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={Boolean(error)}>
|
<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 ? "แก้ไขความคิดเห็น" : 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}
|
<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) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) {
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -134,7 +157,16 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
|
|||||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
{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 ? "กำลังบันทึกความคิดเห็น…" : "กำลังอัปโหลด…"}</ProgressLabel><ProgressValue /></Progress>}
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<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>
|
<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>
|
||||||
|
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||||
|
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label="เพิ่มอีโมจิ" disabled={pending} />}><SmilePlusIcon /></PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 max-w-[calc(100vw-2rem)] p-0" finalFocus={textRef}>
|
||||||
|
<PopoverTitle className="sr-only">เลือกอีโมจิ</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>}
|
{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>
|
<Button type="submit" disabled={pending || (!text.trim() && !files.length && !kept.length)}>{pending ? "กำลังบันทึก…" : editing ? "บันทึกการแก้ไข" : replyTo ? "ตอบกลับ" : "แสดงความคิดเห็น"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user