Files
erika/app/share/[id]/page.tsx
T
2026-08-16 21:12:56 +07:00

292 lines
10 KiB
TypeScript

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]/client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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";
import { getDiscordMemberProfile } from "@/lib/discord/discord";
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 authorProfile = await getDiscordMemberProfile(
share.authorDiscordId,
);
const authorAvatarUrl = authorProfile?.avatarUrl || share.authorAvatarUrl;
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 items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<Avatar size="lg">
{authorAvatarUrl && (
<AvatarImage src={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>
</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>ข้อความต้นฉบับ</CardTitle>
</div>
<CopyShareActions />
</div>
</CardHeader>
<CardContent className="p-0">
<pre
id="share-content"
className="max-h-[72vh] overflow-auto whitespace-pre-wrap wrap-break-word 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 wrap-break-word 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>
);
}