feat: add share pages comments and server actions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "60mb",
|
||||
},
|
||||
},
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user