rubgram public side

Co-authored-by: Gunshin <[email protected]>
This commit is contained in:
Dreamgineer
2025-11-27 12:12:21 -05:00
co-authored by Gunshin
Unverified
parent 45f6d06cc9
commit 8a96ea8573
44 changed files with 2140 additions and 412 deletions
+2 -2
View File
@@ -71,10 +71,10 @@ export default async function AdminLayout({
<Link href="/admin"> <Link href="/admin">
<SidebarMenuButton <SidebarMenuButton
asChild asChild
className="data-[slot=sidebar-menu-button]:!p-1.5" className="data-[slot=sidebar-menu-button]:p-1.5!"
> >
<span> <span>
<Computer className="!size-5" /> <Computer className="size-5!" />
<span className="text-base font-semibold">Buzzy Inc.</span> <span className="text-base font-semibold">Buzzy Inc.</span>
</span> </span>
</SidebarMenuButton> </SidebarMenuButton>
+1 -1
View File
@@ -4,7 +4,7 @@ export default function AdminPage() {
return ( return (
<div className="flex h-full w-full justify-center items-center"> <div className="flex h-full w-full justify-center items-center">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="opacity-80 text-4xl font-bold">Welcome back!</span> <span className="opacity-80 text-4xl font-bold">ยินดีต้อนรับกลับ!</span>
<span> <span>
<RandomWelcomeMessage /> <RandomWelcomeMessage />
</span> </span>
+3 -1
View File
@@ -7,6 +7,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getArtifactConfig } from "@/lib/api";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { characters, submissions } from "@/lib/db/schema"; import { characters, submissions } from "@/lib/db/schema";
import { CopyButton, EnkaBrowser } from "./client"; import { CopyButton, EnkaBrowser } from "./client";
@@ -27,6 +28,7 @@ export default async function AdminSubmissionView({
.from(characters) .from(characters)
.where(eq(characters.name, sub?.char || "")) .where(eq(characters.name, sub?.char || ""))
.limit(1); .limit(1);
const { enka } = await getArtifactConfig();
return ( return (
<div className="p-2 h-full"> <div className="p-2 h-full">
<div className="flex flex-col h-full w-full gap-2"> <div className="flex flex-col h-full w-full gap-2">
@@ -48,7 +50,7 @@ export default async function AdminSubmissionView({
<Avatar char={char} /> <Avatar char={char} />
</div> </div>
<div className="border rounded-md h-full overflow-hidden"> <div className="border rounded-md h-full overflow-hidden">
<EnkaBrowser uid={sub?.uid || ""} /> {enka && <EnkaBrowser uid={sub?.uid || ""} />}
</div> </div>
</div> </div>
</div> </div>
+19 -5
View File
@@ -4,7 +4,7 @@ import { useProgress } from "@bprogress/next";
import { Lock, Unlock } from "lucide-react"; import { Lock, Unlock } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
@@ -138,7 +138,7 @@ export function LimitManager({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Set limit</DialogTitle> <DialogTitle>ตั้ง Limit</DialogTitle>
</DialogHeader> </DialogHeader>
<form <form
id="limit-form" id="limit-form"
@@ -156,11 +156,11 @@ export function LimitManager({
</form> </form>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline">Cancel</Button> <Button variant="outline">ยกเลิก</Button>
</DialogClose> </DialogClose>
<DialogClose asChild> <DialogClose asChild>
<Button type="submit" form="limit-form"> <Button type="submit" form="limit-form">
Save changes บันทึกการเปลี่ยนแปลง
</Button> </Button>
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
@@ -206,10 +206,24 @@ export function SubmissionList({
export function Watcher() { export function Watcher() {
const router = useRouter(); const router = useRouter();
const count = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
const es = new EventSource(`/api/artifact/ev`); const es = new EventSource(`/api/artifact/ev`);
es.addEventListener("update", () => router.refresh()); es.addEventListener("update", () => router.refresh());
return () => es.close(); const interval = setInterval(() => {
fetch("/api/artifact/count")
.then((r) => r.json())
.then((r) => {
if (typeof count.current === "undefined") count.current = r;
if (count.current !== r) router.refresh();
count.current = r;
})
.catch((e) => console.error("Fetching artifact count failed", e));
}, 60000);
return () => {
es.close();
clearInterval(interval);
};
}, [router]); }, [router]);
return <div></div>; return <div></div>;
+6 -6
View File
@@ -69,17 +69,17 @@ export default async function AdminLayout({
<AlertDialogContent className="z-101"> <AlertDialogContent className="z-101">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
Are you absolutely sure? คุณมั่นใจที่จะล้างข้อมูลทั้งหมดใช่ไหม?
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete การกระทำนี้ไม่สามารถย้อนกลับได้
your account and remove your data from our servers. เราจะลบบัญชีของท่านออกจากระบบ
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction onClick={wipe}> <AlertDialogAction onClick={wipe}>
Continue ดำเนินการต่อ
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -89,7 +89,7 @@ export default async function AdminLayout({
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Submissions</SidebarGroupLabel> <SidebarGroupLabel>เสือกไอดีชาวบ้าน</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
<SubmissionList subs={subs} /> <SubmissionList subs={subs} />
</SidebarMenu> </SidebarMenu>
+3 -1
View File
@@ -159,7 +159,9 @@ function Placeholder({ className, ...props }: React.ComponentProps<"div">) {
export function Disclaimer() { export function Disclaimer() {
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
if (agreed) return; const [updated] = comms.var("updated");
if (agreed || updated) return;
return ( return (
<Blocker> <Blocker>
<RulesDialog onOpenChange={(set) => !set && setAgreed(true)}> <RulesDialog onOpenChange={(set) => !set && setAgreed(true)}>
+1 -1
View File
@@ -11,7 +11,7 @@ export function ArtifactFormWrapper(props: React.ComponentProps<"form">) {
.then((q) => { .then((q) => {
if (typeof q === "string") return toast.error(q); if (typeof q === "string") return toast.error(q);
try { try {
// biome-ignore lint/suspicious/noTsIgnore: typescript issue // biome-ignore lint/suspicious/noTsIgnore: error appears on build but not visible to biome
// @ts-ignore // @ts-ignore
cookieStore.set("sid", q.id); cookieStore.set("sid", q.id);
} catch {} } catch {}
-86
View File
@@ -1,86 +0,0 @@
"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 {
// @ts-expect-error
locked: false,
// @ts-expect-error
limit: -1,
// @ts-expect-error
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;
}
-5
View File
@@ -1,5 +0,0 @@
"use client";
export function EndgameForm() {
return <form></form>;
}
-176
View File
@@ -1,176 +0,0 @@
import { eq } from "drizzle-orm";
import { BookAlert, CircleDollarSign, SendHorizonal } from "lucide-react";
import { cookies } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import banner from "#/logos/artifact.webp";
import { PageTransition } from "@/app/transition";
import { Blocker } from "@/components/blocker";
import Image from "@/components/image";
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";
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 (
<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>
</PageTransition>
);
}
+36 -28
View File
@@ -2,6 +2,7 @@ import { ChevronRightIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Banner from "#/banner.webp"; import Banner from "#/banner.webp";
import ArtifactLogo from "#/logos/artifact.webp"; import ArtifactLogo from "#/logos/artifact.webp";
import RubgramLogo from "#/logos/rubgram.webp";
import TierlistLogo from "#/logos/tierlist.webp"; import TierlistLogo from "#/logos/tierlist.webp";
import Image from "@/components/image"; import Image from "@/components/image";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
@@ -37,17 +38,14 @@ export default function Home() {
<Image <Image
src={ArtifactLogo} src={ArtifactLogo}
alt="เสือกไอดีชาวบ้าน" alt="เสือกไอดีชาวบ้าน"
className="w-[calc(100svw-112px)] md:w-22" className="w-22"
width={500} width={500}
height={500} height={500}
/> />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle> <ItemTitle>
<span className="hidden md:block">เสือกไอดีชาวบ้าน</span> <span>เสือกไอดีชาวบ้าน</span>
<span className="block md:hidden">
ตรวจแฟกต์เกนชินในไลฟ์
</span>
</ItemTitle> </ItemTitle>
<ItemDescription className="line-clamp-none hidden md:block"> <ItemDescription className="line-clamp-none hidden md:block">
ระบบลงคิวดูอาร์ติแฟกต์เกนชินในไลฟ์สตรีม ระบบลงคิวดูอาร์ติแฟกต์เกนชินในไลฟ์สตรีม
@@ -59,28 +57,36 @@ export default function Home() {
</Link> </Link>
</Item> </Item>
{/* <Item variant="outline" asChild> {
<Link href="/endgame"> <Item variant="outline" asChild>
<ItemMedia className="justify-center"> <Link href="/rubgram">
<Image <ItemMedia className="justify-center">
src={ArtifactLogo} <Image
alt="รับกรรมแทนคนดู" src={RubgramLogo}
className="w-[calc(100svw-112px)] md:w-22" alt="รับกรรมแทนคนดู"
width={500} className="w-22"
height={500} width={500}
/> height={500}
</ItemMedia> />
<ItemContent> </ItemMedia>
<ItemTitle>รับกรรมแทนคนดู</ItemTitle> <ItemContent>
<ItemDescription className="line-clamp-none hidden md:block"> <ItemTitle>
ระบบลงคิวดูอาร์ติแฟกต์เกนชินในไลฟ์สตรีม <span className="hidden md:block">
</ItemDescription> รับกรรมแทนทางบ้าน
</ItemContent> </span>
<ItemActions> <span className="block md:hidden">เร็วๆนี้</span>
<ChevronRightIcon className="size-4" /> </ItemTitle>
</ItemActions> <ItemDescription className="line-clamp-none hidden md:block">
</Link> รับเล่นคอนเท้น Endgame แทนคนดู{" "}
</Item> */} <span className="text-red-500">ไม่ฟรีนะคับ</span>
</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRightIcon className="size-4" />
</ItemActions>
</Link>
</Item>
}
<Item variant="outline" asChild> <Item variant="outline" asChild>
<Link href="/tl"> <Link href="/tl">
@@ -94,7 +100,9 @@ export default function Home() {
/> />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>จัดเทียร์ลิสต์</ItemTitle> <ItemTitle>
<span>จัดเทียร์ลิสต์</span>
</ItemTitle>
<ItemDescription className="line-clamp-none hidden md:block"> <ItemDescription className="line-clamp-none hidden md:block">
ระบบจัดเทียร์ลิสต์ตัวละครของคอนเท้น Abyss และ Stygain ระบบจัดเทียร์ลิสต์ตัวละครของคอนเท้น Abyss และ Stygain
</ItemDescription> </ItemDescription>
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { Copy, CopyCheck, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function EnkaBrowser({ uid }: { uid: string }) {
const [id, setId] = useState(uid);
const [ready, setReady] = useState(false);
useEffect(() => {
setId(uid);
setReady(false);
}, [uid]);
return (
<div className="h-full w-full relative">
{!ready && (
<Loader2 className="animate-spin absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-8 text-muted-foreground z-100" />
)}
<iframe
src={`https://enka.network/u/${id}`}
className={cn(
"w-full border-0 bg-card -mt-20 h-[calc(100%+5rem)]",
!ready && "grayscale blur-md brightness-50 pointer-events-none",
)}
title="Enka Network"
onLoad={() => setReady(true)}
/>
</div>
);
}
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
if (copied) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Button variant="ghost" size="icon" onClick={copy} disabled={copied}>
{copied ? <CopyCheck /> : <Copy />}
</Button>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { eq } from "drizzle-orm";
import Avatar from "@/components/avatar";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getArtifactConfig } from "@/lib/api";
import { db } from "@/lib/db";
import { characters, submissions } from "@/lib/db/schema";
import { CopyButton, EnkaBrowser } from "./client";
export default async function AdminSubmissionView({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [sub] = await db
.select()
.from(submissions)
.where(eq(submissions.id, id))
.limit(1);
const [char] = await db
.select()
.from(characters)
.where(eq(characters.name, sub?.char || ""))
.limit(1);
const { enka } = await getArtifactConfig();
return (
<div className="p-2 h-full">
<div className="flex flex-col h-full w-full gap-2">
<div className="flex w-full justify-between gap-2">
<Card className="w-full pb-1 rounded-md">
<CardHeader>
<CardTitle>
{sub.queue}. {sub.name}
</CardTitle>
<CardDescription>{sub.comment}</CardDescription>
</CardHeader>
<CardFooter className="h-full items-end">
<div className="flex items-center">
<span className="text-gray-500">{sub.uid}</span>
<CopyButton text={sub.uid} />
</div>
</CardFooter>
</Card>
<Avatar char={char} />
</div>
<div className="border rounded-md h-full overflow-hidden">
{enka && <EnkaBrowser uid={sub?.uid || ""} />}
</div>
</div>
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useProgress } from "@bprogress/next";
import { Lock, Unlock } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { setLimit, toggleCheck, toggleLock } from "@/lib/api";
import { cn } from "@/lib/utils";
export function SidebarLink({
submission,
className,
}: {
submission: { id: string; name: string; checked: boolean; queue: number };
className?: string;
}) {
const { stop } = useProgress();
const { id } = useParams();
const [checked, setChecked] = useState(submission.checked);
useEffect(() => {
setChecked(submission.checked);
}, [submission.checked]);
return (
<Link
href={`/artifact/admin/${submission.id}`}
className={cn(
className,
id === submission.id && "bg-accent text-accent-foreground",
)}
prefetch
onClick={(ev) => {
if ((ev.target as HTMLButtonElement).type === "button") {
ev.preventDefault();
}
}}
>
{submission.queue}. {submission.name}
<SidebarMenuBadge className="pointer-events-auto">
<Checkbox
className="mr-2"
checked={checked}
onCheckedChange={async () => {
setChecked((x) => !x);
await toggleCheck(submission.id);
stop();
}}
/>
</SidebarMenuBadge>
</Link>
);
}
export function LimitManager({
config,
length,
...props
}: React.ComponentProps<typeof SidebarMenu> & {
config: Awaited<ReturnType<typeof import("@/lib/api").getArtifactConfig>>;
length: number;
}) {
return (
<SidebarMenu {...props}>
<SidebarMenuItem>
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<Tooltip>
{config.locked ? (
<>
<TooltipTrigger asChild>
<Lock className="text-red-500" />
</TooltipTrigger>
<TooltipContent>
<p>ปิดรับ</p>
</TooltipContent>
</>
) : config.limit >= 0 && length > config.limit ? (
<>
<TooltipTrigger asChild>
<Lock className="text-orange-400" />
</TooltipTrigger>
<TooltipContent>
<p>เต็มแล้ว</p>
</TooltipContent>
</>
) : (
<>
<TooltipTrigger asChild>
<Unlock className="text-sky-400" />
</TooltipTrigger>
<TooltipContent>
<p>เปิดรับ</p>
</TooltipContent>
</>
)}
</Tooltip>{" "}
จำนวน
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end">
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<span>ตั้ง Limit</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ตั้ง Limit</DialogTitle>
</DialogHeader>
<form
id="limit-form"
action={async (data: FormData) =>
setLimit(Number(data.get("limit")) || -1)
}
>
<Input
id="limit"
name="limit"
type="number"
placeholder="ไม่จำกัด"
defaultValue={config.limit >= 0 ? config.limit : ""}
/>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">ยกเลิก</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" form="limit-form">
บันทึกการเปลี่ยนแปลง
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
<DropdownMenuItem onClick={toggleLock}>
<span>{config.locked && "เ"}ปิดรับ</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
<SidebarMenuBadge>
{length}
{config.limit >= 0 ? `/${config.limit}` : ""}
</SidebarMenuBadge>
</SidebarMenuItem>
</SidebarMenu>
);
}
export function SubmissionList({
subs,
}: {
subs: { id: string; name: string; checked: boolean; queue: number }[];
}) {
const [query, setQuery] = useState("");
return (
<>
<Input
placeholder="Search..."
type="search"
onChange={(ev) => setQuery(ev.target.value.toLowerCase())}
/>
{subs
.filter((s) => (s.queue + s.name).toLowerCase().includes(query))
.map((s) => (
<SidebarMenuButton key={s.id} asChild>
<SidebarLink submission={s} />
</SidebarMenuButton>
))}
</>
);
}
export function Watcher() {
const router = useRouter();
const count = useRef<number | undefined>(undefined);
useEffect(() => {
const es = new EventSource(`/api/artifact/ev`);
es.addEventListener("update", () => router.refresh());
const interval = setInterval(() => {
fetch("/api/artifact/count")
.then((r) => r.json())
.then((r) => {
if (typeof count.current === "undefined") count.current = r;
if (count.current !== r) router.refresh();
count.current = r;
})
.catch((e) => console.error("Fetching artifact count failed", e));
}, 60000);
return () => {
es.close();
clearInterval(interval);
};
}, [router]);
return <div></div>;
}
+111
View File
@@ -0,0 +1,111 @@
import { Dice3, Trash2 } from "lucide-react";
import { redirect } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarProvider,
} from "@/components/ui/sidebar";
import { getArtifactConfig, random, wipe } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { submissions } from "@/lib/db/schema";
import { LimitManager, SubmissionList, Watcher } from "./client";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!(await adminCheck())) redirect("/");
const subs = await db
.select({
id: submissions.id,
name: submissions.name,
checked: submissions.checked,
queue: submissions.queue,
})
.from(submissions)
.orderBy(submissions.id);
const config = await getArtifactConfig();
return (
<SidebarProvider>
<Sidebar>
<SidebarHeader>
<div className="flex justify-between items-center">
<b>Admin</b>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={random}
>
<Dice3 size={24} className="size-6" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Trash2 size={24} className="size-6 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="z-101">
<AlertDialogHeader>
<AlertDialogTitle>
คุณมั่นใจที่จะล้างข้อมูลทั้งหมดใช่ไหม?
</AlertDialogTitle>
<AlertDialogDescription>
การกระทำนี้ไม่สามารถย้อนกลับได้
เราจะลบบัญชีของท่านออกจากระบบ
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction onClick={wipe}>
ดำเนินการต่อ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>เสือกไอดีชาวบ้าน</SidebarGroupLabel>
<SidebarMenu>
<SubmissionList subs={subs} />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarGroup>
<SidebarGroupLabel>การตั้งค่า</SidebarGroupLabel>
<LimitManager config={config} length={subs.length} />
</SidebarGroup>
</SidebarFooter>
</Sidebar>
<SidebarInset className="bg-transparent">
{children}
<Watcher />
</SidebarInset>
</SidebarProvider>
);
}
+14
View File
@@ -0,0 +1,14 @@
import generate from "@/lib/og";
// Image metadata
export const size = {
width: 1222,
height: 560,
};
export const contentType = "image/png";
// Image generation
export default async function Image() {
return generate({ title: "รับกรรมแทนทางบ้าน", sub: "(ฝั่งแอดมิน)" });
}
+7
View File
@@ -0,0 +1,7 @@
export const metadata = {
title: "เสือกไอดีชาวบ้าน (แอดมิน)",
};
export default function AdminPage() {
return <div></div>;
}
+331
View File
@@ -0,0 +1,331 @@
"use server";
import { and, desc, eq, gt, isNull, lt, not, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import image from "next/image";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import {
endgameArchive,
endgameDiscord,
endgameSettings,
endgameSlips,
endgameSubmissions,
settings,
} from "@/lib/db/schema";
import { sse } from "@/lib/utils";
import type { TypedFormData } from "./type";
const { SLIPOK_API_URL, SLIPOK_API_KEY } = process.env as Record<
string,
string
>;
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 && ngm.limit !== -1 && count >= (ngm.limit || 0);
return {
// @ts-expect-error
locked: false,
// @ts-expect-error
limit: -1,
// @ts-expect-error
enka: false,
// @ts-expect-error
free: 0,
count,
full,
...ngm,
...glob,
};
}
export type EndgameFormData = TypedFormData<{
name: string;
server: "as" | "eu" | "us" | "tw";
service: "abyss" | "theater" | "stygian";
user: string; // user snowflake
}>;
export async function submitEndgame(formData: EndgameFormData) {
const { full, locked, limit } = await getEndgameConfig();
const name = formData.get("name");
const server = formData.get("server");
const service = formData.getAll("service");
const user = formData.get("user");
if (!name || !server || !service.length || !user)
return "กรุณากรอกข้อมูลให้ครบถ้วน";
// ไม่ควรเกิดขึ้นแน่ๆ แต่กันไว้ก่อน
if (!["as", "eu", "us", "tw"].includes(server)) return "เซิร์ฟไม่ถูกต้อง";
if (service.some((s) => !["abyss", "theater", "stygian"].includes(s)))
return "บริการไม่ถูกต้อง";
if (name.length > 32) return "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร";
if (locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก";
if (full) return `คิวลงทะเบียนเต็มแล้ว (${limit} ครั้ง)`;
await removeExpiredSubmissions();
const [existing] = await db
.select({ queue: endgameSubmissions.queue, id: endgameSubmissions.id })
.from(endgameSubmissions)
.where(eq(endgameSubmissions.user, user))
.limit(1);
if (existing) return existing;
const [queue] = await db
.insert(endgameSubmissions)
.values({
user,
name,
server,
service,
price: await calcPrice(service),
})
.returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id });
revalidatePath("/rubgram/admin");
sse.publish({}, { topic: "rubgram-ev", event: "submit" });
return queue;
}
export type EndgamePaymentFormData = TypedFormData<{
sid: string;
slip: File;
}>;
export async function submitEndgamePayment(formData: EndgamePaymentFormData) {
const sid = formData.get("sid");
const slip = formData.get("slip");
if (!sid || !slip) return "กรุณาอัพโหลดสลิปให้ครบถ้วน";
const arrayBuffer = await slip.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const queue = await db.transaction(async (tx) => {
const [s] = await tx
.select()
.from(endgameSubmissions)
.where(eq(endgameSubmissions.id, sid))
.limit(1);
const [a] = await tx
.select()
.from(endgameArchive)
.where(eq(endgameArchive.id, sid))
.limit(1);
if (!s) {
if (a)
await tx.insert(endgameSubmissions).values({
...a,
price: await calcPrice(a.service),
});
else return "คุณยังไม่ได้ลงทะเบียน";
}
// const processed = {
// success: true,
// data: { transRef: "test-abc", amount: 12345 },
// } as const;
const processed = await checkSlip(buffer, slip.type, (s || a).price);
if (!processed.success) return `${processed.code}: ${processed.message}`;
const [{ id: slipId }] = await tx
.insert(endgameSlips)
.values({
slip: buffer,
ref: processed.data.transRef,
amount: processed.data.amount?.toString(),
data: processed,
})
.returning({ id: endgameSlips.id })
.catch((e) => {
console.log(e);
return [{ id: "conflict" }];
});
if (slipId === "conflict") return "สลิปนี้ถูกใช้ไปแล้ว";
return await tx
.update(endgameSubmissions)
.set({
slip: slipId,
})
.returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id })
.where(eq(endgameSubmissions.id, sid));
});
revalidatePath("/rubgram/admin");
sse.publish({}, { topic: "rubgram-ev", event: "submit" });
return Array.isArray(queue) ? queue[0] : queue;
}
async function checkSlip(
buffer: Buffer<ArrayBuffer>,
type: string,
amount: number,
): Promise<SlipokResponse> {
const response = await fetch(SLIPOK_API_URL, {
method: "POST",
headers: {
"x-authorization": SLIPOK_API_KEY,
},
body: (() => {
const formData = new FormData();
formData.append("files", new Blob([buffer], { type }), image.name);
formData.append("amount", amount.toString());
// formData.append("log", "true"); // no log cause im logging myself
return formData;
})(),
});
const data = await response.json();
// if (!response.ok) throw data;
return data;
}
// discord auth
export async function getDiscordSession() {
const c = await cookies();
const token = c.get("discord");
if (!token) return undefined;
return await db
.select()
.from(endgameDiscord)
.where(eq(endgameDiscord.token, token.value))
.then((v) => v[0]);
}
export async function loginDiscord() {
redirect(process.env.DISCORD_OAUTH_URL!);
}
export async function calcPrice(service: ("abyss" | "theater" | "stygian")[]) {
const { free, count } = await getEndgameConfig();
return count < free
? 0
: service.reduce(
(p, s) => p + { abyss: 60, theater: 100, stygian: 100 }[s],
0,
) -
((["abyss", "theater", "stygian"] as const).every((s) =>
service.includes(s),
)
? 10
: 0);
}
async function removeExpiredSubmissions() {
// First, get count of items to be removed
const expiredCond = and(
not(eq(endgameSubmissions.price, 0)),
isNull(endgameSubmissions.slip),
lt(endgameSubmissions.expires, new Date()),
);
const toRemove = await db
.select({ count: sql<number>`COUNT(*)` })
.from(endgameSubmissions)
.where(expiredCond);
// Delete expired submissions and update queue numbers in one transaction
await db.transaction(async (tx) => {
// Store the queue numbers before deletion
const expiredQueues = await tx
.select()
.from(endgameSubmissions)
.where(expiredCond)
.orderBy(desc(endgameSubmissions.queue));
// Delete expired submissions
if (expiredQueues.length)
await tx.insert(endgameArchive).values(expiredQueues);
await tx.delete(endgameSubmissions).where(expiredCond);
// Update queue numbers for remaining submissions
for (const { queue } of expiredQueues) {
await tx
.update(endgameSubmissions)
.set({
queue: sql`${endgameSubmissions.queue} - 1`,
})
.where(gt(endgameSubmissions.queue, queue));
}
// Reset the serial sequence to the max queue number
const maxQueue = await tx
.select({ max: sql<number>`MAX(${endgameSubmissions.queue})` })
.from(endgameSubmissions);
const nextSerial = (maxQueue[0]?.max || 0) + 1;
// Update the sequence - adjust schema name if needed
await tx.execute(
sql`SELECT setval('endgame.submissions_queue_seq', ${nextSerial}, false)`,
);
});
return {
removed: Number(toRemove[0]?.count || 0),
};
}
export type SlipokResponse =
| {
success: true;
data: {
success: true;
message: string;
rqUID: string;
language: string;
transRef: string;
sendingBank: string;
receivingBank: string;
transDate: string;
transTime: string;
sender: {
displayName: string;
name: string;
proxy: {
type: null;
value: null;
};
account: {
type: string;
value: string;
};
};
receiver: {
displayName: string;
name: string;
proxy: {
type: string;
value: string;
};
account: {
type: string;
value: string;
};
};
amount: number;
paidLocalAmount: number;
paidLocalCurrency: string;
countryCode: string;
transFeeAmount: number;
ref1: string;
ref2: string;
ref3: string;
toMerchantId: string;
};
}
| {
success: false;
code: number;
message: string;
};
+129
View File
@@ -0,0 +1,129 @@
// app/api/auth/discord/callback/route.ts
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { type NextRequest, NextResponse } from "next/server";
import { stringify } from "yaml";
import { db } from "@/lib/db"; // your drizzle db instance
import { endgameDiscord } from "@/lib/db/schema"; // your schema
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID!;
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET!;
const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI!;
const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID!;
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN!;
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const cookieStore = await cookies();
const code = searchParams.get("code");
if (!code) {
if (searchParams.has("error")) redirect("/rubgram");
return NextResponse.json({ error: "No code provided" }, { status: 400 });
}
try {
// Exchange code for access token
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
// Authorization: `Basic ${btoa(`${DISCORD_CLIENT_ID}:${DISCORD_CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: DISCORD_REDIRECT_URI,
}),
});
if (!tokenResponse.ok) {
throw new Error(
`Failed to exchange code for token: ${tokenResponse.status}\n${stringify(await tokenResponse.json())}`,
);
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
// Get user information
const userResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!userResponse.ok) {
throw new Error("Failed to fetch user data");
}
const userData = await userResponse.json();
// Check if user is in guild
const guildMemberResponse = await fetch(
`https://discord.com/api/users/@me/guilds/${DISCORD_GUILD_ID}/member`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const isInGuild = guildMemberResponse.ok;
// If not in guild, add them using bot token
if (!isInGuild) {
const addMemberResponse = await fetch(
`https://discord.com/api/guilds/${DISCORD_GUILD_ID}/members/${userData.id}`,
{
method: "PUT",
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
access_token: accessToken,
}),
},
);
if (!addMemberResponse.ok) {
console.error("Failed to add user to guild");
// Continue anyway - they might join manually
}
}
// Store/update user in database
const display = userData.global_name || userData.username;
const [{ token }] = await db
.insert(endgameDiscord)
.values({
uid: userData.id,
display,
username: userData.username,
})
.onConflictDoUpdate({
target: endgameDiscord.uid,
set: {
display,
username: userData.username,
},
})
.returning({ token: endgameDiscord.token });
// Redirect to success page or dashboard
cookieStore.set("discord", token);
} catch (error) {
console.error("Discord OAuth error:", (error as Error)?.message || error);
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 500 },
);
}
redirect("/rubgram");
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { ArrowRight, Download, LogIn, ScrollText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Blocker } from "@/components/blocker";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { Label } from "@/components/ui/label";
import {
MultiSelect,
MultiSelectContent,
MultiSelectGroup,
MultiSelectItem,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/ui/multi-select";
import { comms } from "@/lib/comms";
import { calcPrice, type getDiscordSession, loginDiscord } from "./api";
import { RulesDialog } from "./rules";
export function WelcomeScreening({
session,
}: {
session: Awaited<ReturnType<typeof getDiscordSession>>;
}) {
const [agreed, setAgreed] = useState(false);
const [updated] = comms.var("updated");
if (agreed || (updated && session)) return;
return (
<Blocker>
{session ? (
<div className="grid gap-2">
<RulesDialog onOpenChange={(set) => !set && setAgreed(true)}>
<Button type="button">
ดำเนินการต่อ <ArrowRight />
</Button>
</RulesDialog>
<span className="text-gray-600 text-xs">
กำลังล็อกอินในฐานะ <b className="text-gray-300">{session.display}</b>{" "}
<button
className="text-gray-400 hover:text-gray-300 underline cursor-pointer"
onClick={loginDiscord}
type="button"
>
สลับบัญชี?
</button>
</span>
</div>
) : (
<div className="grid gap-2">
โปรดล็อคอินด้วย Discord เพื่อดำเนินการต่อ
<Button onClick={loginDiscord}>
<span className="flex justify-between w-full items-center">
Login ด้วย Discord
<LogIn />
</span>
</Button>
</div>
)}
</Blocker>
);
}
export function ServiceSelector() {
const [selected, setSelected] = comms.var("rubgram.services");
return (
<div className="grid gap-2">
<Label htmlFor="service">บริการ*</Label>
<MultiSelect
onValuesChange={(l) =>
setSelected(l as unknown as Parameters<typeof setSelected>[0])
}
>
<MultiSelectTrigger className="w-full">
<MultiSelectValue placeholder="เลือกบริการที่ต้องการ" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectGroup>
<MultiSelectItem value="abyss">
Spiral abyss <Kbd>60 บาท</Kbd>
</MultiSelectItem>
<MultiSelectItem value="theater">
โรงละครในจินตนาการ <Kbd>100 บาท</Kbd>
</MultiSelectItem>
<MultiSelectItem value="stygian">
Stygian onslaught <Kbd>100 บาท</Kbd>
</MultiSelectItem>
</MultiSelectGroup>
</MultiSelectContent>
</MultiSelect>
<span className="text-xs text-muted-foreground -mt-1">
เลือกทั้งหมด ลดให้ 10 บาท
</span>
<select hidden id="service" name="service" multiple value={selected}>
<option value="abyss" />
<option value="theater" />
<option value="stygian" />
</select>
</div>
);
}
export function PriceEstimation() {
const [selected] = comms.var("rubgram.services");
const [label, setLabel] = useState("ทั้งหมด 0 บาท");
useEffect(() => {
if (selected) calcPrice(selected).then((p) => setLabel(`ทั้งหมด ${p} บาท`));
});
return <Kbd>{label}</Kbd>;
}
export function SlipUpload() {
const ref = useRef<HTMLInputElement>(null);
const [isSelected, setIsSelected] = useState(false);
return (
<>
<Button
className="w-full bg-emerald-600!"
size="sm"
type="button"
onClick={() => ref.current?.click()}
variant={isSelected ? "default" : "destructive"}
>
<ScrollText /> อัพโหลดสลิป
</Button>
<input
ref={ref}
type="file"
name="slip"
accept="image/*"
required
hidden
onChange={() => setIsSelected(!!ref.current?.files?.length)}
/>
</>
);
}
export function DownloadButton() {
return (
<Button
className="w-full"
size="sm"
type="button"
onClick={() => {
fetch("/assets/promptpay_full.jpg")
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "qrcode.jpg";
a.click();
URL.revokeObjectURL(url);
});
}}
variant="ghost"
>
<Download /> ดาวน์โหลด QR Code
</Button>
);
}
export function Countdown({ time }: { time: Date }) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(interval);
}, []);
const diff = Math.max(0, time.getTime() - now.getTime());
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
return (
<span>
{minutes.toString().padStart(2, "0")}:
{seconds.toString().padStart(2, "0")}
</span>
);
}
export function ClearCookie() {
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
// @ts-ignore
cookieStore.delete("rsid");
return "";
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
type EndgameFormData,
type EndgamePaymentFormData,
submitEndgame,
submitEndgamePayment,
} from "./api";
export function EndgameFormWrapper({
type,
...props
}: React.ComponentProps<"form"> & { type: "registration" | "payment" }) {
const router = useRouter();
async function submit(data: FormData) {
({ registration: submitEndgame, payment: submitEndgamePayment })
[type](data as unknown as EndgameFormData & EndgamePaymentFormData)
.then((q) => {
if (typeof q === "string") return toast.error(q);
try {
// biome-ignore lint/suspicious/noTsIgnore: error appears on build but not visible to biome
// @ts-ignore
cookieStore.set("rsid", q.id);
} catch (e) {
console.error("Failed to set cookie", e);
}
router.refresh();
})
.catch((e) => toast.error(`${e.message || e}`));
}
return (
<form
{...props}
onSubmit={(e) => {
e.preventDefault();
submit(new FormData(e.target as HTMLFormElement));
}}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import generate from "@/lib/og";
// Image metadata
export const size = {
width: 1222,
height: 560,
};
export const contentType = "image/png";
// Image generation
export default async function Image() {
return generate({ title: "รับกรรมแทนทางบ้าน", sub: "" });
}
export const dynamic = "force-dynamic";
+302
View File
@@ -0,0 +1,302 @@
import {
and,
eq,
gt,
isNotNull,
isNull,
lt,
not,
or,
type SQL,
} from "drizzle-orm";
import {
AlertCircle,
BookAlert,
CircleDollarSign,
SendHorizonal,
} from "lucide-react";
import { cookies } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import PromptpayImage from "#/assets/promptpay.jpg";
import banner from "#/logos/rubgram.webp";
import { PageTransition } from "@/app/transition";
import { Blocker } from "@/components/blocker";
import Image from "@/components/image";
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 { Kbd } from "@/components/ui/kbd";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { db } from "@/lib/db";
import { endgameSubmissions } from "@/lib/db/schema";
import { LiveButton } from "../artifact/live";
import { getDiscordSession, getEndgameConfig } from "./api";
import {
ClearCookie,
Countdown,
DownloadButton,
PriceEstimation,
ServiceSelector,
SlipUpload,
WelcomeScreening,
} from "./client";
import { EndgameFormWrapper } from "./form";
import { RulesDialog } from "./rules";
export const metadata = {
title: "รับกรรมแทนทางบ้าน",
};
export default async function EndgamePage() {
const cookie = await cookies();
const sid = cookie.get("rsid");
const session = await getDiscordSession();
const [q] = sid?.value
? await db
.select({
queue: endgameSubmissions.queue,
paid: or(
eq(endgameSubmissions.price, 0),
isNotNull(endgameSubmissions.slip),
) as SQL<boolean>,
price: endgameSubmissions.price,
expires: endgameSubmissions.expires,
})
.from(endgameSubmissions)
.where(
or(
eq(endgameSubmissions.id, sid.value),
and(
eq(endgameSubmissions.user, session?.uid || "placeholder"),
gt(endgameSubmissions.expires, new Date()),
),
),
)
: [];
const { count, ...config } = await getEndgameConfig();
const [canExpire] = q
? await db
.select({ queue: endgameSubmissions.queue })
.from(endgameSubmissions)
.where(
and(
not(eq(endgameSubmissions.price, 0)),
isNull(endgameSubmissions.slip),
lt(endgameSubmissions.queue, q.queue),
),
)
: [];
return (
<PageTransition>
<div className="flex flex-col justify-around items-center h-svh">
<Card className="w-full max-w-md">
{q ? (
!q.paid ? (
""
) : (
<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>
{canExpire && (
<span className="text-muted-foreground flex gap-1 items-center">
<SimpleTooltip text="มีบางคิวก่อนหน้าของคุณยังไม่ได้ชำระเงิน">
<AlertCircle size={16} />
</SimpleTooltip>
เลขคิวของคุณอาจมีการเปลื่ยนแปลง
</span>
)}
</div>
</Blocker>
)
) : config.locked ? (
<Blocker>
<span className="font-bold text-3xl">ยังไม่เปิดรับคิว</span>
</Blocker>
) : config.full ? (
<Blocker>
<div className="flex gap-1 flex-col items-center">
<span className="font-bold text-3xl">คิวเต็มแล้ว</span>
</div>
</Blocker>
) : (
<WelcomeScreening {...{ session }} />
)}
<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>
{q ? (
<EndgameFormWrapper id="mainform" type="payment">
<div className="flex flex-col gap-2 items-center">
<div className="flex gap-2 w-full">
<Image
src={PromptpayImage}
alt="Promptpay QR Code"
className="rounded max-w-32 shrink-0"
/>
<div className="flex flex-col shrink-0 relative">
<span className="font-bold text-lg">
ยอดชำระ {q.price} บาท{" "}
{q.expires && (
<Kbd>
<Countdown time={q.expires} />
</Kbd>
)}
</span>
<span className="text-sm text-muted-foreground">
ผู้รับ: นาย พัชรพล พลพันธุ์
</span>
<span className="text-sm text-muted-foreground">
บัญชี: xxx-x-x8666-x
</span>
<span className="text-sm text-muted-foreground">
เลขที่อ้างอิง: 004999056945438
</span>
<DownloadButton />
</div>
</div>
<SlipUpload />
</div>
<input hidden name="sid" readOnly value={sid?.value} />
</EndgameFormWrapper>
) : (
<EndgameFormWrapper id="mainform" type="registration">
<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
defaultValue={session?.display}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="server">เซิร์ฟเวอร์*</Label>
<Select name="server">
<SelectTrigger className="w-full">
<SelectValue placeholder="เลือกเซิร์ฟเวอร์ที่คุณอยู่" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>เซิร์ฟเวอร์</SelectLabel>
<SelectItem value="as">Asia</SelectItem>
<SelectItem value="us">America</SelectItem>
<SelectItem value="eu">Europe</SelectItem>
<SelectItem value="tw">TW, HK, MO</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<ServiceSelector />
</div>
<input hidden name="user" readOnly value={session?.uid || ""} />
</EndgameFormWrapper>
)}
</CardContent>
<CardFooter className="flex gap-2 justify-between">
<div className="flex gap-2">
<SimpleTooltip text="ลัดคิว 50 บาท (ไม่รวมยอดที่ต้องจ่าย)">
<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">
{!q && <PriceEstimation />}
<Kbd>
{count} / {config.limit < 0 ? "∞" : config.limit}
</Kbd>
<SimpleTooltip text="ถัดไป">
<Button
type="submit"
form="mainform"
disabled={
q?.paid ||
config.locked ||
(config.limit >= 0 && count >= config.limit)
}
>
<SendHorizonal />
</Button>
</SimpleTooltip>
</div>
</CardFooter>
</Card>
{sid && !q && <ClearCookie />}
</div>
</PageTransition>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type * as DialogPrimitive from "@radix-ui/react-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function RulesDialog({
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Root> & {
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>กฏการลงคิว</DialogTitle>
</DialogHeader>
<p>
{" "}
1. เมื่อจ่ายเงิน แล้วจะ<u className="text-red-500">ไม่สามารถขอคืนเงิน</u>ได้ทุกกรณีนะคับ
<br />
2. เมื่อถึงคิวแล้วจะมีการเรียกตัวผ่าน Discord ผ่านช่อง ยมบาลตามตัว หลังจากโดนแท็กแล้วให้ทักหาบุสทันที
<br />
3. จะลงทั้งในไลฟ์และนอกไลฟ์เพราะฉะนั้นควรเตรียมตัวให้พร้อมก่อนถึงคิว
<br />
4. หากไม่มาตามคิวที่ลงไว้ จะถูกข้ามคิวไปก่อน ถ้ากลับมาแล้วทักหาด้วย เดี๋ยวลืม
<br />
5. หากใช้ Turemoney แล้วสแกน PromptPay ไม่ได้ จะไม่สามารถลงคิวได้
<br />
6. จะมีเวลาให้สแกนจ่านเงิน 20 นาที หากเกินเวลาแล้วต้องลงคิวใหม่
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">ตกลง</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function TierlistSelectionPage() {
{vers.map((t) => ( {vers.map((t) => (
<div className="flex flex-col gap-1" key={t.id}> <div className="flex flex-col gap-1" key={t.id}>
<div className="font-bold text-4xl"> <div className="font-bold text-4xl">
<div className="px-2 py-1 border border-dashed w-min rounded-md bg-[#2228]"> <div className="px-2 py-1 border w-min rounded-md bg-[#2228]">
{t.name} {t.name}
</div> </div>
</div> </div>
+6
View File
@@ -0,0 +1,6 @@
import { db } from "@/lib/db";
import { submissions } from "@/lib/db/schema";
export async function GET() {
return Response.json(await db.$count(submissions));
}
Executable → Regular
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -31,3 +31,5 @@ export default async function ArtifactCountWidget() {
</div> </div>
); );
} }
export const dynamic = "force-dynamic";
Executable → Regular
+24 -20
View File
@@ -11,7 +11,7 @@
"@headless-tree/core": "^1.5.1", "@headless-tree/core": "^1.5.1",
"@headless-tree/react": "^1.5.1", "@headless-tree/react": "^1.5.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "16.0.0", "@next/bundle-analyzer": "16.0.4",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -51,7 +51,7 @@
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next": "16.0.0", "next": "16.0.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pg": "^8.16.3", "pg": "^8.16.3",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -78,8 +78,8 @@
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@types/node": "^20.19.24", "@types/node": "^20.19.24",
"@types/react": "19.2.2", "@types/react": "19.2.7",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.3",
"drizzle-kit": "^0.31.6", "drizzle-kit": "^0.31.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -96,8 +96,8 @@
"prisma", "prisma",
], ],
"overrides": { "overrides": {
"@types/react": "19.2.2", "@types/react": "19.2.7",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.3",
"better-sqlite3": "npm:dry-uninstall", "better-sqlite3": "npm:dry-uninstall",
}, },
"packages": { "packages": {
@@ -365,25 +365,25 @@
"@mrleebo/prisma-ast": ["@mrleebo/[email protected]", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-VxS+okLpNp6YUDLz3rAXv6sE8G4W5cX039bumkPub65xRr9BFxjHaT5ebX4DsiixIGaBwitjWTvx3mAbyI2EUQ=="], "@mrleebo/prisma-ast": ["@mrleebo/[email protected]", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-VxS+okLpNp6YUDLz3rAXv6sE8G4W5cX039bumkPub65xRr9BFxjHaT5ebX4DsiixIGaBwitjWTvx3mAbyI2EUQ=="],
"@next/bundle-analyzer": ["@next/[email protected].0", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-OYufQoNm/Im2fYQBdXu9fqUrXaP3lPuPnryW0XNGG7kJiGxH/VWS8zc2/x4aW2LQGn+opqIJYNRXy7k5qV/09g=="], "@next/bundle-analyzer": ["@next/[email protected].4", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-6IajJ23QrXW5RTJj2lRHcBM8mxcEl+vgd7XXVODQG/BcJyjgIP1k5OefdRl+P80btPvHeHoV4fIgC1so25pXcg=="],
"@next/env": ["@next/[email protected].0", "", {}, "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA=="], "@next/env": ["@next/[email protected].4", "", {}, "sha512-FDPaVoB1kYhtOz6Le0Jn2QV7RZJ3Ngxzqri7YX4yu3Ini+l5lciR7nA9eNDpKTmDm7LWZtxSju+/CQnwRBn2pA=="],
"@next/swc-darwin-arm64": ["@next/[email protected].0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA=="], "@next/swc-darwin-arm64": ["@next/[email protected].4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TN0cfB4HT2YyEio9fLwZY33J+s+vMIgC84gQCOLZOYusW7ptgjIn8RwxQt0BUpoo9XRRVVWEHLld0uhyux1ZcA=="],
"@next/swc-darwin-x64": ["@next/[email protected].0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg=="], "@next/swc-darwin-x64": ["@next/[email protected].4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XsfI23jvimCaA7e+9f3yMCoVjrny2D11G6H8NCcgv+Ina/TQhKPXB9P4q0WjTuEoyZmcNvPdrZ+XtTh3uPfH7Q=="],
"@next/swc-linux-arm64-gnu": ["@next/[email protected].0", "", { "os": "linux", "cpu": "arm64" }, "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg=="], "@next/swc-linux-arm64-gnu": ["@next/[email protected].4", "", { "os": "linux", "cpu": "arm64" }, "sha512-uo8X7qHDy4YdJUhaoJDMAbL8VT5Ed3lijip2DdBHIB4tfKAvB1XBih6INH2L4qIi4jA0Qq1J0ErxcOocBmUSwg=="],
"@next/swc-linux-arm64-musl": ["@next/[email protected].0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg=="], "@next/swc-linux-arm64-musl": ["@next/[email protected].4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pvR/AjNIAxsIz0PCNcZYpH+WmNIKNLcL4XYEfo+ArDi7GsxKWFO5BvVBLXbhti8Coyv3DE983NsitzUsGH5yTw=="],
"@next/swc-linux-x64-gnu": ["@next/[email protected].0", "", { "os": "linux", "cpu": "x64" }, "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg=="], "@next/swc-linux-x64-gnu": ["@next/[email protected].4", "", { "os": "linux", "cpu": "x64" }, "sha512-2hebpsd5MRRtgqmT7Jj/Wze+wG+ZEXUK2KFFL4IlZ0amEEFADo4ywsifJNeFTQGsamH3/aXkKWymDvgEi+pc2Q=="],
"@next/swc-linux-x64-musl": ["@next/[email protected].0", "", { "os": "linux", "cpu": "x64" }, "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg=="], "@next/swc-linux-x64-musl": ["@next/[email protected].4", "", { "os": "linux", "cpu": "x64" }, "sha512-pzRXf0LZZ8zMljH78j8SeLncg9ifIOp3ugAFka+Bq8qMzw6hPXOc7wydY7ardIELlczzzreahyTpwsim/WL3Sg=="],
"@next/swc-win32-arm64-msvc": ["@next/[email protected].0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA=="], "@next/swc-win32-arm64-msvc": ["@next/[email protected].4", "", { "os": "win32", "cpu": "arm64" }, "sha512-7G/yJVzum52B5HOqqbQYX9bJHkN+c4YyZ2AIvEssMHQlbAWOn3iIJjD4sM6ihWsBxuljiTKJovEYlD1K8lCUHw=="],
"@next/swc-win32-x64-msvc": ["@next/[email protected].0", "", { "os": "win32", "cpu": "x64" }, "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA=="], "@next/swc-win32-x64-msvc": ["@next/[email protected].4", "", { "os": "win32", "cpu": "x64" }, "sha512-0Vy4g8SSeVkuU89g2OFHqGKM4rxsQtihGfenjx2tRckPrge5+gtFnRWGAAwvGXr0ty3twQvcnYjEyOrLHJ4JWA=="],
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="], "@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
@@ -617,9 +617,9 @@
"@types/prompts": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="], "@types/prompts": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="],
"@types/react": ["@types/[email protected].2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@types/react": ["@types/[email protected].7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/[email protected].2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], "@types/react-dom": ["@types/[email protected].3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@xobotyi/scrollbar-width": ["@xobotyi/[email protected]", "", {}, "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="], "@xobotyi/scrollbar-width": ["@xobotyi/[email protected]", "", {}, "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="],
@@ -683,7 +683,7 @@
"css-tree": ["[email protected]", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], "css-tree": ["[email protected]", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["[email protected]", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], "d3-array": ["[email protected]", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -877,7 +877,7 @@
"nanostores": ["[email protected]", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="], "nanostores": ["[email protected]", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
"next": ["[email protected].0", "", { "dependencies": { "@next/env": "16.0.0", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.0", "@next/swc-darwin-x64": "16.0.0", "@next/swc-linux-arm64-gnu": "16.0.0", "@next/swc-linux-arm64-musl": "16.0.0", "@next/swc-linux-x64-gnu": "16.0.0", "@next/swc-linux-x64-musl": "16.0.0", "@next/swc-win32-arm64-msvc": "16.0.0", "@next/swc-win32-x64-msvc": "16.0.0", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg=="], "next": ["[email protected].4", "", { "dependencies": { "@next/env": "16.0.4", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.4", "@next/swc-darwin-x64": "16.0.4", "@next/swc-linux-arm64-gnu": "16.0.4", "@next/swc-linux-arm64-musl": "16.0.4", "@next/swc-linux-x64-gnu": "16.0.4", "@next/swc-linux-x64-musl": "16.0.4", "@next/swc-win32-arm64-msvc": "16.0.4", "@next/swc-win32-x64-msvc": "16.0.4", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-vICcxKusY8qW7QFOzTvnRL1ejz2ClTqDKtm1AcUjm2mPv/lVAdgpGNsftsPRIDJOXOjRQO68i1dM8Lp8GZnqoA=="],
"next-themes": ["[email protected]", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "next-themes": ["[email protected]", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -1163,6 +1163,10 @@
"c12/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ=="], "c12/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ=="],
"dom-helpers/csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"nano-css/csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"nypm/tinyexec": ["[email protected]", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], "nypm/tinyexec": ["[email protected]", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
+3 -1
View File
@@ -18,5 +18,7 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"registries": {} "registries": {
"@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json"
}
} }
+39 -11
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import NextImage from "next/image"; import NextImage from "next/image";
import { useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Spinner } from "./ui/spinner";
export default function Image({ export default function Image({
src, src,
@@ -11,18 +12,45 @@ export default function Image({
...props ...props
}: React.ComponentProps<typeof NextImage>) { }: React.ComponentProps<typeof NextImage>) {
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const ref = useRef<HTMLImageElement>(null);
const [rect, setRect] = useState<{
top: number;
left: number;
width: number;
height: number;
}>();
useLayoutEffect(() => {
if (ref.current?.parentElement) {
const { top: pTop, left: pLeft } =
ref.current.parentElement.getBoundingClientRect();
const { top, left, width, height } = ref.current.getBoundingClientRect();
setRect({ top: top - pTop, left: left - pLeft, width, height });
}
}, []);
return ( return (
<NextImage <>
src={src} <NextImage
alt={alt} ref={ref}
className={cn( src={src}
"transition-opacity duration-700 opacity-0", alt={alt}
isLoaded && "opacity-100", className={cn(
className, "transition-opacity duration-700 opacity-0",
isLoaded && "opacity-100",
className,
)}
onLoad={() => setIsLoaded(true)}
{...props}
/>
{isLoaded || (
<div
className="absolute backdrop-blur-md bg-[#2228] p-1 overflow-hidden rounded-sm animate-in fade-in"
style={rect}
>
<Spinner />
</div>
)} )}
onLoad={() => setIsLoaded(true)} </>
{...props}
/>
); );
} }
+355
View File
@@ -0,0 +1,355 @@
"use client"
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react"
import { Badge } from "@/components/ui/badge"
type MultiSelectContextType = {
open: boolean
setOpen: (open: boolean) => void
selectedValues: Set<string>
toggleValue: (value: string) => void
items: Map<string, ReactNode>
onItemAdded: (value: string, label: ReactNode) => void
}
const MultiSelectContext = createContext<MultiSelectContextType | null>(null)
export function MultiSelect({
children,
values,
defaultValues,
onValuesChange,
}: {
children: ReactNode
values?: string[]
defaultValues?: string[]
onValuesChange?: (values: string[]) => void
}) {
const [open, setOpen] = useState(false)
const [internalValues, setInternalValues] = useState(
new Set<string>(values ?? defaultValues),
)
const selectedValues = values ? new Set(values) : internalValues
const [items, setItems] = useState<Map<string, ReactNode>>(new Map())
function toggleValue(value: string) {
const getNewSet = (prev: Set<string>) => {
const newSet = new Set(prev)
if (newSet.has(value)) {
newSet.delete(value)
} else {
newSet.add(value)
}
return newSet
}
setInternalValues(getNewSet)
onValuesChange?.([...getNewSet(selectedValues)])
}
const onItemAdded = useCallback((value: string, label: ReactNode) => {
setItems(prev => {
if (prev.get(value) === label) return prev
return new Map(prev).set(value, label)
})
}, [])
return (
<MultiSelectContext
value={{
open,
setOpen,
selectedValues,
toggleValue,
items,
onItemAdded,
}}
>
<Popover open={open} onOpenChange={setOpen} modal={true}>
{children}
</Popover>
</MultiSelectContext>
)
}
export function MultiSelectTrigger({
className,
children,
...props
}: {
className?: string
children?: ReactNode
} & ComponentPropsWithoutRef<typeof Button>) {
const { open } = useMultiSelectContext()
return (
<PopoverTrigger asChild>
<Button
{...props}
variant={props.variant ?? "outline"}
role={props.role ?? "combobox"}
aria-expanded={props["aria-expanded"] ?? open}
className={cn(
"flex h-auto min-h-9 w-fit items-center justify-between gap-2 overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
>
{children}
<ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
)
}
export function MultiSelectValue({
placeholder,
clickToRemove = true,
className,
overflowBehavior = "wrap-when-open",
...props
}: {
placeholder?: string
clickToRemove?: boolean
overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff"
} & Omit<ComponentPropsWithoutRef<"div">, "children">) {
const { selectedValues, toggleValue, items, open } = useMultiSelectContext()
const [overflowAmount, setOverflowAmount] = useState(0)
const valueRef = useRef<HTMLDivElement>(null)
const overflowRef = useRef<HTMLDivElement>(null)
const shouldWrap =
overflowBehavior === "wrap" ||
(overflowBehavior === "wrap-when-open" && open)
const checkOverflow = useCallback(() => {
if (valueRef.current == null) return
const containerElement = valueRef.current
const overflowElement = overflowRef.current
const items = containerElement.querySelectorAll<HTMLElement>(
"[data-selected-item]",
)
if (overflowElement != null) overflowElement.style.display = "none"
items.forEach(child => child.style.removeProperty("display"))
let amount = 0
for (let i = items.length - 1; i >= 0; i--) {
const child = items[i]!
if (containerElement.scrollWidth <= containerElement.clientWidth) {
break
}
amount = items.length - i
child.style.display = "none"
overflowElement?.style.removeProperty("display")
}
setOverflowAmount(amount)
}, [])
const handleResize = useCallback(
(node: HTMLDivElement) => {
valueRef.current = node
const mutationObserver = new MutationObserver(checkOverflow)
const observer = new ResizeObserver(debounce(checkOverflow, 100))
mutationObserver.observe(node, {
childList: true,
attributes: true,
attributeFilter: ["class", "style"],
})
observer.observe(node)
return () => {
observer.disconnect()
mutationObserver.disconnect()
valueRef.current = null
}
},
[checkOverflow],
)
if (selectedValues.size === 0 && placeholder) {
return (
<span className="min-w-0 overflow-hidden font-normal text-muted-foreground">
{placeholder}
</span>
)
}
return (
<div
{...props}
ref={handleResize}
className={cn(
"flex w-full gap-1.5 overflow-hidden",
shouldWrap && "h-full flex-wrap",
className,
)}
>
{[...selectedValues]
.filter(value => items.has(value))
.map(value => (
<Badge
variant="outline"
data-selected-item
className="group flex items-center gap-1"
key={value}
onClick={
clickToRemove
? e => {
e.stopPropagation()
toggleValue(value)
}
: undefined
}
>
{items.get(value)}
{clickToRemove && (
<XIcon className="size-2 text-muted-foreground group-hover:text-destructive" />
)}
</Badge>
))}
<Badge
style={{
display: overflowAmount > 0 && !shouldWrap ? "block" : "none",
}}
variant="outline"
ref={overflowRef}
>
+{overflowAmount}
</Badge>
</div>
)
}
export function MultiSelectContent({
search = true,
children,
...props
}: {
search?: boolean | { placeholder?: string; emptyMessage?: string }
children: ReactNode
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
const canSearch = typeof search === "object" ? true : search
return (
<>
<div style={{ display: "none" }}>
<Command>
<CommandList>{children}</CommandList>
</Command>
</div>
<PopoverContent className="min-w-[var(--radix-popover-trigger-width)] p-0">
<Command {...props}>
{canSearch ? (
<CommandInput
placeholder={
typeof search === "object" ? search.placeholder : undefined
}
/>
) : (
<button autoFocus className="sr-only" />
)}
<CommandList>
{canSearch && (
<CommandEmpty>
{typeof search === "object" ? search.emptyMessage : undefined}
</CommandEmpty>
)}
{children}
</CommandList>
</Command>
</PopoverContent>
</>
)
}
export function MultiSelectItem({
value,
children,
badgeLabel,
onSelect,
...props
}: {
badgeLabel?: ReactNode
value: string
} & Omit<ComponentPropsWithoutRef<typeof CommandItem>, "value">) {
const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext()
const isSelected = selectedValues.has(value)
useEffect(() => {
onItemAdded(value, badgeLabel ?? children)
}, [value, children, onItemAdded, badgeLabel])
return (
<CommandItem
{...props}
onSelect={() => {
toggleValue(value)
onSelect?.(value)
}}
>
<CheckIcon
className={cn("mr-2 size-4", isSelected ? "opacity-100" : "opacity-0")}
/>
{children}
</CommandItem>
)
}
export function MultiSelectGroup(
props: ComponentPropsWithoutRef<typeof CommandGroup>,
) {
return <CommandGroup {...props} />
}
export function MultiSelectSeparator(
props: ComponentPropsWithoutRef<typeof CommandSeparator>,
) {
return <CommandSeparator {...props} />
}
function useMultiSelectContext() {
const context = useContext(MultiSelectContext)
if (context == null) {
throw new Error(
"useMultiSelectContext must be used within a MultiSelectContext",
)
}
return context
}
function debounce<T extends (...args: never[]) => void>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null
return function (this: unknown, ...args: Parameters<T>) {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func.apply(this, args), wait)
}
}
+28 -31
View File
@@ -4,8 +4,7 @@ import { and, eq, inArray, not, or, sql } from "drizzle-orm";
import type { PgDatabase } from "drizzle-orm/pg-core"; import type { PgDatabase } from "drizzle-orm/pg-core";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { after } from "next/server"; import type { TypedFormData } from "@/app/(ui)/rubgram/type";
import type { TypedFormData } from "@/app/(ui)/endgame/type";
import { adminCheck } from "./auth"; import { adminCheck } from "./auth";
import { uidRegex } from "./const"; import { uidRegex } from "./const";
import { db } from "./db"; import { db } from "./db";
@@ -93,7 +92,7 @@ export async function toggleCheck(submissionId: string) {
revalidatePath("/artifact/admin"); revalidatePath("/artifact/admin");
revalidatePath("/artifact"); revalidatePath("/artifact");
actionLog(`Toggled an artifact submission check mark`); await actionLog(`Toggled an artifact submission check mark`);
} }
export async function toggleLock() { export async function toggleLock() {
@@ -109,7 +108,7 @@ export async function toggleLock() {
revalidatePath("/artifact/admin"); revalidatePath("/artifact/admin");
revalidatePath("/artifact"); revalidatePath("/artifact");
actionLog( await actionLog(
`${(existing.length ? existing[0].locked : true) ? "Locked" : "Unlocked"} artifact submission`, `${(existing.length ? existing[0].locked : true) ? "Locked" : "Unlocked"} artifact submission`,
); );
} }
@@ -131,7 +130,7 @@ export async function setLimit(limit: number) {
revalidatePath("/artifact/admin"); revalidatePath("/artifact/admin");
revalidatePath("/artifact"); revalidatePath("/artifact");
actionLog(`Set artifact submit limit to ${limit < 0 ? "unlimited" : limit}`); await actionLog(`Set artifact submit limit to ${limit < 0 ? "unlimited" : limit}`);
} }
export async function wipe() { export async function wipe() {
@@ -143,7 +142,7 @@ export async function wipe() {
revalidatePath("/artifact/admin"); revalidatePath("/artifact/admin");
revalidatePath("/artifact"); revalidatePath("/artifact");
actionLog(`Deleted artifact submissions`); await actionLog(`Deleted artifact submissions`);
redirect("/artifact/admin"); redirect("/artifact/admin");
} }
@@ -192,7 +191,7 @@ export async function tlState(
revalidatePath(`/api/tl/${list}/states`); revalidatePath(`/api/tl/${list}/states`);
actionLog(`Updated a state in tierlist ${list}`, data); await actionLog(`Updated a state in tierlist ${list}`, data);
sse.publish(states, { topic: `tl-${list}`, event: "update_states" }); sse.publish(states, { topic: `tl-${list}`, event: "update_states" });
} }
@@ -214,7 +213,7 @@ export async function tlPlacements(
revalidatePath(`/api/tl/${list}`); revalidatePath(`/api/tl/${list}`);
actionLog(`Updated a placement in tierlist ${list}`); await actionLog(`Updated a placement in tierlist ${list}`);
sse.publish(placements, { topic: `tl-${list}`, event: "update_placements" }); sse.publish(placements, { topic: `tl-${list}`, event: "update_placements" });
} }
@@ -227,7 +226,7 @@ export async function cdnDelete(ids: string[], force = false) {
const refs = await checkCdnRefs(id); const refs = await checkCdnRefs(id);
if (refs.length) { if (refs.length) {
revalidatePath("/cdn/admin"); revalidatePath("/cdn/admin");
actionLog( await actionLog(
`Deleted ${deleted}/${ids.length}(Incomplete) files`, `Deleted ${deleted}/${ids.length}(Incomplete) files`,
ids.slice(0, deleted), ids.slice(0, deleted),
); );
@@ -238,7 +237,7 @@ export async function cdnDelete(ids: string[], force = false) {
} }
revalidatePath("/cdn/admin"); revalidatePath("/cdn/admin");
actionLog(`Deleted ${deleted} files`, ids); await actionLog(`Deleted ${deleted} files`, ids);
} }
export async function checkCdnRefs( export async function checkCdnRefs(
@@ -272,7 +271,7 @@ export async function cdnify(
}) })
.returning({ id: cdn.id }); .returning({ id: cdn.id });
actionLog(`File uploaded: ${name || `[${id}]`} (${b2s(data.size)})`); await actionLog(`File uploaded: ${name || `[${id}]`} (${b2s(data.size)})`);
try { try {
revalidatePath("/admin/cdn"); revalidatePath("/admin/cdn");
@@ -281,27 +280,25 @@ export async function cdnify(
} }
export async function actionLog(text: string, details?: unknown) { export async function actionLog(text: string, details?: unknown) {
after(async () => { // If not running in Next.js, skip.
// If not running in Next.js, skip. if (typeof process.versions.bun !== "undefined") return;
if (typeof process.versions.bun !== "undefined") return; const session = await adminCheck();
const session = await adminCheck();
const res = await db const res = await db
.insert(auditLog) .insert(auditLog)
.values({ .values({
author: session?.name, author: session?.name,
text, text,
details, details,
}) })
.returning() .returning()
.catch(() => .catch(() =>
console.error( console.error(
`Error logging audit log, printing it here:\n${session?.name || "[Unknown User]"} - ${text}`, `Error logging audit log, printing it here:\n${session?.name || "[Unknown User]"} - ${text}`,
), ),
); );
revalidatePath("/admin/log"); revalidatePath("/admin/log");
if (res) sse.publish(res, { topic: "log" }); if (res) sse.publish(res, { topic: "log" });
});
} }
-6
View File
@@ -10,12 +10,6 @@ export const auth = betterAuth({
enabled: true, enabled: true,
disableSignUp: true, disableSignUp: true,
}, },
socialProviders: {
discord: {
enabled: true,
clientId: "1397024670355554336",
},
},
plugins: [admin()], plugins: [admin()],
trustedOrigins: [ trustedOrigins: [
"http://nyx:3000", "http://nyx:3000",
+9 -1
View File
@@ -14,6 +14,7 @@ type WebsiteComms = Partial<{
debug: boolean; debug: boolean;
connected: boolean; connected: boolean;
updated: boolean; updated: boolean;
"rubgram.services": ("abyss" | "theater" | "stygian")[];
_raw: Record<string, unknown>; _raw: Record<string, unknown>;
}>; }>;
@@ -91,7 +92,14 @@ export default function CommsProvider({
} }
export const comms = { export const comms = {
var(name: keyof WebsiteComms) { var<K extends keyof WebsiteComms>(
name: K,
): [
WebsiteComms[K],
(
value: WebsiteComms[K] | ((prev: WebsiteComms[K]) => WebsiteComms[K]),
) => void,
] {
const comms = use(CommsContext); const comms = use(CommsContext);
if (!comms.active) { if (!comms.active) {
console.warn("ICC used outside of provider. Using local state instead."); console.warn("ICC used outside of provider. Using local state instead.");
+51 -3
View File
@@ -3,6 +3,7 @@ import {
boolean, boolean,
integer, integer,
jsonb, jsonb,
numeric,
pgEnum, pgEnum,
pgSchema, pgSchema,
pgTable, pgTable,
@@ -11,6 +12,7 @@ import {
timestamp, timestamp,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7"; import { uuidv7 } from "uuidv7";
import type { SlipokResponse } from "@/app/(ui)/rubgram/api";
import { bytea } from "./custom"; import { bytea } from "./custom";
export const element = pgEnum("character_element", [ export const element = pgEnum("character_element", [
@@ -102,22 +104,68 @@ export const artifactSettings = artifact.table("settings", {
//#endregion //#endregion
//#region Artifact //#region Endgame
export const endgame = pgSchema("endgame"); export const endgame = pgSchema("endgame");
export const endgameServer = endgame.enum("server", ["as", "eu", "us", "tw"]);
export const endgameService = endgame.enum("service", [
"abyss",
"theater",
"stygian",
]);
export const endgameSubmissions = endgame.table("submissions", { export const endgameSubmissions = endgame.table("submissions", {
id: text().primaryKey().$defaultFn(uuidv7), id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(), name: text().notNull(),
user: text().notNull(), user: text()
expires: timestamp().$defaultFn(() => new Date(Date.now() + 15 * 60 * 1000)), // 15 minutes to pay .notNull()
.references(() => endgameDiscord.uid, {
onDelete: "cascade",
onUpdate: "cascade",
}),
expires: timestamp().$defaultFn(() => new Date(Date.now() + 20 * 60 * 1000)), // 20 minutes to pay
queue: serial(), queue: serial(),
server: endgameServer().notNull(),
service: endgameService().array().notNull(),
price: integer().notNull(), // calculated on submission
slip: text().references(() => endgameSlips.id), // not paid = null
checked: boolean().notNull().default(false), checked: boolean().notNull().default(false),
}); });
// deleted submissions
export const endgameArchive = endgame.table("archive", {
id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(),
user: text()
.notNull()
.references(() => endgameDiscord.uid, {
onDelete: "cascade",
onUpdate: "cascade",
}),
server: endgameServer().notNull(),
service: endgameService().array().notNull(),
});
export const endgameSlips = endgame.table("slips", {
id: text().primaryKey().$defaultFn(uuidv7),
slip: bytea().notNull(),
ref: text().unique().notNull(),
amount: numeric().notNull(),
data: jsonb().$type<SlipokResponse>().notNull(),
});
export const endgameSettings = endgame.table("settings", { export const endgameSettings = endgame.table("settings", {
id: boolean().primaryKey().default(true), id: boolean().primaryKey().default(true),
locked: boolean().notNull().default(false), locked: boolean().notNull().default(false),
limit: integer().notNull().default(-1), limit: integer().notNull().default(-1),
free: integer().notNull().default(0),
});
export const endgameDiscord = endgame.table("discord", {
uid: text().primaryKey(), // user snowflake
display: text().notNull(),
username: text().notNull(),
token: text().notNull().$defaultFn(uuidv7), // for accessing existing user with cookie.
}); });
//#endregion //#endregion
+3 -1
View File
@@ -117,7 +117,9 @@ export class EventSourceManager {
if (s.topic !== topic) continue; if (s.topic !== topic) continue;
try { try {
s.send(data, event); s.send(data, event);
} catch {} } catch (error) {
console.error(` ERR #${topic}#${s.id}, ${error}`);
}
} }
}); });
} }
+6 -6
View File
@@ -21,7 +21,7 @@
"@headless-tree/core": "^1.5.1", "@headless-tree/core": "^1.5.1",
"@headless-tree/react": "^1.5.1", "@headless-tree/react": "^1.5.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "16.0.0", "@next/bundle-analyzer": "16.0.4",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -61,7 +61,7 @@
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next": "16.0.0", "next": "16.0.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pg": "^8.16.3", "pg": "^8.16.3",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -88,8 +88,8 @@
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@types/node": "^20.19.24", "@types/node": "^20.19.24",
"@types/react": "19.2.2", "@types/react": "19.2.7",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.3",
"drizzle-kit": "^0.31.6", "drizzle-kit": "^0.31.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -97,8 +97,8 @@
}, },
"overrides": { "overrides": {
"better-sqlite3": "npm:dry-uninstall", "better-sqlite3": "npm:dry-uninstall",
"@types/react": "19.2.2", "@types/react": "19.2.7",
"@types/react-dom": "19.2.2" "@types/react-dom": "19.2.3"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@prisma/client", "@prisma/client",
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Executable → Regular
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+15 -19
View File
@@ -12,34 +12,30 @@ test("has title", async ({ page }) => {
test("artifact link", async ({ page }) => { test("artifact link", async ({ page }) => {
await page.goto(BASE_URL); await page.goto(BASE_URL);
const viewportSize = page.viewportSize(); await page.getByText("เสือกไอดีชาวบ้าน").click();
const isMobile = viewportSize && viewportSize.width < 768;
await page
.getByText(isMobile ? "ตรวจแฟลกเกนชินรายแพทช์" : "เสือกไอดีชาวบ้าน")
.click();
await expect(page.getByAltText("เสือกไอดีชาวบ้าน")).toBeVisible(); await expect(page.getByAltText("เสือกไอดีชาวบ้าน")).toBeVisible();
}); });
test("tierlist public", async ({ page }) => { test("tierlist public", async ({ page }) => {
await page.goto(BASE_URL); await page.goto(BASE_URL);
await page.getByText("จัดเทียร์ลิสต์").click(); await page.getByText("จัดเทียร์ลิสต์", { exact: true }).click();
await page.getByText("5.7b").click(); await page.getByText("5.7b").click();
const viewportSize = page.viewportSize(); // This feature has been removed.
const isMobile = viewportSize && viewportSize.width < 768; // const viewportSize = page.viewportSize();
// const isMobile = viewportSize && viewportSize.width < 768;
if (isMobile) { // if (isMobile) {
await expect(page.getByText("โปรดปรับจอเป็นแนวนอน")).toBeVisible({ // await expect(page.getByText("โปรดปรับจอเป็นแนวนอน")).toBeVisible({
timeout: 15000, // timeout: 15000,
}); // });
// rotate phone // // rotate phone
await page.setViewportSize({ // await page.setViewportSize({
height: viewportSize.width, // height: viewportSize.width,
width: viewportSize.height, // width: viewportSize.height,
}); // });
} // }
await expect(page.getByAltText("Disclaimer")).toBeVisible({ timeout: 15000 }); await expect(page.getByAltText("Disclaimer")).toBeVisible({ timeout: 15000 });
await page.getByRole("button", { name: "Close Disclaimer" }).click(); await page.getByRole("button", { name: "Close Disclaimer" }).click();