feat: refactor admin components and improve submission handling
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
data
|
||||
.github
|
||||
.direnv
|
||||
.git
|
||||
.env.local
|
||||
.next
|
||||
+57
@@ -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"]
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,3 +1,3 @@
|
||||
export default function AdminPage() {
|
||||
return <div>Admin Page</div>;
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -114,6 +114,8 @@ export default async function ArtifactFormPage() {
|
||||
<Label htmlFor="character">ตัวละคร</Label>
|
||||
<ComboBox
|
||||
placeholder="ค้นหาตัวละคร"
|
||||
id="character"
|
||||
name="character"
|
||||
data={
|
||||
await db
|
||||
.select({
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user