1107 lines
38 KiB
TypeScript
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>;
|
|
}
|