From 9617d67c7cb8a910bbba61fcec9c6722fb1eb6bc Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 16 Aug 2026 00:58:46 +0700 Subject: [PATCH] feat: add share pages comments and server actions --- app/actions/share.ts | 155 ++++++++++++++++ app/api/share/web/route.ts | 65 ------- app/form/[id]/page.tsx | 3 +- app/form/client.tsx | 38 ---- app/share/[id]/page.tsx | 290 ++++++++++++++++++++++++++++++ app/share/[id]/share-client.tsx | 223 +++++++++++++++++++++++ app/share/layout.tsx | 43 +++++ app/share/page.tsx | 38 ++++ app/share/upload-client.tsx | 308 ++++++++++++++++++++++++++++++++ components/login-dialog.tsx | 52 ++++++ lib/share/current-author.ts | 32 ++++ lib/share/limits.ts | 4 + lib/share/validation.ts | 9 +- next.config.ts | 5 + 14 files changed, 1157 insertions(+), 108 deletions(-) create mode 100644 app/actions/share.ts delete mode 100644 app/api/share/web/route.ts create mode 100644 app/share/[id]/page.tsx create mode 100644 app/share/[id]/share-client.tsx create mode 100644 app/share/layout.tsx create mode 100644 app/share/page.tsx create mode 100644 app/share/upload-client.tsx create mode 100644 components/login-dialog.tsx create mode 100644 lib/share/current-author.ts create mode 100644 lib/share/limits.ts diff --git a/app/actions/share.ts b/app/actions/share.ts new file mode 100644 index 0000000..eed8f39 --- /dev/null +++ b/app/actions/share.ts @@ -0,0 +1,155 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { cdn, shareComments, shareTexts } from "@/db/schema"; +import { getAdminDiscordIds } from "@/lib/auth/auth"; +import { createShare } from "@/lib/share/create"; +import { requireShareAuthor } from "@/lib/share/current-author"; +import { ShareHttpError } from "@/lib/share/http-error"; +import { enforceShareRateLimit } from "@/lib/share/rate-limit"; +import { + validateOptionalImage, + validateShareComment, + validateShareDescription, + validateShareText, +} from "@/lib/share/validation"; + +type ActionResult = + | { success: true; id?: string } + | { success: false; error: string; status: number; retryAfter?: number }; + +function actionError(error: unknown): ActionResult { + if (error instanceof ShareHttpError) { + return { + success: false, + error: error.message, + status: error.status, + ...(error.retryAfter ? { retryAfter: error.retryAfter } : {}), + }; + } + console.error("Share action failed", error); + return { success: false, error: "Internal server error", status: 500 }; +} + +export async function createShareAction(formData: FormData): Promise { + try { + const author = await requireShareAuthor(); + const content = validateShareText(formData.get("text")); + const description = validateShareDescription(formData.get("description")); + const image = await validateOptionalImage(formData.get("image")); + + await enforceShareRateLimit({ + key: `sender:${author.discordId}`, + limit: 10, + windowSeconds: 60 * 60, + }); + + const share = await createShare({ + content, + description, + image, + source: "web", + author, + }); + return { success: true, id: share.id }; + } catch (error) { + return actionError(error); + } +} + +export async function createCommentAction( + shareId: string, + body: string +): Promise { + try { + const author = await requireShareAuthor(); + const comment = validateShareComment(body); + const [share] = await db + .select({ id: shareTexts.id }) + .from(shareTexts) + .where(eq(shareTexts.id, shareId)) + .limit(1); + if (!share) throw new ShareHttpError("Share not found", 404); + + await enforceShareRateLimit({ + key: `comment:${author.discordId}`, + limit: 30, + windowSeconds: 60 * 60, + }); + + await db.insert(shareComments).values({ + shareId, + body: comment, + authorId: author.userId, + authorDiscordId: author.discordId, + authorName: author.displayName, + authorAvatarUrl: author.avatarUrl, + }); + revalidatePath(`/share/${shareId}`); + return { success: true }; + } catch (error) { + return actionError(error); + } +} + +export async function deleteShareAction(shareId: string): Promise { + try { + const author = await requireShareAuthor(); + const [share] = await db + .select({ + id: shareTexts.id, + authorDiscordId: shareTexts.authorDiscordId, + imageCdnId: shareTexts.imageCdnId, + }) + .from(shareTexts) + .where(eq(shareTexts.id, shareId)) + .limit(1); + if (!share) throw new ShareHttpError("Share not found", 404); + const isAdmin = getAdminDiscordIds().includes(author.discordId); + if (share.authorDiscordId !== author.discordId && !isAdmin) { + throw new ShareHttpError("Forbidden", 403); + } + + await db.transaction(async (tx) => { + await tx.delete(shareTexts).where(eq(shareTexts.id, shareId)); + if (share.imageCdnId) { + await tx.delete(cdn).where(eq(cdn.id, share.imageCdnId)); + } + }); + revalidatePath(`/share/${shareId}`); + return { success: true }; + } catch (error) { + return actionError(error); + } +} + +export async function deleteCommentAction( + shareId: string, + commentId: string +): Promise { + try { + const author = await requireShareAuthor(); + const isAdmin = getAdminDiscordIds().includes(author.discordId); + const deleted = await db + .delete(shareComments) + .where( + and( + eq(shareComments.id, commentId), + eq(shareComments.shareId, shareId), + isAdmin + ? eq(shareComments.shareId, shareId) + : eq(shareComments.authorDiscordId, author.discordId) + ) + ) + .returning({ id: shareComments.id }); + if (deleted.length === 0) { + throw new ShareHttpError("Comment not found or forbidden", 404); + } + revalidatePath(`/share/${shareId}`); + return { success: true }; + } catch (error) { + return actionError(error); + } +} diff --git a/app/api/share/web/route.ts b/app/api/share/web/route.ts deleted file mode 100644 index 442b6a2..0000000 --- a/app/api/share/web/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { getServerSession } from "next-auth"; -import { eq } from "drizzle-orm"; -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { authOptions } from "@/lib/auth/auth-options"; -import { getSessionDiscordId, type SessionWithDiscord } from "@/lib/auth/auth"; -import { createShare } from "@/lib/share/create"; -import { ShareHttpError, shareErrorResponse } from "@/lib/share/http-error"; -import { enforceShareRateLimit } from "@/lib/share/rate-limit"; -import { - validateOptionalImage, - validateShareDescription, - validateShareText, -} from "@/lib/share/validation"; - -export const dynamic = "force-dynamic"; - -export async function POST(request: Request) { - try { - const session = await getServerSession(authOptions); - const discordId = getSessionDiscordId(session); - const userId = (session as SessionWithDiscord | null)?.user?.id; - if (!discordId || !userId) { - throw new ShareHttpError("Unauthorized", 401); - } - - const formData = await request.formData(); - const content = validateShareText(formData.get("text")); - const description = validateShareDescription(formData.get("description")); - const image = await validateOptionalImage(formData.get("image")); - - await enforceShareRateLimit({ - key: `sender:${discordId}`, - limit: 10, - windowSeconds: 60 * 60, - }); - - const [user] = await db - .select({ id: users.id, name: users.name, image: users.image }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - if (!user) throw new ShareHttpError("Unauthorized", 401); - - const share = await createShare({ - content, - description, - image, - source: "web", - author: { - userId: user.id, - discordId, - displayName: user.name || session?.user?.name || "Discord user", - avatarUrl: user.image || session?.user?.image || null, - }, - }); - - return Response.json( - { success: true, share: { id: share.id, url: `/share/${share.id}` } }, - { status: 201 } - ); - } catch (error) { - return shareErrorResponse(error); - } -} diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx index 83e0ddc..e660fbd 100644 --- a/app/form/[id]/page.tsx +++ b/app/form/[id]/page.tsx @@ -1,5 +1,6 @@ import { db } from "@/db"; -import { FormFillerClient, LoginDialog } from "../client"; +import { FormFillerClient } from "../client"; +import { LoginDialog } from "@/components/login-dialog"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth/auth-options"; import { notFound } from "next/navigation"; diff --git a/app/form/client.tsx b/app/form/client.tsx index 87e1d08..41349e6 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -23,8 +23,6 @@ import { FormattedText } from "@/components/formatted-text"; import { Dialog, DialogContent, - DialogDescription, - DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; @@ -116,42 +114,6 @@ export function LoginButton() { ); } -export function LoginDialog() { - return ( - - event.preventDefault()} - > - - - เข้าสู่ระบบก่อนกรอกแบบฟอร์ม - - - ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน - - - - - - ); -} - const LS_KEY = (formId: string) => `form-draft-${formId}`; export function FormFillerClient({ diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx new file mode 100644 index 0000000..bde5b57 --- /dev/null +++ b/app/share/[id]/page.tsx @@ -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 ( +
+ + +
+
+ + {share.authorAvatarUrl && ( + + )} + {initials(share.authorName)} + +
+ + {share.authorName} + + + {formatBangkok(share.createdAt)} น. · เวลาไทย + +
+
+ + {share.source === "bot" ? "Discord bot" : "Web"} · {share.id} + +
+
+ {(share.description || share.imageCdnId) && ( + + {share.description && ( +

+ {share.description} +

+ )} + {share.imageCdnId && ( +
+ รูปประกอบข้อความแชร์ +
+ )} +
+ )} +
+ + + +
+
+ share://{share.id} + ข้อความต้นฉบับ +
+ +
+
+ +
{share.content}
+
+
+ + {canDeleteShare && ( +
+ +
+ )} + + + +
+
+ ความคิดเห็น + {total.toLocaleString("th-TH")} ความคิดเห็น +
+
+ + +
+
+
+ + + + {comments.length === 0 ? ( +

+ ยังไม่มีความคิดเห็น เริ่มบทสนทนาได้เลย +

+ ) : ( +
+ {comments.map((comment, index) => { + const canDeleteComment = Boolean( + discordId && + (isAdmin || discordId === comment.authorDiscordId) + ); + return ( +
+ {index > 0 && } +
+ + {comment.authorAvatarUrl && ( + + )} + {initials(comment.authorName)} + +
+
+
+

{comment.authorName}

+

+ {formatBangkok(comment.createdAt)} น. +

+
+ {canDeleteComment && ( + + )} +
+

+ {comment.body} +

+
+
+
+ ); + })} +
+ )} + + {totalPages > 1 && ( + + + + + + + + {page.toLocaleString("th-TH")} / {totalPages.toLocaleString("th-TH")} + + + + + + + + )} +
+
+
+ ); +} diff --git a/app/share/[id]/share-client.tsx b/app/share/[id]/share-client.tsx new file mode 100644 index 0000000..9b4da34 --- /dev/null +++ b/app/share/[id]/share-client.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { Clipboard, Link2, LogIn, MessageSquare, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { + createCommentAction, + deleteCommentAction, + deleteShareAction, +} from "@/app/actions/share"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, +} from "@/components/ui/field"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { MAX_SHARE_COMMENT_LENGTH } from "@/lib/share/limits"; + +export function CopyShareActions() { + async function copyText() { + const content = document.getElementById("share-content")?.textContent; + if (content === undefined) return; + await navigator.clipboard.writeText(content); + toast.success("คัดลอกข้อความแล้ว"); + } + + async function copyLink() { + await navigator.clipboard.writeText(window.location.href); + toast.success("คัดลอกลิงก์แล้ว"); + } + + return ( +
+ + +
+ ); +} + +export function CommentComposer({ + shareId, + authenticated, +}: { + shareId: string; + authenticated: boolean; +}) { + const router = useRouter(); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + if (!authenticated) { + return ( + + ); + } + + return ( +
{ + event.preventDefault(); + setError(null); + startTransition(async () => { + const result = await createCommentAction(shareId, body); + if (!result.success) { + setError(result.error); + return; + } + setBody(""); + toast.success("เพิ่มความคิดเห็นแล้ว"); + router.refresh(); + }); + }} + > + + แสดงความคิดเห็น +