feat: add share pages comments and server actions

This commit is contained in:
2026-08-16 00:58:46 +07:00 Unverified
parent 5bf5d2a2c0
commit 9617d67c7c
14 changed files with 1157 additions and 108 deletions
+155
View File
@@ -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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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);
}
}
-65
View File
@@ -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);
}
}
+2 -1
View File
@@ -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";
-38
View File
@@ -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 (
<Dialog open>
<DialogContent
showCloseButton={false}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
</DialogTitle>
<DialogDescription className="mt-1 text-base">
ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
</DialogDescription>
</DialogHeader>
<Button
className="mt-2 w-full"
onClick={() =>
signIn("discord", { callbackUrl: window.location.href })
}
>
<svg
data-icon="inline-start"
viewBox="0 0 127.14 96.36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
</svg>
ดำเนินการต่อด้วย Discord
</Button>
</DialogContent>
</Dialog>
);
}
const LS_KEY = (formId: string) => `form-draft-${formId}`;
export function FormFillerClient({
+290
View File
@@ -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>
);
}
+223
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" onClick={copyText}>
<Clipboard data-icon="inline-start" />
คัดลอกข้อความ
</Button>
<Button type="button" variant="outline" size="sm" onClick={copyLink}>
<Link2 data-icon="inline-start" />
คัดลอกลิงก์
</Button>
</div>
);
}
export function CommentComposer({
shareId,
authenticated,
}: {
shareId: string;
authenticated: boolean;
}) {
const router = useRouter();
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
if (!authenticated) {
return (
<Button
type="button"
variant="outline"
onClick={() => signIn("discord", { callbackUrl: window.location.href })}
>
<LogIn data-icon="inline-start" />
เข้าสู่ระบบเพื่อแสดงความคิดเห็น
</Button>
);
}
return (
<form
onSubmit={(event) => {
event.preventDefault();
setError(null);
startTransition(async () => {
const result = await createCommentAction(shareId, body);
if (!result.success) {
setError(result.error);
return;
}
setBody("");
toast.success("เพิ่มความคิดเห็นแล้ว");
router.refresh();
});
}}
>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="comment-body">แสดงความคิดเห็น</FieldLabel>
<Textarea
id="comment-body"
value={body}
onChange={(event) => setBody(event.target.value)}
maxLength={MAX_SHARE_COMMENT_LENGTH}
aria-invalid={Boolean(error)}
placeholder="เขียนความคิดเห็นของคุณ…"
className="min-h-24 resize-y"
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<FieldDescription>
{body.length.toLocaleString("th-TH")} / 2,000
</FieldDescription>
<Button type="submit" size="sm" disabled={pending || !body.trim()}>
{pending ? <Spinner data-icon="inline-start" /> : <MessageSquare data-icon="inline-start" />}
ส่งความคิดเห็น
</Button>
</div>
{error && <FieldError>{error}</FieldError>}
</Field>
</form>
);
}
export function DeleteShareButton({ shareId }: { shareId: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
async function remove() {
setPending(true);
const result = await deleteShareAction(shareId);
if (!result.success) {
setPending(false);
toast.error(result.error);
return;
}
toast.success("ลบข้อความแชร์แล้ว");
router.push("/share");
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : <Trash2 data-icon="inline-start" />}
ลบข้อความนี้
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบข้อความแชร์นี้หรือไม่</AlertDialogTitle>
<AlertDialogDescription>
ข้อความ รูปประกอบ และความคิดเห็นทั้งหมดจะถูกลบถาวร
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={remove}>
ลบถาวร
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function DeleteCommentButton({
shareId,
commentId,
}: {
shareId: string;
commentId: string;
}) {
const router = useRouter();
const [pending, setPending] = useState(false);
async function remove() {
setPending(true);
const result = await deleteCommentAction(shareId, commentId);
setPending(false);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success("ลบความคิดเห็นแล้ว");
router.refresh();
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={pending}
aria-label="ลบความคิดเห็น"
>
{pending ? <Spinner /> : <Trash2 />}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบความคิดเห็นนี้หรือไม่</AlertDialogTitle>
<AlertDialogDescription>
การลบความคิดเห็นไม่สามารถย้อนกลับได้
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={remove}>
ลบความคิดเห็น
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
export const metadata: Metadata = {
title: "Share | Erika!",
description: "แชร์ข้อความและไฟล์ .txt ผ่านลิงก์สั้น",
};
export default function ShareLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-10 border-b bg-background/90 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon-sm" asChild>
<Link href="/" aria-label="กลับหน้าหลัก">
<ArrowLeft />
</Link>
</Button>
<Link href="/share" className="font-mono text-sm font-semibold">
Erika / Share
</Link>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/share">
<Plus data-icon="inline-start" />
สร้างลิงก์
</Link>
</Button>
<ThemeToggle />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-10">
{children}
</main>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { getServerSession } from "next-auth";
import { LoginDialog } from "@/components/login-dialog";
import { authOptions } from "@/lib/auth/auth-options";
import { getSessionDiscordId } from "@/lib/auth/auth";
import { ShareUploadClient } from "@/app/share/upload-client";
export const dynamic = "force-dynamic";
export default async function SharePage() {
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
if (!discordId) {
return (
<LoginDialog
title="เข้าสู่ระบบก่อนแชร์ข้อความ"
description="ใช้บัญชี Discord เพื่อยืนยันผู้เผยแพร่ก่อนสร้างลิงก์แชร์"
/>
);
}
return (
<div className="flex flex-col gap-6">
<div className="max-w-2xl">
<p className="mb-2 font-mono text-xs uppercase tracking-[0.18em] text-muted-foreground">
New public paste
</p>
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
วางข้อความ แล้วส่งลิงก์ต่อ
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground sm:text-base">
เนื้อหาหลักจะถูกเก็บตามที่พิมพ์ทุกตัวอักษร คนที่มีลิงก์สามารถเปิดอ่านได้โดยไม่ต้องเข้าสู่ระบบ
</p>
</div>
<ShareUploadClient />
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FileText, ImagePlus, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { createShareAction } from "@/app/actions/share";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
MAX_SHARE_DESCRIPTION_LENGTH,
MAX_SHARE_IMAGE_BYTES,
MAX_SHARE_TEXT_BYTES,
} from "@/lib/share/limits";
const IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
}
export function ShareUploadClient() {
const router = useRouter();
const textFileRef = useRef<HTMLInputElement>(null);
const imageFileRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [description, setDescription] = useState("");
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const byteCount = useMemo(() => new TextEncoder().encode(text).byteLength, [text]);
useEffect(() => {
if (!imagePreview) return;
return () => URL.revokeObjectURL(imagePreview);
}, [imagePreview]);
async function loadTextFile(file: File) {
setError(null);
if (!file.name.toLowerCase().endsWith(".txt")) {
setError("เลือกไฟล์ .txt เท่านั้น");
return;
}
if (file.size > MAX_SHARE_TEXT_BYTES) {
setError("ไฟล์ข้อความต้องมีขนาดไม่เกิน 5 MiB");
return;
}
try {
const content = new TextDecoder("utf-8", { fatal: true }).decode(
await file.arrayBuffer()
);
setText(content);
toast.success(`เปิด ${file.name} แล้ว`);
} catch {
setError("ไฟล์ต้องเป็นข้อความ UTF-8 ที่ถูกต้อง");
}
}
function chooseImage(file: File | undefined) {
setError(null);
if (!file) return;
if (!IMAGE_TYPES.split(",").includes(file.type)) {
setError("รูปภาพต้องเป็น PNG, JPEG, WebP หรือ GIF");
return;
}
if (file.size > MAX_SHARE_IMAGE_BYTES) {
setError("รูปภาพต้องมีขนาดไม่เกิน 50 MiB");
return;
}
setImage(file);
setImagePreview(URL.createObjectURL(file));
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!text.trim()) {
setError("วางข้อความก่อนสร้างลิงก์");
return;
}
if (byteCount > MAX_SHARE_TEXT_BYTES) {
setError("ข้อความต้องมีขนาดไม่เกิน 5 MiB");
return;
}
setPending(true);
const formData = new FormData();
formData.set("text", text);
formData.set("description", description);
if (image) formData.set("image", image);
try {
const result = await createShareAction(formData);
if (!result.success || !result.id) {
setError(result.success ? "สร้างลิงก์ไม่สำเร็จ" : result.error);
return;
}
router.push(`/share/${result.id}`);
} catch {
setError("เชื่อมต่อเซิร์ฟเวอร์ไม่สำเร็จ ลองใหม่อีกครั้ง");
} finally {
setPending(false);
}
}
return (
<form onSubmit={submit}>
<Card className="overflow-hidden">
<CardHeader className="border-b">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<CardTitle className="font-mono text-base">share://new</CardTitle>
<CardDescription>
วางข้อความหรือเปิดไฟล์ .txt แล้วรับลิงก์สำหรับส่งต่อ
</CardDescription>
</div>
<div className="font-mono text-xs text-muted-foreground">
{formatBytes(byteCount)} / 5 MiB
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<FieldGroup className="gap-0">
<Field
data-invalid={Boolean(error)}
className={cn(
"relative gap-0 border-b transition-colors",
dragging && "bg-muted/50"
)}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
setDragging(false);
}
}}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files[0];
if (file) void loadTextFile(file);
}}
>
<FieldLabel htmlFor="share-text" className="sr-only">
ข้อความที่ต้องการแชร์
</FieldLabel>
<Textarea
id="share-text"
value={text}
onChange={(event) => setText(event.target.value)}
aria-invalid={Boolean(error)}
placeholder="วางข้อความของคุณที่นี่…"
className="min-h-[52vh] resize-y rounded-none border-0 bg-transparent p-5 font-mono text-sm leading-6 shadow-none focus-visible:ring-0 sm:p-7"
spellCheck={false}
/>
<div className="flex flex-wrap items-center justify-between gap-3 border-t bg-muted/20 px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => textFileRef.current?.click()}
>
<FileText data-icon="inline-start" />
เปิดไฟล์ .txt
</Button>
<span className="text-xs text-muted-foreground">
หรือลากไฟล์มาวางในช่องข้อความ
</span>
</div>
<span className="font-mono text-xs text-muted-foreground">
{text.split("\n").length.toLocaleString("th-TH")} บรรทัด
</span>
</div>
<Input
ref={textFileRef}
type="file"
accept=".txt,text/plain"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void loadTextFile(file);
event.target.value = "";
}}
/>
</Field>
<div className="p-5 sm:p-7">
<FieldGroup>
<Field>
<FieldLabel htmlFor="share-description">
คำอธิบาย <span className="font-normal text-muted-foreground">(ไม่บังคับ)</span>
</FieldLabel>
<Textarea
id="share-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={MAX_SHARE_DESCRIPTION_LENGTH}
placeholder="บอกคนรับลิงก์ว่าข้อความนี้เกี่ยวกับอะไร"
className="min-h-24 resize-y"
/>
<FieldDescription className="text-right">
{description.length.toLocaleString("th-TH")} / 5,000
</FieldDescription>
</Field>
<Field>
<FieldLabel>รูปประกอบ (ไม่บังคับ)</FieldLabel>
{imagePreview ? (
<div className="overflow-hidden rounded-lg border">
<div className="relative aspect-video bg-muted/30">
<Image
src={imagePreview}
alt="ตัวอย่างรูปประกอบ"
fill
unoptimized
className="object-contain"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-3 py-2">
<span className="truncate text-xs text-muted-foreground">
{image?.name} · {formatBytes(image?.size ?? 0)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
>
<X data-icon="inline-start" />
ลบรูป
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() => imageFileRef.current?.click()}
>
<ImagePlus data-icon="inline-start" />
เพิ่มรูปภาพ
</Button>
)}
<Input
ref={imageFileRef}
type="file"
accept={IMAGE_TYPES}
className="hidden"
onChange={(event) => {
chooseImage(event.target.files?.[0]);
event.target.value = "";
}}
/>
<FieldDescription>
PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 50 MiB
</FieldDescription>
</Field>
{error && (
<Alert variant="destructive">
<AlertTitle>ยังสร้างลิงก์ไม่ได้</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<FieldError className="sr-only">{error}</FieldError>
<Button type="submit" size="lg" disabled={pending || !text.trim()}>
{pending ? <Spinner data-icon="inline-start" /> : <Upload data-icon="inline-start" />}
อัปโหลดและสร้างลิงก์
</Button>
</FieldGroup>
</div>
</FieldGroup>
</CardContent>
</Card>
</form>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { signIn } from "next-auth/react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function LoginDialog({
title = "เข้าสู่ระบบก่อนกรอกแบบฟอร์ม",
description = "ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน",
}: {
title?: string;
description?: string;
}) {
return (
<Dialog open>
<DialogContent
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="text-xl font-bold">{title}</DialogTitle>
<DialogDescription className="mt-1 text-base">
{description}
</DialogDescription>
</DialogHeader>
<Button
className="mt-2 w-full"
onClick={() =>
signIn("discord", { callbackUrl: window.location.href })
}
>
<svg
data-icon="inline-start"
viewBox="0 0 127.14 96.36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
</svg>
ดำเนินการต่อด้วย Discord
</Button>
</DialogContent>
</Dialog>
);
}
+32
View File
@@ -0,0 +1,32 @@
import "server-only";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { getSessionDiscordId, type SessionWithDiscord } from "@/lib/auth/auth";
import { authOptions } from "@/lib/auth/auth-options";
import { ShareHttpError } from "@/lib/share/http-error";
export async function requireShareAuthor() {
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 [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);
return {
userId: user.id,
discordId,
displayName: user.name || session?.user?.name || "Discord user",
avatarUrl: user.image || session?.user?.image || null,
};
}
+4
View File
@@ -0,0 +1,4 @@
export const MAX_SHARE_TEXT_BYTES = 5 * 1024 * 1024;
export const MAX_SHARE_DESCRIPTION_LENGTH = 5_000;
export const MAX_SHARE_COMMENT_LENGTH = 2_000;
export const MAX_SHARE_IMAGE_BYTES = 50 * 1024 * 1024;
+5 -4
View File
@@ -1,9 +1,10 @@
import { validateImageUploadFile } from "@/lib/cdn-images";
import { ShareHttpError } from "@/lib/share/http-error";
export const MAX_SHARE_TEXT_BYTES = 5 * 1024 * 1024;
export const MAX_SHARE_DESCRIPTION_LENGTH = 5_000;
export const MAX_SHARE_COMMENT_LENGTH = 2_000;
import {
MAX_SHARE_COMMENT_LENGTH,
MAX_SHARE_DESCRIPTION_LENGTH,
MAX_SHARE_TEXT_BYTES,
} from "@/lib/share/limits";
export type ValidatedImage = {
bytes: Buffer;
+5
View File
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
experimental: {
serverActions: {
bodySizeLimit: "60mb",
},
},
turbopack: {
root: __dirname,
},