feat: refactor admin components and improve submission handling

This commit is contained in:
Dreamgineer
2025-09-24 05:44:18 -04:00 Unverified
parent f4307e0090
commit 9f588bf0ff
14 changed files with 169 additions and 44 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
data
.github
.direnv
.git
.env.local
.next
+57
View File
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1.4
### Stage 1: deps ###
FROM oven/bun:latest AS deps
WORKDIR /home/container
RUN apt-get update \
&& apt-get install -y \
python3 \
make \
g++ \
sqlite3 \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json bun.lock ./
# Install into cache, then persist into real node_modules
RUN bun install --frozen-lockfile
### Stage 2: builder ###
FROM oven/bun:latest AS builder
# Isolation
RUN useradd -mu 1001 container
USER container
WORKDIR /home/container
# Reuse deps
COPY --from=deps /home/container/node_modules ./node_modules
COPY --from=deps /home/container/package*.json ./
COPY --from=deps /home/container/bun.lock ./
# App source
COPY . .
# Run patcher (background) + Next.js build (foreground), then kill patcher
RUN bun run build
### Stage 3: runner ###
FROM oven/bun:latest AS runner
# Install jc (disk usage info)
RUN apt update && apt install jc -y
# Isolation
RUN useradd -mu 1001 container
USER container
WORKDIR /home/container
COPY --from=builder /home/container/node_modules ./node_modules
COPY --from=builder /home/container/.next ./.next
COPY --from=builder /home/container/package*.json ./
COPY --from=builder /home/container/bun.lock ./
COPY --from=builder /home/container/* ./
CMD ["bun", "start"]
-6
View File
@@ -11,12 +11,6 @@ import { db } from "@/lib/db";
import { characters, submissions } from "@/lib/db/schema";
import { CopyButton, EnkaBrowser } from "./client";
export const revalidate = 86400; // 24 hours
export async function generateStaticParams() {
return await db.select({ id: submissions.id }).from(submissions);
}
export default async function AdminSubmissionView({
params,
}: {
+28 -3
View File
@@ -3,6 +3,7 @@
import { Lock, Unlock } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -52,11 +53,11 @@ export function SidebarLink({
)}
>
{submission.queue}. {submission.name}
<SidebarMenuBadge>
<SidebarMenuBadge className="pointer-events-auto">
<Checkbox
className="mr-2 z-500"
className="mr-2"
checked={submission.checked}
onClick={() => toggleCheck(submission.id)}
onCheckedChange={() => toggleCheck(submission.id)}
/>
</SidebarMenuBadge>
</Link>
@@ -160,3 +161,27 @@ export function LimitManager({
</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>
))}
</>
);
}
+16 -16
View File
@@ -1,6 +1,5 @@
import { Dice3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sidebar,
SidebarContent,
@@ -9,14 +8,12 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarProvider,
SidebarMenu, SidebarProvider
} from "@/components/ui/sidebar";
import { getArtifactConfig } from "@/lib/api";
import { getArtifactConfig, random, wipe } from "@/lib/api";
import { db } from "@/lib/db";
import { submissions } from "@/lib/db/schema";
import { LimitManager, SidebarLink } from "./client";
import { LimitManager, SubmissionList } from "./client";
export default async function AdminLayout({
children,
@@ -40,10 +37,20 @@ export default async function AdminLayout({
<div className="flex justify-between items-center">
<b>Admin</b>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="size-8">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={random}
>
<Dice3 size={24} className="size-6" />
</Button>
<Button variant="ghost" size="icon" className="size-8">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={wipe}
>
<Trash2 size={24} className="size-6 text-red-500" />
</Button>
</div>
@@ -53,14 +60,7 @@ export default async function AdminLayout({
<SidebarGroup>
<SidebarGroupLabel>Submissions</SidebarGroupLabel>
<SidebarMenu>
<form>
<Input placeholder="Search..." type="search" name="q" />
</form>
{subs.map((s) => (
<SidebarMenuButton key={s.id} asChild>
<SidebarLink submission={s} />
</SidebarMenuButton>
))}
<SubmissionList subs={subs} />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
+1 -1
View File
@@ -1,3 +1,3 @@
export default function AdminPage() {
return <div>Admin Page</div>;
return <div></div>;
}
+4 -1
View File
@@ -36,7 +36,10 @@ export function CharacterChooser() {
const charIds = data.playerInfo.showAvatarInfoList.map((c) =>
c.avatarId.toString(),
);
setChars(await getCharacters(charIds));
const chars = await getCharacters(charIds);
setChars(
charIds.map((e) => chars.find((c) => c.amber === e)!).filter(Boolean),
);
}
fetchChars()
.catch(() => setIsError(true))
+2
View File
@@ -7,8 +7,10 @@ import { submitArtifact } from "@/lib/api";
export function ArtifactFormWrapper(props: React.ComponentProps<"form">) {
const router = useRouter();
async function submit(data: FormData) {
console.log(Object.fromEntries(data.entries()));
submitArtifact(data)
.then((q) => {
if (typeof q === "string") return toast.error(q);
cookieStore.set("sid", q.id);
router.refresh();
})
+3 -4
View File
@@ -4,13 +4,12 @@ import Image from "next/image";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";
import { base } from "@/lib/const";
import type { YoutubeLiveInfo } from "../api/live/route";
export async function LiveButton() {
const live: YoutubeLiveInfo = await fetch(`${base}/api/live`).then((e) =>
e.json(),
);
const live: YoutubeLiveInfo = await fetch(`http://localhost:3000/api/live`)
.then((e) => e.json())
.catch(() => "none");
if (live === "none") return;
const { url, thumbnails } = live;
return (
+2
View File
@@ -114,6 +114,8 @@ export default async function ArtifactFormPage() {
<Label htmlFor="character">ตัวละคร</Label>
<ComboBox
placeholder="ค้นหาตัวละคร"
id="character"
name="character"
data={
await db
.select({
+3 -2
View File
@@ -22,6 +22,7 @@ export function ComboBox({
data,
placeholder = "Search...",
id,
name,
className,
...props
}: React.ComponentProps<"button"> & {
@@ -48,7 +49,7 @@ export function ComboBox({
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder={placeholder} className="h-9" />
<CommandList>
@@ -77,7 +78,7 @@ export function ComboBox({
</Command>
</PopoverContent>
</Popover>
<input id={id} type="hidden" value={value} />
<input id={id} name={name} type="hidden" value={value} />
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
services:
app:
restart: always
build: .
ports:
- 8093:3000
deploy:
resources:
limits:
cpus: "4.0"
memory: 1024M
volumes:
- ./data/prod:/app/data
stop_grace_period: 1s
+30 -10
View File
@@ -1,7 +1,8 @@
"use server";
import { eq, inArray, not } from "drizzle-orm";
import { eq, inArray, not, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiAuthCheck } from "./auth";
import { uidRegex } from "./const";
import { db } from "./db";
@@ -23,7 +24,7 @@ export async function getArtifactConfig() {
const [art] = await db.select().from(artifactSettings).limit(1);
const [glob] = await db.select().from(settings).limit(1);
// @ts-expect-error
return { locked: false, limit: -1, enka: true, ...art, ...glob };
return { locked: false, limit: -1, enka: false, ...art, ...glob };
}
export async function submitArtifact(formData: FormData) {
@@ -32,26 +33,26 @@ export async function submitArtifact(formData: FormData) {
const uid = formData.get("uid")?.toString();
const character = formData.get("character")?.toString();
const comment = formData.get("comment")?.toString() || "";
if (!name || !uid || !character) throw "กรุณากรอกข้อมูลให้ครบถ้วน";
if (name.length > 32) throw "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร";
if (!uidRegex.test(uid)) throw "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หลัก";
if (comment.length > 256) throw "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 512 ตัวอักษร";
if (config.locked) throw "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก";
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 (config.locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก";
const count = await db.$count(submissions);
if (config.limit !== -1 && count >= config.limit)
throw `คิวลงทะเบียนเต็มแล้ว (${config.limit} ครั้ง)`;
return `คิวลงทะเบียนเต็มแล้ว (${config.limit} ครั้ง)`;
const [char] = await db
.select()
.from(characters)
.where(eq(characters.name, character))
.limit(1);
if (!char) throw "ไม่พบตัวละครที่เลือก";
if (!char) return "ไม่พบตัวละครที่เลือก";
const [existing] = await db
.select()
.from(submissions)
.where(eq(submissions.uid, uid))
.limit(1);
if (existing) throw "คุณลงทะเบียนไปแล้ว";
if (existing) return "คุณลงทะเบียนไปแล้ว";
const [queue] = await db
.insert(submissions)
.values({
@@ -108,3 +109,22 @@ export async function setLimit(limit: number) {
await db.insert(artifactSettings).values({ limit });
revalidatePath("/artifact/admin");
}
export async function wipe() {
if (!(await apiAuthCheck())) throw "Unauthorized";
await db.delete(submissions);
revalidatePath("/artifact/admin");
redirect("/artifact/admin");
}
export async function random() {
if (!(await apiAuthCheck())) throw "Unauthorized";
const [sub] = await db
.select()
.from(submissions)
.where(not(submissions.checked))
.orderBy(sql`RANDOM()`)
.limit(1);
if (sub) redirect(`/artifact/admin/${sub.id}`);
else throw "ไม่พบผู้ลงทะเบียนที่ยังไม่ตรวจสอบ";
}
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "biome check",
"format": "biome format --write",
"dr": "drizzle-kit",
"ba": "bunx @better-auth/cli"
"ba": "bunx @better-auth/cli",
"up": "docker compose up -d --build"
},
"dependencies": {
"@headless-tree/core": "^1.4.0",