feat: add share pages comments and server actions
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { asc, count, desc, eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { db } from "@/db";
|
||||
import { shareComments, shareTexts } from "@/db/schema";
|
||||
import {
|
||||
CommentComposer,
|
||||
CopyShareActions,
|
||||
DeleteCommentButton,
|
||||
DeleteShareButton,
|
||||
} from "@/app/share/[id]/share-client";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { authOptions } from "@/lib/auth/auth-options";
|
||||
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth/auth";
|
||||
import { cdnUrl } from "@/lib/cdn-images";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const COMMENTS_PER_PAGE = 50;
|
||||
|
||||
function formatBangkok(date: Date) {
|
||||
return new Intl.DateTimeFormat("th-TH", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Asia/Bangkok",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.trim().slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function commentsHref(id: string, sort: "oldest" | "newest", page: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (sort === "newest") params.set("comments", "newest");
|
||||
if (page > 1) params.set("page", String(page));
|
||||
const query = params.toString();
|
||||
return `/share/${id}${query ? `?${query}` : ""}#comments`;
|
||||
}
|
||||
|
||||
export default async function SharedTextPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{
|
||||
comments?: string | string[];
|
||||
page?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const [{ id }, query, session] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
if (!/^[a-z0-9]{8}$/.test(id)) notFound();
|
||||
|
||||
const [share] = await db
|
||||
.select()
|
||||
.from(shareTexts)
|
||||
.where(eq(shareTexts.id, id))
|
||||
.limit(1);
|
||||
if (!share) notFound();
|
||||
|
||||
const requestedSort = Array.isArray(query.comments)
|
||||
? query.comments[0]
|
||||
: query.comments;
|
||||
const sort = requestedSort === "newest" ? "newest" : "oldest";
|
||||
const requestedPage = Number(
|
||||
Array.isArray(query.page) ? query.page[0] : query.page
|
||||
);
|
||||
const [{ total }] = await db
|
||||
.select({ total: count() })
|
||||
.from(shareComments)
|
||||
.where(eq(shareComments.shareId, id));
|
||||
const totalPages = Math.max(1, Math.ceil(total / COMMENTS_PER_PAGE));
|
||||
const page = Math.min(
|
||||
totalPages,
|
||||
Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1
|
||||
);
|
||||
const direction = sort === "newest" ? desc : asc;
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(shareComments)
|
||||
.where(eq(shareComments.shareId, id))
|
||||
.orderBy(direction(shareComments.createdAt), direction(shareComments.id))
|
||||
.limit(COMMENTS_PER_PAGE)
|
||||
.offset((page - 1) * COMMENTS_PER_PAGE);
|
||||
|
||||
const discordId = getSessionDiscordId(session);
|
||||
const isAdmin = Boolean(
|
||||
discordId && getAdminDiscordIds().includes(discordId)
|
||||
);
|
||||
const canDeleteShare = Boolean(
|
||||
discordId && (isAdmin || discordId === share.authorDiscordId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar size="lg">
|
||||
{share.authorAvatarUrl && (
|
||||
<AvatarImage src={share.authorAvatarUrl} alt={share.authorName} />
|
||||
)}
|
||||
<AvatarFallback>{initials(share.authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{share.authorName}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{formatBangkok(share.createdAt)} น. · เวลาไทย
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{share.source === "bot" ? "Discord bot" : "Web"} · {share.id}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{(share.description || share.imageCdnId) && (
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{share.description && (
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">
|
||||
{share.description}
|
||||
</p>
|
||||
)}
|
||||
{share.imageCdnId && (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border bg-muted/20">
|
||||
<Image
|
||||
src={cdnUrl(share.imageCdnId)}
|
||||
alt="รูปประกอบข้อความแชร์"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="font-mono text-base">share://{share.id}</CardTitle>
|
||||
<CardDescription>ข้อความต้นฉบับ</CardDescription>
|
||||
</div>
|
||||
<CopyShareActions />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre
|
||||
id="share-content"
|
||||
className="max-h-[72vh] overflow-auto whitespace-pre-wrap break-words p-5 font-mono text-sm leading-6 sm:p-7"
|
||||
>{share.content}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canDeleteShare && (
|
||||
<div className="flex justify-end">
|
||||
<DeleteShareButton shareId={share.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card id="comments">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>ความคิดเห็น</CardTitle>
|
||||
<CardDescription>{total.toLocaleString("th-TH")} ความคิดเห็น</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant={sort === "oldest" ? "secondary" : "ghost"} size="sm" asChild>
|
||||
<Link href={commentsHref(id, "oldest", 1)}>เก่าสุด</Link>
|
||||
</Button>
|
||||
<Button variant={sort === "newest" ? "secondary" : "ghost"} size="sm" asChild>
|
||||
<Link href={commentsHref(id, "newest", 1)}>ใหม่สุด</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<CommentComposer shareId={share.id} authenticated={Boolean(discordId)} />
|
||||
<Separator />
|
||||
{comments.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
ยังไม่มีความคิดเห็น เริ่มบทสนทนาได้เลย
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
{comments.map((comment, index) => {
|
||||
const canDeleteComment = Boolean(
|
||||
discordId &&
|
||||
(isAdmin || discordId === comment.authorDiscordId)
|
||||
);
|
||||
return (
|
||||
<div key={comment.id} className="flex flex-col gap-5">
|
||||
{index > 0 && <Separator />}
|
||||
<article className="flex items-start gap-3">
|
||||
<Avatar>
|
||||
{comment.authorAvatarUrl && (
|
||||
<AvatarImage
|
||||
src={comment.authorAvatarUrl}
|
||||
alt={comment.authorName}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback>{initials(comment.authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{comment.authorName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBangkok(comment.createdAt)} น.
|
||||
</p>
|
||||
</div>
|
||||
{canDeleteComment && (
|
||||
<DeleteCommentButton
|
||||
shareId={share.id}
|
||||
commentId={comment.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={commentsHref(id, sort, Math.max(1, page - 1))}
|
||||
text="ก่อนหน้า"
|
||||
aria-disabled={page === 1}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<span className="px-3 text-sm text-muted-foreground">
|
||||
{page.toLocaleString("th-TH")} / {totalPages.toLocaleString("th-TH")}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={commentsHref(id, sort, Math.min(totalPages, page + 1))}
|
||||
text="ถัดไป"
|
||||
aria-disabled={page === totalPages}
|
||||
className={page === totalPages ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user