TierList visuals complete

Co-authored-by: Gunshin <[email protected]>
Co-authored-by: S4mSh1ne <[email protected]>
This commit is contained in:
Dreamgineer
2025-10-02 18:10:39 -04:00
co-authored by Gunshin S4mSh1ne
Unverified
parent ca652d273d
commit b66212acf8
27 changed files with 817 additions and 183 deletions
-3
View File
@@ -40,9 +40,6 @@ 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
+1 -1
View File
@@ -15,7 +15,7 @@ export function EnkaBrowser({ uid }: { uid: string }) {
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" />
<Loader2 className="animate-spin absolute top-[calc(50%+5rem] 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}`}
+18 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { useProgress } from "@bprogress/next";
import { Lock, Unlock } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
@@ -43,8 +44,14 @@ export function SidebarLink({
submission: { id: string; name: string; checked: boolean; queue: number };
className?: string;
}) {
const { stop } = useProgress();
const { id } = useParams();
const router = useRouter();
const [checked, setChecked] = useState(submission.checked);
useEffect(() => {
setChecked(submission.checked);
}, [submission.checked]);
return (
<Link
href={`/artifact/admin/${submission.id}`}
@@ -52,15 +59,22 @@ export function SidebarLink({
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={submission.checked}
checked={checked}
onCheckedChange={async () => {
setChecked((x) => !x);
await toggleCheck(submission.id);
router.refresh();
stop();
}}
/>
</SidebarMenuBadge>
@@ -199,4 +213,4 @@ export function Watcher() {
}, [router]);
return <div></div>;
}
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { Dice3, Trash2 } from "lucide-react";
import { redirect } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
@@ -23,6 +24,7 @@ import {
SidebarProvider,
} from "@/components/ui/sidebar";
import { getArtifactConfig, random, wipe } from "@/lib/api";
import { apiAuthCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { submissions } from "@/lib/db/schema";
import { LimitManager, SubmissionList, Watcher } from "./client";
@@ -32,6 +34,7 @@ export default async function AdminLayout({
}: {
children: React.ReactNode;
}) {
if (!(await apiAuthCheck())) redirect("/");
const subs = await db
.select({
id: submissions.id,
@@ -63,7 +66,7 @@ export default async function AdminLayout({
<Trash2 size={24} className="size-6 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent className="z-101">
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
+22 -24
View File
@@ -49,15 +49,36 @@ export default async function ArtifactFormPage() {
return (
<div className="flex flex-col justify-around items-center h-full">
<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={{
position: "absolute",
transform: "translateY(-70%)",
}}
className="absolute z-50"
height={137.5}
width={276.5}
src={banner}
@@ -70,29 +91,6 @@ export default async function ArtifactFormPage() {
<CardContent>
<ArtifactFormWrapper id="mainform">
<div className="flex flex-col gap-3">
{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 />
)}
<div className="grid gap-2">
<Label htmlFor="name">ชื่อ*</Label>
<Input
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+5
View File
@@ -137,3 +137,8 @@ input[type="number"] {
z-index: 0;
position: relative;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-blue-500) var(--card);
}
+4 -5
View File
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
metadataBase: new URL("https://buzz.gunshiz.top"),
title: "เกนชินไม่ใช่เกมมือถือ",
description: "ระบบอีเวนท์ของเกนชินไม่ใช่เกมมือถือ",
icons: "https://buzz.gunshiz.top/favicon.webp",
icons: "https://buzzy.gunshiz.top/favicon.webp", // TODO: CHANGE BACK TO buzz
openGraph: {
title: "เกนชินไม่ใช่เกมมือถือ",
description: "ระบบอีเวนท์ของเกนชินไม่ใช่เกมมือถือ",
@@ -37,15 +37,14 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full">
<html lang="en" className="h-full dark">
<body
className={`${anuphan.variable} ${geistMono.variable} font-sans antialiased dark h-full`}
className={`${anuphan.variable} ${geistMono.variable} font-sans antialiased h-full`}
>
<Image
src={Background}
alt="Background"
fill
className="z-[-1] opacity-20 object-cover"
className="z-[-1] opacity-20 object-cover fixed top-0 left-0 w-full h-full"
/>
<Providers>{children}</Providers>
<Toaster />
+28 -10
View File
@@ -1,8 +1,8 @@
"use client";
import { UserLock } from "lucide-react";
import { Loader2, UserLock } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FormEvent, use } from "react";
import { type FormEvent, use, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -14,22 +14,40 @@ export default function LoginPage({
}: {
searchParams: Promise<{ next?: string }>;
}) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const { next } = use(searchParams);
const cb = next || "/admin";
useEffect(() => {
async function checkSession() {
const { data } = await authClient.getSession();
if (data) router.push(cb);
}
checkSession();
}, [cb, router]);
async function submit(ev: FormEvent<HTMLFormElement>) {
ev.preventDefault();
setLoading(true);
const data = new FormData(ev.currentTarget);
const r = await authClient.signUp
const r = await authClient.signIn
.email({
email: data.get("email") as string,
password: data.get("password") as string,
name: data.get("name") as string,
// name: data.get("name") as string,
})
.catch(() => toast.error("Failed to login"));
if (typeof r !== "object") return toast.error("Invalid email or password");
if (r.error) return toast.error(r.error.message);
if (typeof r !== "object") {
setLoading(false);
return toast.error("Invalid email or password");
}
if (r.error) {
setLoading(false);
return toast.error(r.error.message);
}
router.push(cb);
setLoading(false);
}
return (
<div className="bg-[#1117] backdrop-blur-lg flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
@@ -59,7 +77,7 @@ export default function LoginPage({
</div>
</div>
<div className="flex flex-col gap-3">
<div className="grid gap-1">
{/* <div className="grid gap-1">
<Label htmlFor="email">Name</Label>
<Input
id="name"
@@ -69,7 +87,7 @@ export default function LoginPage({
required
autoFocus
/>
</div>
</div> */}
<div className="grid gap-1">
<Label htmlFor="email">Email</Label>
<Input
@@ -90,8 +108,8 @@ export default function LoginPage({
required
/>
</div>
<Button type="submit" className="w-full">
Login
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : "Login"}
</Button>
</div>
</div>
+3
View File
@@ -7,6 +7,9 @@ export default function Home() {
<Link href="/artifact">
<Button>Artifact</Button>
</Link>
<Link href="/tl">
<Button>TierList</Button>
</Link>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { type ReactNode, useContext } from "react";
import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
import { TierListContext } from "./context";
export function TierListCell({
tier,
column,
children,
}: {
tier: typeof tierlistTiers.$inferSelect;
column: typeof tierlistColumns.$inferSelect;
children?: ReactNode;
}) {
const { tileSize } = useContext(TierListContext);
const { setNodeRef } = useDroppable({
id: `${tier.id}-${column.id}`,
});
return (
<div
ref={setNodeRef}
className="flex flex-wrap p-1 gap-2 items-start"
style={{
minHeight: `${tileSize + 8}px`,
}}
>
{children}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useDraggable } from "@dnd-kit/core";
import Image from "next/image";
import { useContext } from "react";
import type { characters } from "@/lib/db/schema";
import { TierListContext } from "./context";
export function Draggable({ char }: { char: typeof characters.$inferSelect }) {
const { tileSize } = useContext(TierListContext);
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: char.id,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
<Image
src={`/cdn/${char.image}`}
style={{
background: `rgba(${
char.stars === 5
? "200,124,36"
: char.stars === 4
? "148,112,187"
: "100,100,100"
}) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)`,
}}
className="rounded"
alt={char.name}
height={tileSize}
width={tileSize}
/>
</button>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { createContext } from "react";
import type { characters, tierlistBadges } from "@/lib/db/schema";
export const TierListContext = createContext<{
chars: (typeof characters.$inferSelect)[];
badges: (typeof tierlistBadges.$inferSelect)[];
tileSize: number;
}>({
chars: [],
badges: [],
tileSize: 64,
});
+75
View File
@@ -0,0 +1,75 @@
import { and, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import {
characters,
tierlistBadges,
tierlistColumns,
tierlistTiers,
tierlistTypes,
tierlistVersions,
versions,
} from "@/lib/db/schema";
import { TierList } from "./tierlist";
export default async function TierlistPage({
params,
}: {
params: Promise<{ type: string; ver: string }>;
}) {
const { type, ver } = await params;
const config = await db.transaction(async (tx) => {
// resolve version info
const [version] = await tx
.select()
.from(tierlistVersions)
.where(
and(eq(tierlistVersions.type, type), eq(tierlistVersions.id, ver)),
);
if (!version) return;
const [typeInfo] = await tx
.select()
.from(tierlistTypes)
.where(eq(tierlistTypes.id, type));
// resolve `from` fields
const vers = await tx
.select({ id: versions.id, from: versions.from })
.from(versions);
const ids: string[] = [];
let cur: string | null = version.from;
while (cur) {
ids.push(cur);
const r = vers.find((v) => v.id === cur);
cur = r?.from ?? null;
}
const chars =
ids.length > 0
? await tx
.select()
.from(characters)
.where(inArray(characters.version, ids)).orderBy(characters.order)
: ([] as (typeof characters.$inferSelect)[]);
// fetch rows, columns, badges data...
const tiers = await tx
.select()
.from(tierlistTiers)
.orderBy(tierlistTiers.order);
const columns = await tx
.select()
.from(tierlistColumns)
.orderBy(tierlistColumns.order);
const badges = await tx
.select()
.from(tierlistBadges)
.orderBy(tierlistBadges.order);
return { type: typeInfo, version, tiers, columns, badges, chars };
});
// console.log(config);
if (!config) notFound();
return <TierList {...config} />;
// return <pre>{JSON.stringify(config, null, 2)}</pre>;
}
+357
View File
@@ -0,0 +1,357 @@
"use client";
import {
DndContext,
type DragEndEvent,
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { ChevronDown, ChevronUp } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import {
Fragment,
type ReactNode,
type RefObject,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useLocalStorage } from "usehooks-ts";
import Favicon from "#/favicon.webp";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type {
characters,
tierlistBadges,
tierlistColumns,
tierlistTiers,
tierlistTypes,
tierlistVersions,
} from "@/lib/db/schema";
import { TierListCell } from "./cell";
import { Draggable } from "./character";
import { TierListContext } from "./context";
function Untiered({
children,
ref,
open
}: {
children?: ReactNode;
ref?: RefObject<HTMLDivElement | null>;
open?: boolean;
}) {
const { tileSize } = useContext(TierListContext);
const { setNodeRef } = useDroppable({
id: "untiered",
});
return (
<div ref={ref}>
<div
ref={setNodeRef}
className={`flex flex-wrap ${open?"p-1":""} gap-2 bg-[#0005] overflow-auto`}
style={{
maxHeight: `${(tileSize + 4) * 3 + 4}px`,
}}
>
{children}
</div>
</div>
);
}
export function TierList({
type,
version,
chars,
tiers,
columns,
badges,
}: {
type: typeof tierlistTypes.$inferSelect;
version: typeof tierlistVersions.$inferSelect;
chars: (typeof characters.$inferSelect)[];
tiers: (typeof tierlistTiers.$inferSelect)[];
columns: (typeof tierlistColumns.$inferSelect)[];
badges: (typeof tierlistBadges.$inferSelect)[];
}) {
const [tileSizeSetting, setTileSize] = useLocalStorage<number | null>(
"tl_tileSize",
null,
);
const [tileSizeAuto, setTileSizeAuto] = useState(0);
const tileSize = tileSizeSetting || tileSizeAuto;
const colRef = useRef<HTMLDivElement | null>(null);
const untieredRef = useRef<HTMLDivElement | null>(null);
//#region tileSize calculation
// biome-ignore lint/correctness/useExhaustiveDependencies: falsy
useLayoutEffect(() => {
function recalc() {
let auto = 0;
let tiles = Math.ceil(24 / columns.length);
do {
if (!colRef.current || !untieredRef.current) return;
const colRect = colRef.current.getBoundingClientRect();
const untieredRect = untieredRef.current.getBoundingClientRect();
const colPaddingLeft = 4; // or measure with getComputedStyle(colRef.current)
const contPaddingLeft = 4;
const tilePaddingRight = 8;
// tiered (always 6 cols)
const tileSizeCol =
(colRect.width - colPaddingLeft) / tiles - tilePaddingRight;
// untiered → calculate how many columns fit with ~tileSizeCol
let x = Math.floor(
(untieredRect.width - contPaddingLeft) /
(tileSizeCol + tilePaddingRight),
);
if (x < 1) x = 1;
const tileSizeUntiered =
(untieredRect.width - contPaddingLeft) / x - tilePaddingRight;
console.log(tileSizeCol, tileSizeUntiered);
// final auto size
auto = Math.min(tileSizeCol, tileSizeUntiered);
tiles--;
} while (auto < 50 && tiles > 0);
setTileSizeAuto(auto);
}
const observer = new ResizeObserver(recalc);
if (colRef.current) observer.observe(colRef.current);
if (untieredRef.current) observer.observe(untieredRef.current);
// run once immediately
recalc();
return () => observer.disconnect();
}, [columns.length, colRef.current, untieredRef.current]);
//#endregion tileSize
const [untieredOpen, setUntieredOpen] = useLocalStorage(
"tl_untieredOpen",
true,
);
//#region placements handling
const initialPlacements: Record<string, string[]> = {
untiered: chars.map((ch) => ch.id),
...Object.fromEntries(
tiers.flatMap((t) =>
columns.map((c) => [`${t.id}-${c.id}`, [] as string[]]),
),
),
};
const [placements, setPlacements] = useLocalStorage<Record<string, string[]>>(
`tl_${type.id}_${version.id}_placements`,
initialPlacements,
);
const [dragging, setDragging] = useState<string | null>(null);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
const charId = active.id as string;
const target = over.id as string;
let source: string | undefined;
for (const cell in placements) {
if (placements[cell].includes(charId)) {
source = cell;
break;
}
}
if (!source || source === target) return;
setPlacements({
...placements,
[source]: placements[source].filter((id) => id !== charId),
[target]: [...placements[target], charId],
});
}
//#endregion placements
return (
<TierListContext.Provider value={{ badges, chars, tileSize }}>
<DndContext
onDragStart={(e) => setDragging(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col justify-between h-full">
<div className="flex-1 min-h-0 overflow-auto">
<div
className={`grid w-full [&>*]:border`}
style={{
gridTemplateColumns: `min-content repeat(${columns.length}, 1fr)`,
gridTemplateRows: `min-content repeat(${tiers.length}, minmax(0, min-content))`,
}}
>
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex flex-col font-bold items-center justify-center aspect-square w-min h-min whitespace-nowrap bg-[#2225] cursor-pointer">
<span>{type.name}</span>
<span>{version.name}</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link href="/tl">
<DropdownMenuItem>หน้าหลัก</DropdownMenuItem>
</Link>
<DialogTrigger asChild>
<DropdownMenuItem>การตั้งค่า</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>การตั้งค่า</DialogTitle>
</DialogHeader>
<div className="flex justify-between">
<div className="grid gap-2 group">
<span>
ขนาดตัวละคร{" "}
<span className="text-muted-foreground group-hover:opacity-100 opacity-0 transition-opacity">
(px)
</span>
</span>
<div className="flex gap-1">
<Input
value={tileSize}
onChange={(ev) =>
setTileSize(Number(ev.target.value))
}
type="number"
/>
<Button
onClick={() =>
setTileSize((x) => (x || tileSize) + 1)
}
variant="outline"
size="icon"
>
<ChevronUp />
</Button>
<Button
onClick={() =>
setTileSize((x) => (x || tileSize) - 1)
}
variant="outline"
size="icon"
>
<ChevronDown />
</Button>
</div>
</div>
<div>
<Image
src={Favicon}
alt="Test Image"
width={tileSize}
height={tileSize}
className="rounded"
/>
</div>
</div>
</DialogContent>
</Dialog>
{columns.map((c) => (
<div
key={`C${c.id}`}
className="bg-[#0005] text-2xl font-bold flex items-center justify-center relative"
ref={colRef}
>
{c.image ? (
<Image
src={`/cdn/${c.image}`}
alt={c.name}
fill
className="p-2 object-cover"
/>
) : (
c.name
)}
</div>
))}
{tiers.map((t) => (
<Fragment key={`T${t.id}`}>
<div className="bg-[#0005] text-4xl font-bold flex items-center justify-center">
{t.image ? (
<Image
src={`/cdn/${t.image}`}
alt={t.name}
height={100}
width={100}
/>
) : (
t.name
)}
</div>
{columns.map((c) => {
const cellId = `${t.id}-${c.id}`;
const cellChars = placements[cellId].map(
(id) => chars.find((ch) => ch.id === id)!,
);
return (
<TierListCell key={cellId} column={c} tier={t}>
{cellChars.map((ch) => (
<Draggable key={ch.id} char={ch} />
))}
</TierListCell>
);
})}
</Fragment>
))}
</div>
</div>
<div className="flex flex-col">
<Button
onClick={() => setUntieredOpen(!untieredOpen)}
variant="outline"
className="rounded-none"
>
({placements.untiered.length}) ตัวละครที่ยังไม่ได้จัดเทียร์
{untieredOpen ? (
<ChevronDown className="ml-1" />
) : (
<ChevronUp className="ml-1" />
)}
</Button>
<Untiered ref={untieredRef} open={untieredOpen}>
{untieredOpen &&
placements.untiered.map((id) => {
const ch = chars.find((c) => c.id === id)!;
return <Draggable key={ch.id} char={ch} />;
})}
</Untiered>
</div>
</div>
<DragOverlay>
{dragging && (
<Draggable char={chars.find((c) => c.id === dragging)!} />
)}
</DragOverlay>
</DndContext>
</TierListContext.Provider>
);
}
-68
View File
@@ -1,68 +0,0 @@
import { and, eq } from "drizzle-orm";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
export default async function TierlistSelectionPage({
params,
}: {
params: Promise<{ type: string }>;
}) {
const { type: typeName } = await params;
const [type] = await db
.select()
.from(tierlistTypes)
.where(and(eq(tierlistTypes.id, typeName)));
if (!type) notFound();
const versions = await db
.select()
.from(tierlistVersions)
.where(
and(
eq(tierlistVersions.hidden, false),
eq(tierlistVersions.type, typeName),
),
);
return (
<div className="w-full min-h-full flex justify-center">
<div className="min-h-full flex flex-col items-center justify-center gap-2">
<div className="grid gap-1">
<center className="font-bold text-4xl">{type.name}</center>
<div className="flex gap-2 flex-wrap justify-center">
{[
...versions.map((e) => ({ ...e, id: `${e.id}1` })),
...versions.map((e) => ({ ...e, id: `${e.id}2` })),
...versions.map((e) => ({ ...e, id: `${e.id}3` })),
...versions.map((e) => ({ ...e, id: `${e.id}4` })),
...versions.map((e) => ({ ...e, id: `${e.id}6` })),
].map((e) =>
e.image ? (
<Link href={`/tl/${type.id}/${e.id}`} key={e.id}>
<div className="relative aspect-video w-80 rounded-sm border">
<Image
src={`/cdn/${e.image}`}
alt={e.name}
fill
className="rounded-sm border object-cover bg-primary"
/>
<div className="flex absolute w-full justify-center bottom-0 py-1 bg-card rounded-b-sm">
{e.name}
</div>
</div>
</Link>
) : (
<Link href={`/tl/${type.id}/${e.id}`} key={e.id}>
<div className="flex items-center justify-center aspect-video w-80 border rounded-sm bg-[#1118] backdrop-blur-xl font-bold text-4xl">
{e.name}
</div>
</Link>
),
)}
</div>
</div>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { desc } from "drizzle-orm";
import Image from "next/image";
import Link from "next/link";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
export default async function TierlistSelectionPage() {
const types = await db.select().from(tierlistTypes).orderBy(tierlistTypes.id);
const versionsList = await db
.select()
.from(tierlistVersions)
.orderBy(desc(tierlistVersions.id));
const vers = types.map((t) => ({
...t,
versions: versionsList.filter((v) => v.type === t.id),
}));
return (
<div className="max-w-full min-h-full flex flex-col justify-center gap-2 mx-2">
<center className="text-6xl font-bold mb-8">
<Link href="/">จัดเทียร์ลิสต์</Link>
</center>
{vers.map((t) => (
<div className="flex flex-col gap-1" key={t.id}>
<div className="font-bold text-4xl">
<div className="px-2 py-1 border border-dashed w-min rounded-md bg-[#2228]">
{t.name}
</div>
</div>
<ScrollArea>
<div className="flex gap-2 max-w-full">
{t.versions.map((e) => (
<Link href={`/tl/${t.id}/${e.id}`} key={e.id}>
{e.image ? (
<div className="relative aspect-video w-60 rounded-sm border">
<Image
src={`/cdn/${e.image}`}
alt={e.name}
fill
className="rounded-sm border object-cover bg-primary"
/>
<div className="flex absolute w-full justify-center bottom-0 py-1 bg-card rounded-b-sm">
{e.name}
</div>
</div>
) : (
<div className="flex items-center justify-center aspect-video w-60 border rounded-sm bg-[#1118] backdrop-blur-xl font-bold text-4xl">
{e.name}
</div>
)}
</Link>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
))}
</div>
);
}
+23 -11
View File
@@ -5,6 +5,7 @@
"name": "buzzy",
"dependencies": {
"@bprogress/next": "^3.2.12",
"@dnd-kit/core": "^6.3.1",
"@headless-tree/core": "^1.4.0",
"@headless-tree/react": "^1.4.0",
"@hookform/resolvers": "^5.2.2",
@@ -44,7 +45,7 @@
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"next": "^15.5.4",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"radix-ui": "^1.4.3",
@@ -56,6 +57,7 @@
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1",
"uuidv7": "^1.0.2",
"vaul": "^1.1.2",
"zod": "^4.1.11",
@@ -197,6 +199,12 @@
"@date-fns/tz": ["@date-fns/[email protected]", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@drizzle-team/brocli": ["@drizzle-team/[email protected]", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
@@ -335,23 +343,23 @@
"@mrleebo/prisma-ast": ["@mrleebo/[email protected]", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-VxS+okLpNp6YUDLz3rAXv6sE8G4W5cX039bumkPub65xRr9BFxjHaT5ebX4DsiixIGaBwitjWTvx3mAbyI2EUQ=="],
"@next/env": ["@next/[email protected].3", "", {}, "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw=="],
"@next/env": ["@next/[email protected].4", "", {}, "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A=="],
"@next/swc-darwin-arm64": ["@next/[email protected].3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg=="],
"@next/swc-darwin-arm64": ["@next/[email protected].4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA=="],
"@next/swc-darwin-x64": ["@next/[email protected].3", "", { "os": "darwin", "cpu": "x64" }, "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g=="],
"@next/swc-darwin-x64": ["@next/[email protected].4", "", { "os": "darwin", "cpu": "x64" }, "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA=="],
"@next/swc-linux-arm64-gnu": ["@next/[email protected].3", "", { "os": "linux", "cpu": "arm64" }, "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw=="],
"@next/swc-linux-arm64-gnu": ["@next/[email protected].4", "", { "os": "linux", "cpu": "arm64" }, "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA=="],
"@next/swc-linux-arm64-musl": ["@next/[email protected].3", "", { "os": "linux", "cpu": "arm64" }, "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ=="],
"@next/swc-linux-arm64-musl": ["@next/[email protected].4", "", { "os": "linux", "cpu": "arm64" }, "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A=="],
"@next/swc-linux-x64-gnu": ["@next/[email protected].3", "", { "os": "linux", "cpu": "x64" }, "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA=="],
"@next/swc-linux-x64-gnu": ["@next/[email protected].4", "", { "os": "linux", "cpu": "x64" }, "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA=="],
"@next/swc-linux-x64-musl": ["@next/[email protected].3", "", { "os": "linux", "cpu": "x64" }, "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg=="],
"@next/swc-linux-x64-musl": ["@next/[email protected].4", "", { "os": "linux", "cpu": "x64" }, "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw=="],
"@next/swc-win32-arm64-msvc": ["@next/[email protected].3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA=="],
"@next/swc-win32-arm64-msvc": ["@next/[email protected].4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA=="],
"@next/swc-win32-x64-msvc": ["@next/[email protected].3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
"@next/swc-win32-x64-msvc": ["@next/[email protected].4", "", { "os": "win32", "cpu": "x64" }, "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg=="],
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
@@ -767,6 +775,8 @@
"lodash": ["[email protected]", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.debounce": ["[email protected]", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -785,7 +795,7 @@
"nanostores": ["[email protected]", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
"next": ["[email protected].3", "", { "dependencies": { "@next/env": "15.5.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-x64": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-musl": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.3", "sharp": "^0.34.3" }, "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-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw=="],
"next": ["[email protected].4", "", { "dependencies": { "@next/env": "15.5.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": "15.5.4", "@next/swc-darwin-x64": "15.5.4", "@next/swc-linux-arm64-gnu": "15.5.4", "@next/swc-linux-arm64-musl": "15.5.4", "@next/swc-linux-x64-gnu": "15.5.4", "@next/swc-linux-x64-musl": "15.5.4", "@next/swc-win32-arm64-msvc": "15.5.4", "@next/swc-win32-x64-msvc": "15.5.4", "sharp": "^0.34.3" }, "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-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA=="],
"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=="],
@@ -943,6 +953,8 @@
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"usehooks-ts": ["[email protected]", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
"uuidv7": ["[email protected]", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA=="],
"vaul": ["[email protected]", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
+1 -1
View File
@@ -12,7 +12,7 @@ export function Blocker({
return (
<div
className={cn(
"blocker backdrop-blur-sm bg-[#2225] flex justify-center items-center absolute top-[-5px] left-[-5px] bottom-[-5px] right-[-5px] rounded-sm z-100 border-t-gray-700 border-l-gray-600 border-1",
"blocker backdrop-blur-sm bg-[#2225] flex justify-center items-center absolute top-[-5px] left-[-5px] bottom-[-5px] right-[-5px] rounded-sm z-45 border-t-gray-700 border-l-gray-600 border-1",
className,
)}
{...props}
+20 -21
View File
@@ -1,15 +1,14 @@
"use client"
"use client";
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
@@ -17,7 +16,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
);
}
function AlertDialogPortal({
@@ -25,7 +24,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
);
}
function AlertDialogOverlay({
@@ -37,11 +36,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -55,12 +54,12 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
className,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
@@ -73,7 +72,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function AlertDialogFooter({
@@ -85,11 +84,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
@@ -102,7 +101,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -115,7 +114,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -127,7 +126,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)}
{...props}
/>
)
);
}
function AlertDialogCancel({
@@ -139,7 +138,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
);
}
export {
@@ -154,4 +153,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};
+45 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { eq, inArray, not, sql } from "drizzle-orm";
import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiAuthCheck } from "./auth";
@@ -77,6 +78,7 @@ export async function toggleCheck(submissionId: string) {
})
.where(eq(submissions.id, submissionId));
revalidatePath("/artifact/admin");
revalidatePath("/artifact");
}
export async function toggleLock() {
@@ -93,7 +95,7 @@ export async function toggleLock() {
)
await db.insert(artifactSettings).values({ locked: true });
revalidatePath("/artifact/admin");
revalidatePath("/");
revalidatePath("/artifact");
}
export async function setLimit(limit: number) {
@@ -111,7 +113,7 @@ export async function setLimit(limit: number) {
)
await db.insert(artifactSettings).values({ limit });
revalidatePath("/artifact/admin");
revalidatePath("/");
revalidatePath("/artifact");
}
export async function wipe() {
@@ -121,6 +123,7 @@ export async function wipe() {
sql`ALTER SEQUENCE artifact.submissions_queue_seq RESTART WITH 1`,
);
revalidatePath("/artifact/admin");
revalidatePath("/artifact");
redirect("/artifact/admin");
}
@@ -135,3 +138,43 @@ export async function random() {
if (sub) redirect(`/artifact/admin/${sub.id}`);
else throw "ไม่พบผู้ลงทะเบียนที่ยังไม่ตรวจสอบ";
}
export async function reorder(
table: PgTable & { id: PgColumn; order: PgColumn },
id: string,
position: number,
) {
await db
.update(table)
.set({
order: sql`
(
SELECT (prev_order + next_order) / 2
FROM (
SELECT "order",
lag("order") OVER (ORDER BY "order") AS prev_order,
lead("order") OVER (ORDER BY "order") AS next_order,
row_number() OVER (ORDER BY "order") AS rn
FROM ${table}
) sub
WHERE rn = ${position}
)
`,
})
.where(eq(table.id, id));
}
export async function untangle(
table: PgTable & { id: PgColumn; order: PgColumn },
) {
await db.execute(sql`
WITH ordered AS (
SELECT id, row_number() OVER (ORDER BY "order") * 10 AS new_order
FROM ${table}
)
UPDATE ${table} t
SET "order" = o.new_order
FROM ordered o
WHERE t.id = o.id;
`);
}
+8 -3
View File
@@ -7,9 +7,14 @@ import * as schema from "$/db/schema";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
// disableSignUp: true,
disableSignUp: true,
},
trustedOrigins: ["http://nyx:3000"],
trustedOrigins: [
"http://nyx:3000",
"http://astral:3000",
"http://localhost:3000",
"https://dev3000.dgnr.us",
],
database: drizzleAdapter(db, {
provider: "pg", // or "mysql", "sqlite"
schema,
@@ -23,7 +28,7 @@ export async function apiAuthCheck() {
headers: await headers(),
});
console.log(session);
// console.log(session);
return session?.user;
}
+23 -14
View File
@@ -1,14 +1,15 @@
import {
type AnyPgColumn,
boolean,
date,
integer,
pgEnum,
pgSchema,
pgTable,
serial,
text,
timestamp,
type AnyPgColumn,
boolean,
date,
integer,
numeric,
pgEnum,
pgSchema,
pgTable,
serial,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
import { bytea } from "./custom";
@@ -27,6 +28,7 @@ export const element = pgEnum("character_element", [
export const cdn = pgTable("cdn", {
id: text().primaryKey().$defaultFn(uuidv7),
data: bytea().notNull(),
name: text(),
type: text().notNull(),
size: text().notNull(),
});
@@ -50,6 +52,7 @@ export const characters = pgTable("characters", {
.references(() => cdn.id),
weapon: text().notNull(),
amber: text().notNull(), // Amber character ID
order: numeric().notNull(),
});
export const versions = pgTable("versions", {
@@ -95,27 +98,30 @@ export const tierlist = pgSchema("tierlist");
export const tierlistTypes = tierlist.table("types", {
id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(),
image: text()
.notNull()
.references(() => cdn.id),
image: text().references(() => cdn.id),
order: numeric().notNull(),
});
export const tierlistTiers = tierlist.table("tiers", {
id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(),
badges: text().references(()=>tierlistBadges.id).array(),
image: text().references(() => cdn.id),
order: numeric().notNull(),
});
export const tierlistColumns = tierlist.table("columns", {
id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(),
image: text().references(() => cdn.id),
order: numeric().notNull(),
});
export const tierlistBadges = tierlist.table("badges", {
id: text().primaryKey().$defaultFn(uuidv7),
name: text().notNull(),
image: text().references(() => cdn.id),
order: numeric().notNull(),
});
export const tierlistVersions = tierlist.table("versions", {
@@ -126,6 +132,8 @@ export const tierlistVersions = tierlist.table("versions", {
.notNull()
.references(() => tierlistTypes.id),
image: text().references(() => cdn.id),
from: text().notNull().references(()=>versions.id),
order: numeric().notNull(),
});
export const tierlistStates = tierlist.table("states", {
@@ -138,7 +146,7 @@ export const tierlistStates = tierlist.table("states", {
.references(() => tierlistVersions.id),
tier: text()
.notNull()
.references(() => tierlistVersions.id),
.references(() => tierlistTiers.id),
column: text()
.notNull()
.references(() => tierlistColumns.id),
@@ -147,6 +155,7 @@ export const tierlistStates = tierlist.table("states", {
.references(() => tierlistBadges.id)
.array(4)
.default([]),
order: numeric().notNull(),
});
//#endregion
+1 -1
View File
@@ -92,4 +92,4 @@ export class EventSourceManager {
}
}
export const sse = new EventSourceManager();
export const sse = new EventSourceManager();
+1 -1
View File
@@ -20,5 +20,5 @@ export async function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/artifact/admin/:path*"], // Specify the routes the middleware applies to
matcher: ["/artifact/admin/:path*", "/tl/:path*/:path*/admin"], // Specify the routes the middleware applies to
};
+3 -1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@bprogress/next": "^3.2.12",
"@dnd-kit/core": "^6.3.1",
"@headless-tree/core": "^1.4.0",
"@headless-tree/react": "^1.4.0",
"@hookform/resolvers": "^5.2.2",
@@ -53,7 +54,7 @@
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"next": "^15.5.4",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"radix-ui": "^1.4.3",
@@ -65,6 +66,7 @@
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1",
"uuidv7": "^1.0.2",
"vaul": "^1.1.2",
"zod": "^4.1.11"
+27 -12
View File
@@ -1,3 +1,4 @@
import { eq } from "drizzle-orm";
import { AmberElementMap } from "@/lib/const";
import { db } from "@/lib/db";
import { cdn, characters, versions } from "@/lib/db/schema";
@@ -10,16 +11,27 @@ async function main() {
await db.transaction(async (tx) => {
console.log("Cleaning up...");
await tx.delete(characters);
await tx.delete(cdn);
await tx.delete(versions);
console.log("Creating base version...");
const [{ id: version }] = await tx
.insert(versions)
.values({
id: "b10",
name: "Base 1.0",
})
.returning({ id: versions.id });
// await tx.delete(cdn);
const existingBase = (
await tx
.select({ id: versions.id })
.from(versions)
.where(eq(versions.id, "b10"))
).length;
console.log(
existingBase
? "Base version already exist. Skipping creation."
: "Creating base version...",
);
const [{ id: version }] = existingBase
? await tx.select({ id: versions.id }).from(versions)
: await tx
.insert(versions)
.values({
id: "b10",
name: "Base 1.0",
})
.returning({ id: versions.id });
process.stdout.write("Syncing characters...");
const total = Object.keys(data.data.items).length * 3;
var done = 0;
@@ -33,7 +45,7 @@ async function main() {
);
return p;
}
async function handle(char: Avatar) {
async function handle(char: Avatar, order: string) {
await tx
.insert(characters)
.values({
@@ -56,14 +68,17 @@ async function main() {
stars: char.rank as 4 | 5,
version,
weapon: char.weaponType,
order,
})
.onConflictDoNothing();
}
let i = 0;
for (const char of Object.values(data.data.items)) {
i += 10;
process.stdout.write(
`\rSyncing characters... ${char.name} `,
);
pros.push(handle(char).then(progress));
pros.push(handle(char, `${i}`).then(progress));
}
await Promise.all(pros);
console.log("\nDone!");