292 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|