initial admin
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "(ฝั่งแอดมิน)" });
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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: "" });
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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">
|
||||
เทียร์ลิสต์เมต้าตัวละครของอบิสแพทช์นั้นๆเท่านั้น
|
||||
ซึ่งจะเปลี่ยนไปในแต่ละแพทช์ตามความสามารถของตัวละครในการรับมือกับอบิสแพทช์นั้นๆ
|
||||
ไม่ใช่เทียร์ลิสต์ภาพรวมของตัวละคร
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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: "" });
|
||||
}
|
||||
Reference in New Issue
Block a user