|
|
|
@@ -3,18 +3,20 @@
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
|
|
|
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
|
|
|
|
|
import { BanIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, PencilIcon, ShieldCheckIcon, ThumbsDownIcon, ThumbsUpIcon, Trash2Icon } from "lucide-react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
|
|
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
|
|
|
import { CommentSkeleton, CommentThreadSkeleton } from "./comment-skeleton";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
|
import { TableCell, TableRow } from "@/components/ui/table";
|
|
|
|
|
import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer } from "@/lib/comments/types";
|
|
|
|
|
import { CommentImageView } from "./comment-image";
|
|
|
|
|
import { CommentComposer } from "./comment-composer";
|
|
|
|
@@ -131,6 +133,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|
|
|
|
const replyToggleRef = useRef<HTMLButtonElement>(null);
|
|
|
|
|
const [history, setHistory] = useState(false);
|
|
|
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
|
const [banning, setBanning] = useState(false);
|
|
|
|
|
const [pending, setPending] = useState(false);
|
|
|
|
|
const [reactionOverride, setReactionOverride] = useState<{ reaction: number; likes: number; hearted: boolean } | null>(null);
|
|
|
|
|
const optimistic = reactionOverride ?? { reaction: item.reaction, likes: item.likes, hearted: item.hearted };
|
|
|
|
@@ -148,7 +151,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|
|
|
|
if (reactionOverride) void Promise.resolve().then(() => setReactionOverride((current) => current === reactionOverride ? null : current));
|
|
|
|
|
}
|
|
|
|
|
}, [item.reaction, item.likes, item.hearted, reactionOverride]);
|
|
|
|
|
async function mutate(action: "reaction" | "heart" | "moderation" | "delete", value?: number | boolean) {
|
|
|
|
|
async function mutate(action: "reaction" | "heart" | "ban" | "delete", value?: number | boolean) {
|
|
|
|
|
if (!viewer) return router.push(commentSignInHref());
|
|
|
|
|
if (action === "reaction" || action === "heart") {
|
|
|
|
|
const sequence = ++reactionSequence.current;
|
|
|
|
@@ -193,9 +196,9 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|
|
|
|
try {
|
|
|
|
|
await commentRequest(`/api/comments/${item.id}${action === "delete" ? "" : `/${action}`}`, {
|
|
|
|
|
method: action === "delete" ? "DELETE" : "PUT",
|
|
|
|
|
...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hidden: value }) }),
|
|
|
|
|
...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ banned: value }) }),
|
|
|
|
|
});
|
|
|
|
|
setDeleting(false); onChange();
|
|
|
|
|
setDeleting(false); setBanning(false); await onChange();
|
|
|
|
|
} catch (cause) { toast.error(cause instanceof Error ? cause.message : "ไม่สามารถอัปเดตความคิดเห็นได้"); }
|
|
|
|
|
finally { setPending(false); }
|
|
|
|
|
}
|
|
|
|
@@ -207,12 +210,18 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|
|
|
|
const optionsMenu = (item.canEdit || viewer?.admin) && <DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" className="shrink-0 rounded-full" aria-label="ตัวเลือกความคิดเห็น" disabled={pending} />}><EllipsisVerticalIcon /></DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end"><DropdownMenuGroup>
|
|
|
|
|
{item.canEdit && <><DropdownMenuItem onClick={() => compose("edit")}>แก้ไข</DropdownMenuItem><DropdownMenuItem variant="destructive" onClick={() => setDeleting(true)}>ลบ</DropdownMenuItem></>}
|
|
|
|
|
{viewer?.admin && <DropdownMenuItem disabled={!item.canModerate || (item.hidden && !item.ownHidden)} onClick={() => void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}</DropdownMenuItem>}
|
|
|
|
|
{item.canEdit && <DropdownMenuItem onClick={() => compose("edit")}><PencilIcon />แก้ไข</DropdownMenuItem>}
|
|
|
|
|
{(item.canEdit || viewer?.admin) && <DropdownMenuItem variant="destructive" disabled={pending || (viewer?.admin && !item.canModerate)} onClick={() => setDeleting(true)}><Trash2Icon />ลบ</DropdownMenuItem>}
|
|
|
|
|
{viewer?.admin && <DropdownMenuItem disabled={pending || !item.canBanAuthor} onClick={() => item.authorBanned ? void mutate("ban", false) : setBanning(true)}>
|
|
|
|
|
{item.authorBanned ? <ShieldCheckIcon /> : <BanIcon />}{item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"}
|
|
|
|
|
</DropdownMenuItem>}
|
|
|
|
|
</DropdownMenuGroup></DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>;
|
|
|
|
|
return <article id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("flex scroll-mt-36 flex-col gap-2 py-2", inbox && "grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_280px] md:gap-6", highlighted && "rounded-lg bg-accent ring-2 ring-ring", expanded && !item.rootId && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border", fullWidthMobile && "max-sm:before:hidden")}>
|
|
|
|
|
const Container = inbox ? TableRow : "article";
|
|
|
|
|
const Content = inbox ? TableCell : "div";
|
|
|
|
|
return <Container id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("scroll-mt-36", inbox ? "max-md:block" : "flex flex-col gap-2 py-2", highlighted && "rounded-lg bg-accent ring-2 ring-ring", expanded && !item.rootId && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border", fullWidthMobile && "max-sm:before:hidden")}>
|
|
|
|
|
|
|
|
|
|
<Content className={cn("min-w-0", inbox && "px-0 py-5 align-top whitespace-normal max-md:block max-md:pb-3 md:pr-6")}>
|
|
|
|
|
<div className={cn("flex min-w-0 items-start gap-3", fullWidthMobile && "grid grid-cols-[auto_minmax(0,1fr)] sm:flex")}>
|
|
|
|
|
<Avatar className={item.rootId ? "size-8 shrink-0" : "size-10 shrink-0"}><AvatarImage src={item.authorImage ?? undefined} alt={item.authorName} /><AvatarFallback>{item.authorName.slice(0, 2)}</AvatarFallback></Avatar>
|
|
|
|
|
<div className={cn("flex min-w-0 flex-1 flex-col gap-2", fullWidthMobile && "max-sm:contents")}>
|
|
|
|
@@ -255,14 +264,21 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{inbox && <div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</div>}
|
|
|
|
|
</Content>
|
|
|
|
|
{inbox && <TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0">
|
|
|
|
|
<div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</div>
|
|
|
|
|
</TableCell>}
|
|
|
|
|
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>ประวัติการแก้ไข</DialogTitle><DialogDescription>ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น</DialogDescription></DialogHeader>
|
|
|
|
|
{history && <RevisionHistory id={item.id} />}</DialogContent></Dialog>
|
|
|
|
|
<Dialog open={deleting} onOpenChange={setDeleting}><DialogContent><DialogHeader><DialogTitle>ลบความคิดเห็นนี้?</DialogTitle><DialogDescription>ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้</DialogDescription></DialogHeader>
|
|
|
|
|
<div className="flex justify-end gap-2"><Button variant="ghost" disabled={pending} onClick={() => setDeleting(false)}>ยกเลิก</Button><Button variant="destructive" disabled={pending} onClick={() => void mutate("delete")}>ลบ</Button></div>
|
|
|
|
|
</DialogContent></Dialog>
|
|
|
|
|
<AlertDialog open={banning} onOpenChange={(open) => { if (!pending) setBanning(open); }}><AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader><AlertDialogTitle>แบน {item.authorName}?</AlertDialogTitle><AlertDialogDescription>ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่</AlertDialogDescription></AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter><AlertDialogCancel className="max-md:min-h-11" disabled={pending}>ยกเลิก</AlertDialogCancel><AlertDialogAction variant="destructive" className="max-md:min-h-11" disabled={pending} onClick={() => void mutate("ban", true)}><BanIcon data-icon="inline-start" />แบนผู้เขียน</AlertDialogAction></AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent></AlertDialog>
|
|
|
|
|
|
|
|
|
|
</article>;
|
|
|
|
|
</Container>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; moderationDetails?: boolean }) {
|
|
|
|
|