Files
buzz-sheet/components/comments/comment-card.tsx
T

1107 lines
38 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import {
BanIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
EllipsisVerticalIcon,
HeartIcon,
PencilIcon,
ShieldCheckIcon,
ThumbsDownIcon,
ThumbsUpIcon,
Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
import { Avatar, AvatarBadge, 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 { SimpleTooltip } from "@/components/tooltip";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { CommentSkeleton } 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";
import { commentRequest, commentSignInHref } from "./client";
import { useCommentFeed } from "./use-comment-feed";
// A single minute clock for all timestamps, with a stable SSR snapshot.
let clockTime = 0;
let clockTimer: ReturnType<typeof setInterval> | undefined;
const clockListeners = new Set<() => void>();
function subscribeClock(listener: () => void) {
clockListeners.add(listener);
clockTime = Date.now();
if (!clockTimer)
clockTimer = setInterval(() => {
clockTime = Date.now();
for (const notify of clockListeners) notify();
}, 60_000);
return () => {
clockListeners.delete(listener);
if (!clockListeners.size) {
clearInterval(clockTimer);
clockTimer = undefined;
}
};
}
const clockSnapshot = () => clockTime;
const serverClockSnapshot = () => 0;
const timestampFormatter = new Intl.DateTimeFormat("th-TH", {
calendar: "gregory",
timeZone: "Asia/Bangkok",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
function CommentTimestamp({ value }: { value: string }) {
const now = useSyncExternalStore(
subscribeClock,
clockSnapshot,
serverClockSnapshot,
);
function relativeTime() {
const seconds = Math.round((new Date(value).getTime() - now) / 1000);
const absolute = Math.abs(seconds);
if (absolute < 60) return "เมื่อสักครู่";
const formatter = new Intl.RelativeTimeFormat("th-TH", { numeric: "auto" });
const units: [number, Intl.RelativeTimeFormatUnit][] = [
[31536000, "year"],
[2592000, "month"],
[86400, "day"],
[3600, "hour"],
[60, "minute"],
];
const [size, unit] = units.find(([size]) => absolute >= size)!;
return formatter.format(Math.round(seconds / size), unit);
}
return (
<SimpleTooltip text={timestampFormatter.format(new Date(value))}>
<time
dateTime={value}
tabIndex={0}
className="cursor-default text-xs text-muted-foreground hover:underline"
>
{now > 0 ? relativeTime() : `${value.slice(0, 16).replace("T", " ")}`}
</time>
</SimpleTooltip>
);
}
export function CommentImages({ images }: { images: CommentImage[] }) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selected = selectedIndex === null ? null : images[selectedIndex];
return images.length ? (
<>
<div className="flex flex-wrap gap-2">
{images.map((image, index) => (
<Button
key={image.id}
variant="ghost"
className="h-auto rounded-lg p-0"
aria-label={`เปิดรูปภาพที่ ${index + 1}`}
onClick={() => setSelectedIndex(index)}
>
<CommentImageView
image={image}
alt={`รูปภาพประกอบความคิดเห็นที่ ${index + 1}`}
className="size-28 rounded-lg border object-cover sm:size-36"
/>
</Button>
))}
</div>
<Dialog
open={Boolean(selected)}
onOpenChange={(open) => {
if (!open) setSelectedIndex(null);
}}
>
<DialogContent
className="max-h-[90svh] overflow-y-auto sm:max-w-5xl"
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
setSelectedIndex((current) =>
Math.max(
0,
Math.min(
images.length - 1,
(current ?? 0) + (event.key === "ArrowLeft" ? -1 : 1),
),
),
);
}
}}
>
<DialogHeader>
<DialogTitle>{"รูปภาพประกอบความคิดเห็น"}</DialogTitle>
<DialogDescription>
{"รูปที่"} {(selectedIndex ?? 0) + 1} {"จาก"} {images.length}
</DialogDescription>
</DialogHeader>
{selected && (
<>
<CommentImageView
fullSize
key={selected.id}
image={selected}
alt={`รูปภาพประกอบความคิดเห็นที่ ${(selectedIndex ?? 0) + 1}`}
className="object-contain"
/>
{images.length > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="icon"
aria-label={"รูปก่อนหน้า"}
disabled={selectedIndex === 0}
onClick={() =>
setSelectedIndex((current) => (current ?? 0) - 1)
}
>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
size="icon"
aria-label={"รูปถัดไป"}
disabled={selectedIndex === images.length - 1}
onClick={() =>
setSelectedIndex((current) => (current ?? 0) + 1)
}
>
<ChevronRightIcon />
</Button>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</>
) : null;
}
function CommentText({ text }: { text: string }) {
return (
<p className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed">
{text.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) =>
/^https?:\/\//.test(part) ? (
<a
key={i}
href={part}
rel="nofollow ugc noopener noreferrer"
target="_blank"
className="text-primary underline underline-offset-4"
>
{part}
</a>
) : (
part
),
)}
</p>
);
}
function RevisionHistory({ id }: { id: string }) {
const [items, setItems] = useState<CommentHistoryItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(true);
const load = useCallback(
async (next?: string) => {
setBusy(true);
try {
const result = await commentRequest<{
items: CommentHistoryItem[];
nextCursor: string | null;
}>(`/api/comments/${id}/history${next ? `?cursor=${next}` : ""}`);
setItems((current) =>
next ? [...current, ...result.items] : result.items,
);
setCursor(result.nextCursor);
setError("");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "ไม่สามารถโหลดประวัติการแก้ไขได้",
);
} finally {
setBusy(false);
}
},
[id],
);
useEffect(() => {
void Promise.resolve().then(() => load());
}, [load]);
return (
<div className="flex max-h-[65vh] flex-col gap-4 overflow-y-auto">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{items.map((revision) => (
<section key={revision.id} className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
{"เวอร์ชัน"} {revision.version} ·{" "}
{new Date(revision.createdAt).toLocaleString("th-TH")}
</p>
<CommentText text={revision.text} />
<CommentImages images={revision.images} />
<Separator />
</section>
))}
{busy && <CommentSkeleton count={2} />}
{cursor && (
<Button
variant="outline"
disabled={busy}
onClick={() => void load(cursor)}
>
{"ดูเวอร์ชันก่อนหน้า"}
</Button>
)}
{error && (
<Button variant="outline" disabled={busy} onClick={() => void load()}>
{"ลองใหม่"}
</Button>
)}
</div>
);
}
export function CommentCard({
item,
viewer,
onChange,
revision = 0,
inbox = false,
inboxReply = false,
moderationDetails = false,
replies = true,
defaultExpanded = false,
focusReplyId,
highlighted = false,
overview,
}: {
item: CommentItem;
viewer: CommentViewer | null;
onChange: () => void | Promise<unknown>;
revision?: number;
inbox?: boolean;
inboxReply?: boolean;
moderationDetails?: boolean;
replies?: boolean;
defaultExpanded?: boolean;
focusReplyId?: string;
highlighted?: boolean;
overview?: ReactNode;
}) {
const router = useRouter();
const fullWidthMobile = inbox || moderationDetails;
const [mode, setMode] = useState<"reply" | "edit" | null>(null);
const [expanded, setExpanded] = useState(defaultExpanded);
const [hasOpenedReplies, setHasOpenedReplies] = useState(defaultExpanded);
function expandReplies(open: boolean) {
setExpanded(open);
if (open) setHasOpenedReplies(true);
}
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,
};
const heartedBy = item.heartedBy ?? (viewer?.admin && !item.hearted ? viewer : null);
const heartLabel = `${heartedBy?.name || "ผู้ดูแล"} ถูกใจความคิดเห็นนี้`;
const heartAvatar = (
<SimpleTooltip text={heartLabel}>
<Avatar size="sm" aria-label={heartLabel}>
<AvatarImage src={heartedBy?.image ?? undefined} alt="" />
<AvatarFallback>{heartedBy?.name?.slice(0, 2) || "ผู"}</AvatarFallback>
<AvatarBadge className="-right-0.5 -bottom-0.5 bg-transparent text-destructive ring-0 group-data-[size=sm]/avatar:size-3.5 group-data-[size=sm]/avatar:[&>svg]:block [&>svg]:size-2.5!">
<HeartIcon fill="currentColor" />
</AvatarBadge>
</Avatar>
</SimpleTooltip>
);
const reactionQueue = useRef<Promise<void>>(Promise.resolve());
const reactionSequence = useRef(0);
const queuedReactions = useRef(0);
const baseReaction = useRef({
reaction: item.reaction,
likes: item.likes,
hearted: item.hearted,
});
const desired = useRef({
reaction: item.reaction,
likes: item.likes,
hearted: item.hearted,
});
const confirmedReaction = useRef({
reaction: item.reaction,
likes: item.likes,
hearted: item.hearted,
});
useEffect(() => {
baseReaction.current = {
reaction: item.reaction,
likes: item.likes,
hearted: item.hearted,
};
if (
!queuedReactions.current &&
(!reactionOverride ||
(item.reaction === reactionOverride.reaction &&
item.hearted === reactionOverride.hearted))
) {
desired.current = baseReaction.current;
confirmedReaction.current = baseReaction.current;
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" | "ban" | "delete",
value?: number | boolean,
) {
if (!viewer) return router.push(commentSignInHref());
if (action === "reaction" || action === "heart") {
const sequence = ++reactionSequence.current;
const next =
action === "heart"
? { ...desired.current, hearted: Boolean(value) }
: {
...desired.current,
reaction: Number(value),
likes: Math.max(
0,
desired.current.likes +
(value === 1 ? 1 : 0) -
(desired.current.reaction === 1 ? 1 : 0),
),
};
desired.current = next;
setReactionOverride(next);
queuedReactions.current++;
const save = reactionQueue.current.then(async () => {
await commentRequest(`/api/comments/${item.id}/${action}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
action === "reaction" ? { value } : { hearted: value },
),
});
confirmedReaction.current =
action === "heart"
? { ...confirmedReaction.current, hearted: Boolean(value) }
: {
...confirmedReaction.current,
reaction: Number(value),
likes: Math.max(
0,
confirmedReaction.current.likes +
(value === 1 ? 1 : 0) -
(confirmedReaction.current.reaction === 1 ? 1 : 0),
),
};
await onChange();
});
reactionQueue.current = save.catch(() => undefined);
try {
await save;
} catch (cause) {
if (sequence === reactionSequence.current) {
desired.current = confirmedReaction.current;
setReactionOverride(confirmedReaction.current);
}
toast.error(
cause instanceof Error
? cause.message
: "ไม่สามารถบันทึกได้ กรุณาลองใหม่",
);
} finally {
queuedReactions.current--;
if (!queuedReactions.current && sequence === reactionSequence.current) {
const saved = confirmedReaction.current;
desired.current = saved;
setReactionOverride(
baseReaction.current.reaction === saved.reaction &&
baseReaction.current.hearted === saved.hearted
? null
: saved,
);
}
}
return;
}
setPending(true);
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({ banned: value }),
}),
},
);
setDeleting(false);
setBanning(false);
await onChange();
} catch (cause) {
toast.error(
cause instanceof Error
? cause.message
: "ไม่สามารถอัปเดตความคิดเห็นได้",
);
} finally {
setPending(false);
}
}
function compose(next: "reply" | "edit") {
if (!viewer) return router.push(commentSignInHref());
setMode(next);
}
const canReadHistory =
(!item.deleted && !item.hidden) || Boolean(viewer?.admin);
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")}>
<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>
);
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",
inboxReply && "relative",
highlighted && "rounded-lg bg-accent ring-2 ring-ring",
expanded &&
!item.rootId &&
!inbox &&
"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",
inbox &&
expanded &&
!item.rootId &&
"relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border max-md:before:hidden",
)}
>
<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",
)}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold">{item.authorName}</span>
{item.authorAdmin && (
<Badge variant="secondary">{"ผู้ดูแล"}</Badge>
)}
<CommentTimestamp value={item.createdAt} />
{inbox && item.rootId && (
<Badge variant="outline">{"ตอบกลับ"}</Badge>
)}
{(inbox || moderationDetails) && item.hidden && (
<Badge variant="secondary">
{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}
</Badge>
)}
{(inbox || moderationDetails) && item.deleted && (
<Badge variant="secondary">{"ลบแล้ว"}</Badge>
)}
{item.version > 1 && canReadHistory && (
<Button
variant="link"
size="xs"
onClick={() => setHistory(true)}
>
{"แก้ไขแล้ว"}
</Button>
)}
</div>
{/* Inbox replies reach across the 280px content column and 24px cell padding. */}
{!inbox && optionsMenu && (
<div
className={cn(
"shrink-0",
inboxReply && "md:absolute md:top-2 md:-right-76",
)}
>
{optionsMenu}
</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",
)}
>
{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} />
)}
<CommentImages images={item.images} />
<div className="flex flex-wrap items-center gap-1">
<Button
variant={
optimistic.reaction === 1 ? "secondary" : "ghost"
}
size="sm"
className="rounded-full"
aria-label={`ถูกใจความคิดเห็น มี ${optimistic.likes} ถูกใจ`}
aria-pressed={optimistic.reaction === 1}
disabled={pending || !item.canInteract}
onClick={() =>
void mutate(
"reaction",
desired.current.reaction === 1 ? 0 : 1,
)
}
>
<ThumbsUpIcon
data-icon="inline-start"
fill={
optimistic.reaction === 1 ? "currentColor" : "none"
}
/>
{optimistic.likes > 0 &&
optimistic.likes.toLocaleString("th-TH")}
</Button>
<Button
variant={
optimistic.reaction === -1 ? "secondary" : "ghost"
}
size="icon-sm"
className="rounded-full"
aria-label={"ไม่ถูกใจความคิดเห็น"}
aria-pressed={optimistic.reaction === -1}
disabled={pending || !item.canInteract}
onClick={() =>
void mutate(
"reaction",
desired.current.reaction === -1 ? 0 : -1,
)
}
>
<ThumbsDownIcon
fill={
optimistic.reaction === -1 ? "currentColor" : "none"
}
/>
</Button>
{item.canInteract && (
<Button
variant="ghost"
size="sm"
className="rounded-full"
onClick={() => compose("reply")}
disabled={pending}
>
{"ตอบกลับ"}
</Button>
)}
{viewer?.admin ? (
<Button
variant="ghost"
size="icon"
className="rounded-full"
aria-label={optimistic.hearted ? "ยกเลิกหัวใจจากผู้ดูแล" : "ให้หัวใจจากผู้ดูแล"}
aria-pressed={optimistic.hearted}
disabled={pending || !item.canInteract}
onClick={() =>
void mutate("heart", !desired.current.hearted)
}
>
{optimistic.hearted ? heartAvatar : <HeartIcon />}
</Button>
) : (
optimistic.hearted &&
!item.deleted &&
!item.hidden && (
heartAvatar
)
)}
</div>
</>
)}
{mode === "reply" && (
<CommentComposer
key="reply"
target={item.target}
replyTo={item}
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"))
}
>
<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>
<AccordionContent
keepMounted
panelClassName="data-open:overflow-visible"
className="[&_p]:mb-0"
>
{hasOpenedReplies && (
<div className="flex flex-col gap-3">
<CommentReplies
rootId={item.id}
focusReplyId={focusReplyId}
viewer={viewer}
onChange={onChange}
revision={revision}
moderationDetails={inbox || moderationDetails}
inbox={inbox}
/>
<div
className={cn(
"relative self-start before:absolute before:-left-8 before:top-0 before:h-5 before:w-8 before:rounded-bl-2xl before:border-b before:border-l before:border-border",
fullWidthMobile && "max-md:before:hidden",
)}
>
<Button
variant="ghost"
size="sm"
className="rounded-full"
onClick={() => {
setExpanded(false);
requestAnimationFrame(() =>
replyToggleRef.current?.focus(),
);
}}
>
<ChevronUpIcon data-icon="inline-start" />
Show less
</Button>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
</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>
</Container>
);
}
function CommentReplies({
rootId,
revision,
onChange,
focusReplyId,
moderationDetails = false,
inbox = false,
}: {
focusReplyId?: string;
rootId: string;
viewer: CommentViewer | null;
revision: number;
onChange: () => void | Promise<unknown>;
moderationDetails?: boolean;
inbox?: boolean;
}) {
const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`);
const { refresh, busy, error, page, more } = feed;
const previousRevision = useRef(revision);
useEffect(() => {
if (previousRevision.current !== revision) {
previousRevision.current = revision;
void refresh({ silent: true });
}
}, [revision, refresh]);
const focused = useRef<string | null>(null);
useEffect(() => {
if (!focusReplyId || focused.current === focusReplyId || busy || error)
return;
if (page.items.some((item) => item.id === focusReplyId)) {
focused.current = focusReplyId;
requestAnimationFrame(() => {
const reply = document.getElementById(`comment-${focusReplyId}`);
reply?.scrollIntoView({ block: "center" });
reply?.focus({ preventScroll: true });
});
} else if (page.nextCursor) void more();
}, [focusReplyId, busy, error, page, more]);
return (
<div id={`replies-${rootId}`} className="flex flex-col gap-3 pt-2">
{feed.error && (
<Alert variant="destructive">
<AlertDescription>{feed.error}</AlertDescription>
</Alert>
)}
<CommentReplyTree
focusReplyId={focusReplyId}
items={feed.page.items}
viewer={feed.page.viewer}
moderationDetails={moderationDetails}
inbox={inbox}
onChange={async () => {
await refresh({ silent: true });
await onChange();
}}
/>
{feed.busy && <CommentSkeleton count={2} />}
{feed.page.nextCursor && (
<Button
variant="outline"
disabled={feed.busy}
onClick={() => void feed.more()}
>
{"ดูคำตอบเพิ่มเติม"}
</Button>
)}
{feed.error && (
<Button
variant="outline"
disabled={feed.busy}
onClick={() => void refresh()}
>
{"ลองใหม่"}
</Button>
)}
</div>
);
}
function CommentReplyTree({
items,
viewer,
onChange,
focusReplyId,
moderationDetails = false,
inbox = false,
}: {
focusReplyId?: string;
items: CommentItem[];
viewer: CommentViewer | null;
onChange: () => void | Promise<unknown>;
moderationDetails?: boolean;
inbox?: boolean;
}) {
const loaded = new Set(items.map((item) => item.id));
const groups = new Map<string | null, CommentItem[]>();
for (const item of items) {
const parent =
item.replyToId &&
item.replyToId !== item.rootId &&
loaded.has(item.replyToId)
? item.replyToId
: null;
const siblings = groups.get(parent) ?? [];
siblings.push(item);
groups.set(parent, siblings);
}
function branch(parent: string | null): React.ReactNode {
const siblings = groups.get(parent) ?? [];
return siblings.map((reply, index) => (
<div
key={reply.id}
className={cn(
"relative flex min-w-0 flex-col gap-3 before:absolute before:-left-8 before:top-0 before:h-6 before:w-6 before:rounded-bl-2xl before:border-b before:border-l before:border-border",
parent !== null &&
index < siblings.length - 1 &&
"after:absolute after:-bottom-3 after:-left-8 after:top-0 after:border-l after:border-border",
moderationDetails && "max-md:before:hidden max-md:after:hidden",
moderationDetails && parent !== null && "before:hidden after:hidden",
)}
>
<div
className={cn(
!moderationDetails &&
groups.has(reply.id) &&
"relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border",
)}
>
<CommentCard
item={reply}
highlighted={reply.id === focusReplyId}
viewer={viewer}
moderationDetails={moderationDetails}
inboxReply={inbox}
replies={false}
onChange={onChange}
/>
</div>
{groups.has(reply.id) && (
<div
className={cn(
"ml-4 flex min-w-0 flex-col gap-3 pl-8",
moderationDetails && "ml-0 pl-0",
)}
>
{branch(reply.id)}
</div>
)}
</div>
));
}
return <div className="flex flex-col gap-4">{branch(null)}</div>;
}