initial admin

This commit is contained in:
Dreamgineer
2025-10-15 01:03:31 -04:00 Unverified
parent ba6143f63b
commit fc97ebc35f
99 changed files with 2351 additions and 1408 deletions
+12
View File
@@ -0,0 +1,12 @@
import { DataTable } from "@/components/tantable";
import { db } from "@/lib/db";
import { cdn } from "@/lib/db/schema";
import { cdnColumns } from "./table";
export default async function CdnManagerPage() {
const files = await db
.select({ id: cdn.id, name: cdn.name, size: cdn.size })
.from(cdn)
.orderBy(cdn.name);
return <DataTable columns={cdnColumns} data={files} />;
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
export const cdnColumns: ColumnDef<{
id: string;
name: string | null;
size: string;
}>[] = [
{
accessorFn: (row) => row.name || `[${row.id}]`,
header: "Name",
},
{
accessorFn: (row) => b2s(Number(row.size)),
header: "Size",
},
];
const b2s = (t: number) => {
let e = (Math.log2(t) / 10) | 0;
// biome-ignore lint/suspicious/noAssignInExpressions: copied
return `${(t / 1024 ** (e = e <= 0 ? 0 : e)).toFixed(1)}${" KMGP"[e]}B`;
};
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
export function VersionCreateDialogForm() {
return <form>Todo</form>;
}
export function RandomWelcomeMessage({ list }: { list?: string[] }) {
const messages = list || [
"What are we cooking today?",
"What are we doing today?",
"Why are you here?",
"The food is ready sir.",
"Who left the artifact stats like that?",
"Don’t forget to hydrate, boss.",
"Another day, another reroll.",
"Still reviewing artifacts? Respect.",
"Let's make these builds shine.",
"You came back… impressive.",
"Processing luck… 0.3% found.",
"Someone's gotta keep this event alive.",
"New submissions incoming!",
"Welcome back, brave reviewer.",
"Did you bring Mora this time?",
"Your approval power is unmatched.",
"Remember: critique kindly, roast fairly.",
"Time to bless—or curse—some artifacts.",
"Don’t worry, no 5★ drama yet.",
"Admin mode: activated.",
];
const [message, setMessage] = useState("");
// biome-ignore lint/correctness/useExhaustiveDependencies: false
useEffect(
() => setMessage(messages[Math.floor(Math.random() * messages.length)]),
[messages.length],
);
return message;
}
+252
View File
@@ -0,0 +1,252 @@
import {
Badge,
BookUser,
Columns3Cog,
Computer,
Database,
GitGraph,
ListTree,
MoreHorizontal,
Package,
PlusCircle,
ScrollText,
Settings,
SquareUserRound,
UserRoundSearch,
} from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { SimpleTooltip } from "@/components/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar";
import { NavUser } from "@/components/user";
import { db } from "@/lib/db";
import { versions } from "@/lib/db/schema";
import { VersionCreateDialogForm } from "./client";
export default async function AdminLayout({
children,
}: {
children: ReactNode;
}) {
const vers = await db
.select()
.from(versions)
.catch(() => [{ id: "..", name: "Error Fetching List." }]);
const health: { database: boolean; enka: boolean; amber: boolean } =
await fetch("http://localhost:3000/api/health").then((r) => r.json());
return (
<SidebarProvider>
<Sidebar variant="floating">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<Link href="/admin">
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<span>
<Computer className="!size-5" />
<span className="text-base font-semibold">Buzzy Inc.</span>
</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="pb-0">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<div className="flex justify-between w-full">
<div className="flex gap-1 [&>svg]:size-4 [&>svg]:shrink-0">
{/* Database */}
<SimpleTooltip text="Database">
<Database
className={
health.database
? "text-emerald-400"
: "text-red-400"
}
/>
</SimpleTooltip>{" "}
{/* Enka */}
<SimpleTooltip text="Enka Network API">
<UserRoundSearch
className={
health.enka ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>{" "}
{/* Amber */}
<SimpleTooltip text="Project Amber">
<BookUser
className={
health.amber ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>
</div>
<span>
{Object.values(health).some((x) => !x)
? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา`
: "ปกติทุกอย่าง"}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Tierlist</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<Link href="/admin/tl/ver">
<SidebarMenuButton>
<GitGraph />
Versions
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/tl/badges">
<SidebarMenuButton>
<Badge />
Badges
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/tl/layout">
<SidebarMenuButton>
<Columns3Cog />
Layout
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Versions</SidebarGroupLabel>
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarGroupAction>
<MoreHorizontal />
</SidebarGroupAction>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger asChild>
<DropdownMenuItem>
<PlusCircle /> Create...
</DropdownMenuItem>
</DialogTrigger>
<Link href="/admin/ver/types">
<DropdownMenuItem>
<ListTree /> Types
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Version</DialogTitle>
</DialogHeader>
<VersionCreateDialogForm />
</DialogContent>
</Dialog>
<SidebarGroupContent>
<SidebarMenu>
{vers.map((v) => (
<SidebarMenuItem key={v.id}>
<Link href={`/admin/ver/${v.id}`}>
<SidebarMenuButton>{v.name}</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarGroup className="mt-auto">
<SidebarGroupLabel>Global</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<Link href="/admin/char">
<SidebarMenuButton>
<SquareUserRound />
Characters
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/cdn">
<SidebarMenuButton>
<Package />
CDN
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem className="mt-auto">
<Link href="/admin/log">
<SidebarMenuButton>
<ScrollText />
Audit Log
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/settings">
<SidebarMenuButton>
<Settings />
Settings
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset className="bg-transparent">{children}</SidebarInset>
</SidebarProvider>
);
}
export const dynamic = "force-dynamic";
+14
View File
@@ -0,0 +1,14 @@
import { RandomWelcomeMessage } from "./client";
export default function AdminPage() {
return (
<div className="flex h-full w-full justify-center items-center">
<div className="flex flex-col">
<span className="opacity-80 text-4xl font-bold">Welcome back!</span>
<span>
<RandomWelcomeMessage />
</span>
</div>
</div>
);
}
+8 -5
View File
@@ -1,9 +1,12 @@
export const revalidate = 3600;
export async function GET() {
return new Response(await fetch("https://gi.yatta.moe/api/v2/en/avatar").then(e => e.text()), {
headers: {
"Content-Type": "application/json"
}
});
return new Response(
await fetch("https://gi.yatta.moe/api/v2/en/avatar").then((e) => e.text()),
{
headers: {
"Content-Type": "application/json",
},
},
);
}
+10 -5
View File
@@ -1,9 +1,14 @@
export const revalidate = 3600;
export async function GET() {
return new Response(await fetch("https://gi.yatta.moe/api/v2/static/changelog").then(e => e.text()), {
headers: {
"Content-Type": "application/json"
}
});
return new Response(
await fetch("https://gi.yatta.moe/api/v2/static/changelog").then((e) =>
e.text(),
),
{
headers: {
"Content-Type": "application/json",
},
},
);
}
+23
View File
@@ -0,0 +1,23 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
export const revalidate = 900;
export async function GET() {
const database = await db
.execute(sql<boolean>`select true`)
.then(() => true)
.catch(() => false);
const enka = await fetch("https://enka.network/api/uid/888888888/?info", {
cache: "force-cache",
headers: { "User-Agent": "Buzz Event Platform(Health Checker)" },
})
.then((r) => r.ok)
.catch(() => false);
const amber = await fetch("https://gi.yatta.moe/api/v2/en/avatar", {
cache: "force-cache",
})
.then((r) => r.ok)
.catch(() => false);
return Response.json({ database, enka, amber });
}
+47 -45
View File
@@ -2,52 +2,54 @@ import { NextResponse } from "next/server";
export const revalidate = 900;
export type YoutubeLiveInfo = {
url: string;
thumbnails: {
url: string;
width: number;
height: number;
}
} | "none";
export type YoutubeLiveInfo =
| {
url: string;
thumbnails: {
url: string;
width: number;
height: number;
};
}
| "none";
type APISearchResource = {
"kind": "youtube#searchResult",
"etag": string,
"id": {
"kind": string,
"videoId": string,
"channelId": string,
"playlistId": string
},
"snippet": {
"publishedAt": string,
"channelId": string,
"title": string,
"description": string,
"thumbnails": {
kind: "youtube#searchResult";
etag: string;
id: {
kind: string;
videoId: string;
channelId: string;
playlistId: string;
};
snippet: {
publishedAt: string;
channelId: string;
title: string;
description: string;
thumbnails: {
[key: string]: {
"url": string,
"width": number,
"height": number
}
},
"channelTitle": string,
"liveBroadcastContent": string
}
url: string;
width: number;
height: number;
};
};
channelTitle: string;
liveBroadcastContent: string;
};
};
type APISearchResponse = {
"kind": "youtube#searchListResponse",
"etag": string,
"nextPageToken": string,
"prevPageToken": string,
"regionCode": string,
"pageInfo": {
"totalResults": number,
"resultsPerPage": number
},
"items": APISearchResource[],
kind: "youtube#searchListResponse";
etag: string;
nextPageToken: string;
prevPageToken: string;
regionCode: string;
pageInfo: {
totalResults: number;
resultsPerPage: number;
};
items: APISearchResource[];
};
export async function GET() {
@@ -61,18 +63,18 @@ export async function GET() {
revalidate: 900, // refresh every 15 minutes
},
cache: "force-cache",
}
},
);
if (!response.ok)
throw new Error(`YouTube API error: ${response.statusText}`);
const data: APISearchResponse = await response.json();
if (data.items.length <= 0)
return NextResponse.json<YoutubeLiveInfo>("none");
if (data.items.length <= 0) return NextResponse.json<YoutubeLiveInfo>("none");
const live = data.items[0];
return NextResponse.json<YoutubeLiveInfo>({
url: `https://www.youtube.com/watch?v=${live.id.videoId}`,
thumbnails: live.snippet.thumbnails[Object.keys(live.snippet.thumbnails)[0]]
thumbnails:
live.snippet.thumbnails[Object.keys(live.snippet.thumbnails)[0]],
});
}
}
+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: "(ฝั่งแอดมิน)" });
}
+4 -2
View File
@@ -171,6 +171,8 @@ export function Disclaimer() {
}
export async function ClearCookie() {
cookieStore.delete("sid");
return <></>;
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
// @ts-ignore
cookieStore.delete("sid");
return "";
}
+5 -1
View File
@@ -10,7 +10,11 @@ export function ArtifactFormWrapper(props: React.ComponentProps<"form">) {
submitArtifact(data)
.then((q) => {
if (typeof q === "string") return toast.error(q);
cookieStore.set("sid", q.id);
try {
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
// @ts-ignore
cookieStore.set("sid", q.id);
} catch {}
router.refresh();
})
.catch((e) => toast.error(`${e.message || e}`));
+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: "" });
}
+26
View File
@@ -142,3 +142,29 @@ input[type="number"] {
scrollbar-width: thin;
scrollbar-color: var(--color-blue-500) var(--card);
}
@theme {
--animate-phonerotate: phonerotate 3s ease infinite;
@keyframes phonerotate {
0% {
transform: rotate(0deg);
opacity: 0%;
}
10% {
opacity: 100%;
}
40% {
transform: rotate(0deg);
}
60% {
transform: rotate(90deg);
}
90% {
opacity: 100%;
}
100% {
transform: rotate(90deg);
opacity: 0%;
}
}
}
+4 -4
View File
@@ -17,14 +17,14 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
metadataBase: new URL("https://buzz.gunshiz.top"),
metadataBase: new URL(process.env.BASE_URL || "https://buzz.gunshiz.top"),
title: "เกนชินไม่ใช่เกมมือถือ",
description: "ระบบอีเวนท์ของเกนชินไม่ใช่เกมมือถือ",
icons: "https://buzzy.gunshiz.top/favicon.webp", // TODO: CHANGE BACK TO buzz
icons: `/favicon.webp`, // TODO: CHANGE BACK TO buzz
openGraph: {
title: "เกนชินไม่ใช่เกมมือถือ",
description: "ระบบอีเวนท์ของเกนชินไม่ใช่เกมมือถือ",
url: "https://buzz.gunshiz.top",
url: `/`,
siteName: "เกนชินไม่ใช่เกมมือถือ",
locale: "th_TH",
type: "website",
@@ -47,7 +47,7 @@ export default function RootLayout({
className="z-[-1] opacity-20 object-cover fixed top-0 left-0 w-full h-full"
/>
<Providers>{children}</Providers>
<Toaster theme="dark" />
<Toaster theme="dark" richColors />
</body>
</html>
);
+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: "" });
}
+10 -5
View File
@@ -31,18 +31,23 @@ export default function Home() {
<div className="flex flex-col gap-2">
<Item variant="outline" asChild>
<Link href="/artifact">
<ItemMedia>
<ItemMedia className="justify-center">
<Image
src={ArtifactLogo}
alt="เสือกไอดีชาวบ้าน"
className="w-22"
className="w-[calc(100svw-112px)] md:w-22"
width={500}
height={500}
/>
</ItemMedia>
<ItemContent>
<ItemTitle>เสือกไอดีชาวบ้าน</ItemTitle>
<ItemDescription className="line-clamp-none">
<ItemTitle>
<span className="hidden md:block">เสือกไอดีชาวบ้าน</span>
<span className="block md:hidden">
ตรวจแฟลกเกนชินรายแพทช์
</span>
</ItemTitle>
<ItemDescription className="line-clamp-none hidden md:block">
ใครอยากให้ดูอาร์ติแฟกต์ให้ในไลฟ์ ไปกรอกแบบฟอร์มได้เลย
</ItemDescription>
</ItemContent>
@@ -65,7 +70,7 @@ export default function Home() {
</ItemMedia>
<ItemContent>
<ItemTitle>จัดเทียร์ลิสต์</ItemTitle>
<ItemDescription className="line-clamp-none">
<ItemDescription className="line-clamp-none hidden md:block">
เทียร์ลิสต์เมต้าตัวละครของอบิสแพทช์นั้นๆเท่านั้น
ซึ่งจะเปลี่ยนไปในแต่ละแพทช์ตามความสามารถของตัวละครในการรับมือกับอบิสแพทช์นั้นๆ
ไม่ใช่เทียร์ลิสต์ภาพรวมของตัวละคร
+54 -11
View File
@@ -1,6 +1,13 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { type DraggableAttributes, useDroppable } from "@dnd-kit/core";
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
import {
rectSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { type ReactNode, useContext } from "react";
import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
import { TierListContext } from "./context";
@@ -9,26 +16,62 @@ export function TierListCell({
tier,
column,
children,
items, // Array of character IDs in this cell
}: {
tier: typeof tierlistTiers.$inferSelect;
column: typeof tierlistColumns.$inferSelect;
children?: ReactNode;
items: string[]; // Add this prop
}) {
const { tileSize } = useContext(TierListContext);
const cellId = `${tier.id}-${column.id}`;
const { setNodeRef } = useDroppable({
id: `${tier.id}-${column.id}`,
id: cellId,
});
return (
<div
ref={setNodeRef}
className="flex flex-wrap p-1 gap-2 items-start"
style={{
minHeight: `${tileSize + 8}px`,
}}
>
{children}
</div>
<SortableContext id={cellId} items={items} strategy={rectSortingStrategy}>
<div
ref={setNodeRef}
className="flex flex-wrap p-1 gap-2 items-start"
style={{
minHeight: `${tileSize + 8}px`,
}}
>
{children}
</div>
</SortableContext>
);
}
// Wrapper component for sortable draggable items
export function SortableDraggable({
id,
children,
}: {
id: string;
children: (props: {
listeners: SyntheticListenerMap | undefined;
attributes: DraggableAttributes | undefined;
setNodeRef: (node: HTMLElement | null) => void;
style: React.CSSProperties | undefined;
}) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return children({ listeners, attributes, setNodeRef, style });
}
+145 -142
View File
@@ -1,6 +1,5 @@
"use client";
import { useDraggable } from "@dnd-kit/core";
import Image from "next/image";
import Link from "next/link";
import { useContext, useEffect, useState } from "react";
@@ -15,6 +14,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import type { characters, tierlistStates } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { SortableDraggable } from "./cell";
import { TierListContext } from "./context";
export function Draggable({
@@ -29,15 +29,6 @@ export function Draggable({
const { tileSize, badgeSize, badges, setState, editable } =
useContext(TierListContext);
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: char.id,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
const [panelOpen, setPanelOpen] = useState(false);
const [comment, setComment] = useState("");
@@ -69,140 +60,152 @@ export function Draggable({
}
return (
<Popover open={panelOpen} onOpenChange={setPanelOpen}>
<PopoverTrigger asChild>
<button
ref={setNodeRef}
style={style}
suppressHydrationWarning
{...listeners}
{...attributes}
onContextMenu={(ev) => {
ev.preventDefault();
setPanelOpen((x) => !x);
}}
>
<div className="relative">
<SimpleTooltip text={char.name} delayDuration={1000}>
<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 hover:brightness-110"
alt={char.name}
height={tileSize}
width={tileSize}
/>
</SimpleTooltip>
{[
"bottom-0.5 right-0.5",
"bottom-0.5 left-0.5",
"top-0.5 right-0.5",
"top-0.5 left-0.5",
].map((p, i) => {
if (!assignedBadges[i]) return "";
const badge = badges.find((e) => e.id === assignedBadges[i]);
if (!badge) return "";
return badge.image ? (
<Image
key={p}
src={`/cdn/${badge.image}`}
className={`bg-[#2225] backdrop-blur-sm ${p} absolute rounded border`}
alt="badge"
width={badgeSize}
height={badgeSize}
/>
) : (
<div
key={p}
style={{ width: badgeSize, height: badgeSize }}
className={`bg-[#2225] backdrop-blur-sm ${p} absolute rounded border font-bold`}
>
{badge.name}
</div>
);
})}
</div>
</button>
</PopoverTrigger>
<PopoverContent side="right" className="bg-[#2225] backdrop-blur-md p-1">
<div className="flex gap-2">
{editable && (
<div className="flex flex-col justify-between shrink-0 gap-2">
<div className="grid grid-cols-2 gap-1 h-min items-stretch">
{badges
.filter(
(b) =>
assignedBadges.includes(b.id) ||
!b.tier.length ||
!tier ||
b.tier.includes(tier),
)
.map((e) =>
e.image ? (
<Image
src={`/cdn/${e.image}`}
alt={e.name}
key={`B${e.id}`}
width={32}
height={32}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 cursor-pointer",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
/>
) : (
<button
key={`B${e.id}`}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 flex justify-center items-center font-bold cursor-pointer min-w-8 min-h-8",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
type="button"
>
{e.name}
</button>
),
)}
<SortableDraggable id={char.id}>
{({ listeners, attributes, setNodeRef, style }) => (
<Popover open={panelOpen} onOpenChange={setPanelOpen}>
<PopoverTrigger asChild>
<button
ref={setNodeRef}
style={style}
suppressHydrationWarning
{...listeners}
{...attributes}
onContextMenu={(ev) => {
ev.preventDefault();
setPanelOpen((x) => !x);
}}
>
<div className="relative">
<SimpleTooltip text={char.name} delayDuration={1000}>
<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 hover:brightness-110"
alt={char.name}
height={tileSize}
width={tileSize}
/>
</SimpleTooltip>
{[
"bottom-0.5 right-0.5",
"bottom-0.5 left-0.5",
"top-0.5 right-0.5",
"top-0.5 left-0.5",
].map((p, i) => {
if (!assignedBadges[i]) return "";
const badge = badges.find((e) => e.id === assignedBadges[i]);
if (!badge) return "";
return badge.image ? (
<Image
key={p}
src={`/cdn/${badge.image}`}
className={`bg-[#2225] backdrop-blur-sm ${p} absolute rounded border`}
alt="badge"
width={badgeSize}
height={badgeSize}
/>
) : (
<div
key={p}
style={{ width: badgeSize, height: badgeSize }}
className={`bg-[#2225] backdrop-blur-sm ${p} absolute rounded border font-bold`}
>
{badge.name}
</div>
);
})}
</div>
<Link
href={`https://gi.yatta.moe/en/archive/avatar/${char.amber}/${char.name.replace(/ /g, "-").toLowerCase()}`}
target="_blank"
rel="noreferrer noopener"
>
<Button variant="outline" className="p-1 w-full">
<Image src={AmberIcon} alt="Amber" width={16} height={16} />
<div className="flex flex-col items-start">
<span className="text-[8px]">เปิดใน</span>
<span className="text-[10px]">Amber</span>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
className="bg-[#2225] backdrop-blur-md p-1"
>
<div className="flex gap-2">
{editable && (
<div className="flex flex-col justify-between shrink-0 gap-2">
<div className="grid grid-cols-2 gap-1 h-min items-stretch">
{badges
.filter(
(b) =>
assignedBadges.includes(b.id) ||
!b.tier.length ||
!tier ||
b.tier.includes(tier),
)
.map((e) =>
e.image ? (
<Image
src={`/cdn/${e.image}`}
alt={e.name}
key={`B${e.id}`}
width={32}
height={32}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 cursor-pointer",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
/>
) : (
<button
key={`B${e.id}`}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 flex justify-center items-center font-bold cursor-pointer min-w-8 min-h-8",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
type="button"
>
{e.name}
</button>
),
)}
</div>
</Button>
</Link>
<Link
href={`https://gi.yatta.moe/en/archive/avatar/${char.amber}/${char.name.replace(/ /g, "-").toLowerCase()}`}
target="_blank"
rel="noreferrer noopener"
>
<Button variant="outline" className="p-1 w-full">
<Image
src={AmberIcon}
alt="Amber"
width={16}
height={16}
/>
<div className="flex flex-col items-start">
<span className="text-[8px]">เปิดใน</span>
<span className="text-[10px]">Amber</span>
</div>
</Button>
</Link>
</div>
)}
<Textarea
placeholder="Comment..."
className="aspect-square bg-card resize-none"
value={comment}
onChange={(ev) => {
setDirty(500);
setComment(ev.target.value);
}}
disabled={!editable}
/>
</div>
)}
<Textarea
placeholder="Comment..."
className="aspect-square bg-card resize-none"
value={comment}
onChange={(ev) => {
setDirty(500);
setComment(ev.target.value);
}}
disabled={!editable}
/>
</div>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
)}
</SortableDraggable>
);
}
+10 -3
View File
@@ -1,13 +1,20 @@
"use client";
import { createContext } from "react";
import type { characters, tierlistBadges, tierlistStates } from "@/lib/db/schema";
import type {
characters,
tierlistBadges,
tierlistStates,
} from "@/lib/db/schema";
export const TierListContext = createContext<{
chars: (typeof characters.$inferSelect)[];
badges: (typeof tierlistBadges.$inferSelect & { tier: string[] })[];
tileSize: number;
badgeSize: number;
setState: (char: string, data: Partial<typeof tierlistStates.$inferInsert>) => void;
setState: (
char: string,
data: Partial<typeof tierlistStates.$inferInsert>,
) => void;
editable: boolean;
}>({
chars: [],
@@ -15,5 +22,5 @@ export const TierListContext = createContext<{
tileSize: 64,
badgeSize: 24,
setState: () => {},
editable: false
editable: false,
});
+35
View File
@@ -0,0 +1,35 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
import generate from "@/lib/og";
import { notFound } from "next/navigation";
// Image metadata
export const size = {
width: 1222,
height: 560,
};
export const contentType = "image/png";
// Image generation
export default async function Image({
params,
}: {
params: Promise<{ type: string; ver: string }>;
}) {
const { type, ver } = await params;
const [version] = await db
.select({ name: tierlistVersions.name })
.from(tierlistVersions)
.where(and(eq(tierlistVersions.id, ver), eq(tierlistVersions.type, type)));
if (!version) notFound();
const [tlType] = await db
.select()
.from(tierlistTypes)
.where(eq(tierlistTypes.id, type));
return generate({
title: `เทียร์ลิสต์ ${tlType.name}`,
sub: `เวอร์ชั่น ${version.name}`,
});
}
+33
View File
@@ -0,0 +1,33 @@
/** biome-ignore-all lint/a11y/noSvgWithoutTitle: no */
"use client";
export function TlStatusOverlay({
ev,
deprecates,
}: {
ev: {
upload: boolean;
download: boolean;
ev: "unknown" | "connecting" | "ready";
};
deprecates: string;
}) {
return (
<div
className="absolute top-0 right-0 m-1 rounded bg-[#2225] backdrop-blur-sm border px-1 py-0.5 text-sm transition-colors z-30 pointer-events-none flex gap-1 items-center"
style={{
color:
ev.upload || ev.download
? "#27B0F5"
: { connecting: "#F5EB27", ready: "#27F557", unknown: "#A5A7A8" }[
ev.ev
],
}}
>
<span className="text-gray-300">ใช้ได้ถึง {deprecates}</span>
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="5" fill="currentColor" />
</svg>
</div>
);
}
+133 -41
View File
@@ -6,11 +6,13 @@ import {
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
import {
Calculator,
ChevronDown,
ChevronUp,
TriangleAlert,
Smartphone,
X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -27,6 +29,7 @@ import {
import { toast } from "sonner";
import { useLocalStorage } from "usehooks-ts";
import Favicon from "#/favicon.webp";
import { Blocker } from "@/components/blocker";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -60,6 +63,7 @@ import type {
import { TierListCell } from "./cell";
import { Draggable } from "./character";
import { TierListContext } from "./context";
import { TlStatusOverlay } from "./overlay";
function Untiered({
children,
@@ -116,12 +120,16 @@ export function TierList({
const [states, setStates] = useState<(typeof tierlistStates.$inferSelect)[]>(
[],
);
const [evStatus, setEvStatus] = useState<{
upload: boolean;
download: boolean;
ev: "unknown" | "connecting" | "ready";
}>({ upload: false, download: false, ev: "unknown" });
//#region error handling
useEffect(() => {
const handler = (ev: ErrorEvent) =>
toast("Unexpected Error Occured", {
icon: <TriangleAlert className="text-red-400" />,
toast.error("Unexpected Error Occured", {
description: `${ev.error.message || ev.error || "*unknown error*"}`,
});
window.addEventListener("error", handler);
@@ -132,25 +140,27 @@ export function TierList({
//#region states fetching
useEffect(() => {
async function fetchStates() {
setEvStatus((x) => ({ ...x, download: true }));
setStates(
await fetch(`/api/tl/${version.id}/states`).then((r) => r.json()),
await fetch(`/api/tl/${version.id}/states`)
.then((r) => r.json())
.finally(() => setEvStatus((x) => ({ ...x, download: false }))),
);
}
fetchStates();
}, [version]);
}, [version.id]);
useEffect(() => {
setEvStatus((x) => ({ ...x, ev: "connecting" }));
const es = new EventSource(`/api/tl/${version.id}/ev`);
es.onopen = () => setEvStatus((x) => ({ ...x, ev: "ready" }));
es.addEventListener("update_states", (d) => setStates(JSON.parse(d.data)));
es.addEventListener("update_placements", (d) =>
setPlacements(JSON.parse(d.data)),
);
es.onerror = () =>
toast("Live Reload ใช้งานไม่ได้ ลองรีโหลดใหม่", {
icon: <TriangleAlert className="text-red-400" />,
});
es.onerror = () => setEvStatus((x) => ({ ...x, ev: "unknown" }));
return () => es.close();
}, [version]);
}, [version.id]);
//#endregion
const colRef = useRef<HTMLDivElement | null>(null);
@@ -205,6 +215,7 @@ export function TierList({
}, [columns.length, colRef.current, untieredRef.current]);
//#endregion tileSize
//#region placements
const [untieredOpen, setUntieredOpen] = useLocalStorage(
"tl_untieredOpen",
true,
@@ -229,30 +240,73 @@ export function TierList({
if (!editable) return;
const { active, over } = event;
if (!over) return;
const charId = active.id as string;
const target = over.id as string;
let source: string | undefined;
const targetId = over.id as string;
// Find source cell
let sourceCell: string | undefined;
for (const cell in placements) {
if (placements[cell].includes(charId)) {
source = cell;
sourceCell = cell;
break;
}
}
if (!source || source === target) return;
const newPlacements = {
...placements,
[source]: placements[source].filter((id) => id !== charId),
[target]: [...placements[target], charId],
};
if (!sourceCell) return;
let newPlacements = { ...placements };
// Check if dropped on another character (sorting within same cell)
if (chars.some((ch) => ch.id === targetId)) {
// Find target cell
let targetCell: string | undefined;
for (const cell in placements) {
if (placements[cell].includes(targetId)) {
targetCell = cell;
break;
}
}
if (!targetCell) return;
if (sourceCell === targetCell) {
// Reorder within same cell
const items = [...placements[sourceCell]];
const oldIndex = items.indexOf(charId);
const newIndex = items.indexOf(targetId);
newPlacements[sourceCell] = arrayMove(items, oldIndex, newIndex);
} else {
// Move to different cell
newPlacements = {
...newPlacements,
[sourceCell]: placements[sourceCell].filter((id) => id !== charId),
[targetCell]: [...placements[targetCell], charId],
};
}
} else {
// Dropped on a cell (not on a character)
const targetCell = targetId;
if (sourceCell !== targetCell) {
newPlacements = {
...newPlacements,
[sourceCell]: placements[sourceCell].filter((id) => id !== charId),
[targetCell]: [...placements[targetCell], charId],
};
}
}
setPlacements(newPlacements);
tlPlacements(version.id, newPlacements).catch((e) =>
toast(`Sync ล้มเหลว: ${e.message || e}`, {
icon: <TriangleAlert className="text-red-400" />,
}),
);
setEvStatus((x) => ({ ...x, upload: true }));
tlPlacements(version.id, newPlacements)
.catch((e) => toast.error(`Sync ล้มเหลว: ${e.message || e}`))
.finally(() => setEvStatus((x) => ({ ...x, upload: false })));
}
//#endregion placements
//#region ui states
const [disclaimer, showDisclaimer] = useState(true);
//#endregion ui
return (
<TierListContext.Provider
value={{
@@ -272,9 +326,7 @@ export function TierList({
};
setStates((s) => [...s.filter((e) => e.char !== char), newEntry]);
tlState(newEntry).catch((e) =>
toast(`Sync ล้มเหลว: ${e.message || e}`, {
icon: <TriangleAlert className="text-red-400" />,
}),
toast.error(`Sync ล้มเหลว: ${e.message || e}`),
);
},
}}
@@ -284,6 +336,32 @@ export function TierList({
onDragEnd={handleDragEnd}
>
<div className="flex flex-col justify-between h-full">
<Blocker inner className="not-portrait:hidden md:hidden">
<div className="flex flex-col items-center font-bold text-2xl gap-16">
<Smartphone size={128} className="animate-phonerotate" />
โปรดปรับจอเป็นแนวนอน
</div>
</Blocker>
{version.disclaimer && disclaimer ? (
<Blocker inner className="hidden not-portrait:block md:block">
<Image
src={`/cdn/${version.disclaimer}`}
alt="Disclaimer"
fill
className="object-contain bg-[#000A]"
/>
<Button
variant="outline"
className="absolute top-0 right-0 m-2"
size="icon"
onClick={() => showDisclaimer(false)}
>
<X />
</Button>
</Blocker>
) : (
<TlStatusOverlay ev={evStatus} deprecates={version.deprecates} />
)}
<div className="flex-1 min-h-0 overflow-auto">
<div
className={`grid w-full [&>*]:border`}
@@ -462,7 +540,12 @@ export function TierList({
(id) => chars.find((ch) => ch.id === id)!,
);
return (
<TierListCell key={cellId} column={c} tier={t}>
<TierListCell
key={cellId}
column={c}
tier={t}
items={placements[cellId]}
>
{cellChars.map((ch) => (
<Draggable
key={ch.id}
@@ -491,19 +574,24 @@ export function TierList({
<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}
state={states.find((s) => s.char === ch.id)}
/>
);
})}
</Untiered>
<SortableContext
items={placements.untiered}
strategy={rectSortingStrategy}
>
<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}
state={states.find((s) => s.char === ch.id)}
/>
);
})}
</Untiered>
</SortableContext>
</div>
</div>
<DragOverlay>
@@ -515,3 +603,7 @@ export function TierList({
</TierListContext.Provider>
);
}
function arrayMove<T>(arr: T[], oldIndex: number, newIndex: number): T[] {
return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]);
}
+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: "" });
}