This commit is contained in:
superdinmc
2025-11-14 17:23:58 +07:00 Unverified
parent 6c0fda2b86
commit 27b8336295
8 changed files with 301 additions and 21 deletions
+83
View File
@@ -0,0 +1,83 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { uidRegex } from "@/lib/const";
import { db } from "@/lib/db";
import {
characters,
endgameSettings,
endgameSubmissions,
settings,
submissions,
} from "@/lib/db/schema";
import { sse } from "@/lib/utils";
import type { TypedFormData } from "./type";
export async function getEndgameConfig() {
const [ngm]: (typeof endgameSettings.$inferSelect | undefined)[] = await db
.select()
.from(endgameSettings)
.limit(1);
const [glob]: (typeof settings.$inferSelect | undefined)[] = await db
.select()
.from(settings)
.limit(1);
const count = await db.$count(endgameSubmissions);
const full = ngm?.limit !== -1 && count >= (ngm?.limit || 0);
return {
locked: false,
limit: -1,
enka: false,
count,
full,
...ngm,
...glob,
};
}
export async function submitEndgame(
formData: TypedFormData<{
name: string;
uid: string;
character: string;
comment: string;
}>,
) {
const { full, locked, limit } = await getEndgameConfig();
const name = formData.get("name")?.toString();
const uid = formData.get("uid")?.toString();
const character = formData.get("character")?.toString();
const comment = formData.get("comment")?.toString() || "";
if (!name || !uid || !character) return "กรุณากรอกข้อมูลให้ครบถ้วน";
if (name.length > 32) return "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร";
if (!uidRegex.test(uid)) return "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หลัก";
if (comment.length > 256) return "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 512 ตัวอักษร";
if (locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก";
if (full) return `คิวลงทะเบียนเต็มแล้ว (${limit} ครั้ง)`;
const [char] = await db
.select()
.from(characters)
.where(eq(characters.name, character))
.limit(1);
if (!char) return "ไม่พบตัวละครที่เลือก";
const [existing] = await db
.select()
.from(submissions)
.where(eq(submissions.uid, uid))
.limit(1);
if (existing) return "คุณลงทะเบียนไปแล้ว";
const [queue] = await db
.insert(submissions)
.values({
uid,
name,
comment,
char: char.name,
})
.returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id });
revalidatePath("/artifact/admin");
sse.publish({}, { topic: "artifact-ev", event: "update" });
return queue;
}
+1 -1
View File
@@ -2,4 +2,4 @@
export function EndgameForm() {
return <form></form>;
}
}
+169 -7
View File
@@ -1,14 +1,176 @@
import { eq } from "drizzle-orm";
import { BookAlert, CircleDollarSign, SendHorizonal } from "lucide-react";
import { cookies } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { PageTransition } from "@/app/transition";
import { Blocker } from "@/components/blocker";
import { EndgameForm } from "./form";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { db } from "@/lib/db";
import { endgameSubmissions } from "@/lib/db/schema";
import { ClearCookie, Disclaimer } from "../artifact/client";
import { ArtifactFormWrapper } from "../artifact/form";
import { LiveButton } from "../artifact/live";
import { RulesDialog } from "../artifact/rules";
import { getEndgameConfig } from "./api";
import banner from "#/logos/artifact.webp";
export default async function EndgamePage() {
const cookie = await cookies();
const sid = cookie.get("sid");
const [q] = sid?.value
? await db
.select({ queue: endgameSubmissions.queue })
.from(endgameSubmissions)
.where(eq(endgameSubmissions.id, sid.value))
: [];
const { count, ...config } = await getEndgameConfig();
return (
<div className="flex h-full w-full justify-center items-center">
<div className="rounded flex flex-col bg-card border p-2">
<Blocker>ขณะนี้คิวเต็มแล้ว</Blocker>
<h1>รับลงเอนเกคอนเท้น</h1>
<EndgameForm />
<PageTransition>
<div className="flex flex-col justify-around items-center h-svh">
<Card className="w-full max-w-md">
{q ? (
<Blocker>
<div className="flex gap-1 flex-col items-center">
<span className="font-bold text-3xl">คิวของคุณคือหมายเลข</span>
<span className="font-bold text-5xl">{q.queue}</span>
</div>
</Blocker>
) : config.locked ? (
<Blocker>
<span className="font-bold text-3xl">ยังไม่เปิดรับคิว</span>
</Blocker>
) : config.limit >= 0 && count >= config.limit ? (
<Blocker>
<div className="flex gap-1 flex-col items-center">
<span className="font-bold text-3xl">คิวเต็มแล้ว</span>
<span className="font-bold text-2xl">ต้องโดเนทลัดคิวแล้วล่ะ</span>
</div>
</Blocker>
) : (
<Disclaimer />
)}
<CardHeader className="justify-center">
<CardTitle>
<div className="w-[276.5px]">
<Link href="/">
<Image
style={{
transform: "translateY(-70%)",
}}
className="absolute z-50"
height={137.5}
width={276.5}
src={banner}
alt="เสือกไอดีชาวบ้าน"
/>
</Link>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<ArtifactFormWrapper id="mainform">
<div className="flex flex-col gap-3">
<div className="grid gap-2">
<Label htmlFor="name">ชื่อ*</Label>
<Input
id="name"
name="name"
type="text"
placeholder="Mr.Buzz"
autoComplete="name"
maxLength={32}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="comment">ข้อความเพิ่มเติม</Label>
<Textarea
id="comment"
name="comment"
placeholder="เช่น Er พอไหมครับ, คริสวยยังครับ (ไม่บังคับ)"
className="bg-card!"
maxLength={512}
/>
</div>
</div>
</ArtifactFormWrapper>
</CardContent>
<CardFooter className="flex gap-2 justify-between">
<div className="flex gap-2">
<SimpleTooltip text="โดเนทลัดคิว ขั้นต่ำ 10 บาท">
<Link
href="https://tipme.in.th/536d969652666273c2fa85ad"
target="_blank"
rel="noreferrer"
>
<Button
variant="outline"
className={
config.limit >= 0 && count >= config.limit
? "bg-emerald-600! border-white! animate-pulse"
: ""
}
type="button"
>
<CircleDollarSign />
</Button>
</Link>
</SimpleTooltip>
<Tooltip>
<RulesDialog>
<TooltipTrigger asChild>
<Button variant="destructive" type="button">
<BookAlert />
</Button>
</TooltipTrigger>
</RulesDialog>
<TooltipContent>อ่านกฏการลงคิว</TooltipContent>
</Tooltip>
<Suspense>
<LiveButton />
</Suspense>
</div>
<div className="flex gap-2 items-center">
<span className="p-1">
{count} / {config.limit < 0 ? "∞" : config.limit}
</span>
<SimpleTooltip text="ส่งเลยจัฟลูกพี่">
<Button
type="submit"
form="mainform"
disabled={
!!q ||
config.locked ||
(config.limit >= 0 && count >= config.limit)
}
>
<SendHorizonal />
</Button>
</SimpleTooltip>
</div>
</CardFooter>
</Card>
{sid && !q && <ClearCookie />}
</div>
</div>
</PageTransition>
);
}
+29
View File
@@ -0,0 +1,29 @@
export type TypedFormDataShape = Record<
string,
string | Blob | File | (string | Blob | File)[]
>;
export interface TypedFormData<T extends TypedFormDataShape> {
append<K extends keyof T>(
key: K,
value: T[K] extends unknown[] ? T[K][number] : T[K],
): void;
set<K extends keyof T>(
key: K,
value: T[K] extends unknown[] ? T[K][number] : T[K],
): void;
get<K extends keyof T>(
key: K,
): T[K] extends unknown[] ? T[K][number] | null : T[K] | null;
getAll<K extends keyof T>(key: K): T[K] extends unknown[] ? T[K] : T[K][];
delete<K extends keyof T>(key: K): void;
has<K extends keyof T>(key: K): boolean;
/** underlying raw FormData */
readonly raw: FormData;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: ["./lib/db/schema.ts", "./lib/db/references.ts"],
schemaFilter: ["public", "artifact", "tierlist"],
schemaFilter: ["public", "artifact", "tierlist", "endgame"],
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
+11 -4
View File
@@ -5,6 +5,7 @@ import type { PgDatabase } from "drizzle-orm/pg-core";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { after } from "next/server";
import type { TypedFormData } from "@/app/(ui)/endgame/type";
import { apiAuthCheck } from "./auth";
import { uidRegex } from "./const";
import { db } from "./db";
@@ -36,11 +37,17 @@ export async function getArtifactConfig() {
}
export async function submitArtifact(formData: FormData) {
const form = formData as unknown as TypedFormData<{
name: string;
uid: string;
character: string;
comment: string;
}>;
const config = await getArtifactConfig();
const name = formData.get("name")?.toString();
const uid = formData.get("uid")?.toString();
const character = formData.get("character")?.toString();
const comment = formData.get("comment")?.toString() || "";
const name = form.get("name")?.toString();
const uid = form.get("uid")?.toString();
const character = form.get("character")?.toString();
const comment = form.get("comment")?.toString() || "";
if (!name || !uid || !character) return "กรุณากรอกข้อมูลให้ครบถ้วน";
if (name.length > 32) return "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร";
if (!uidRegex.test(uid)) return "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หลัก";
+1 -1
View File
@@ -38,5 +38,5 @@ export async function apiAuthCheck() {
// console.log(session);
return session?.user;
return session?.user.role === "admin" ? session.user : null;
}
+6 -7
View File
@@ -109,13 +109,7 @@ export const endgameSubmissions = endgame.table("submissions", {
id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(),
user: text().notNull(),
slip: bytea().notNull(),
char: text()
.notNull()
.references(() => characters.name, {
onDelete: "cascade",
onUpdate: "cascade",
}),
expires: timestamp().$defaultFn(() => new Date(Date.now() + 15 * 60 * 1000)), // 15 minutes to pay
queue: serial(),
checked: boolean().notNull().default(false),
});
@@ -244,6 +238,10 @@ export const user = pgTable("user", {
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
});
export const session = pgTable("session", {
@@ -259,6 +257,7 @@ export const session = pgTable("session", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
});
export const account = pgTable("account", {