tierlist done(without sortables)

This commit is contained in:
Dreamgineer
2025-10-09 04:02:22 -04:00 Unverified
parent 4a58cdfb4e
commit ba6143f63b
17 changed files with 547 additions and 90 deletions
+9
View File
@@ -0,0 +1,9 @@
import { sse } from "@/lib/utils";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ ver: string }> },
) {
const { ver } = await params;
return sse.new(`tl-${ver}`);
}
+16
View File
@@ -0,0 +1,16 @@
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { tierlistStates } from "@/lib/db/schema";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ ver: string }> },
) {
const { ver } = await params;
const states = await db
.select()
.from(tierlistStates)
.where(eq(tierlistStates.list, ver));
return NextResponse.json(states);
}
+1 -1
View File
@@ -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 />
<Toaster theme="dark" />
</body>
</html>
);
+84 -8
View File
@@ -1,15 +1,91 @@
import { ChevronRightIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import Banner from "#/banner.webp";
import ArtifactLogo from "#/logos/artifact.webp";
import TierlistLogo from "#/logos/tierlist.webp";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<Link href="/artifact">
<Button>Artifact</Button>
</Link>
<Link href="/tl">
<Button>TierList</Button>
</Link>
<div className="relative h-screen w-full overflow-hidden flex items-center justify-center">
<div className="max-w-[85rem] w-full mx-auto px-4 sm:px-6 py-6">
<Card className="max-w-lg w-full mx-auto pt-0">
<Image
src={Banner}
alt="Banner"
className="w-full rounded-t-xl"
width={500}
height={500}
/>
<CardContent className="space-y-6">
<div className="flex flex-col gap-2">
<Item variant="outline" asChild>
<Link href="/artifact">
<ItemMedia>
<Image
src={ArtifactLogo}
alt="เสือกไอดีชาวบ้าน"
className="w-22"
width={500}
height={500}
/>
</ItemMedia>
<ItemContent>
<ItemTitle>เสือกไอดีชาวบ้าน</ItemTitle>
<ItemDescription className="line-clamp-none">
ใครอยากให้ดูอาร์ติแฟกต์ให้ในไลฟ์ ไปกรอกแบบฟอร์มได้เลย
</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRightIcon className="size-4" />
</ItemActions>
</Link>
</Item>
<Item variant="outline" asChild>
<Link href="/tl">
<ItemMedia>
<Image
src={TierlistLogo}
alt="จัดเทียร์ลิสต์อบิส"
className="w-22"
width={500}
height={500}
/>
</ItemMedia>
<ItemContent>
<ItemTitle>จัดเทียร์ลิสต์</ItemTitle>
<ItemDescription className="line-clamp-none">
เทียร์ลิสต์เมต้าตัวละครของอบิสแพทช์นั้นๆเท่านั้น
ซึ่งจะเปลี่ยนไปในแต่ละแพทช์ตามความสามารถของตัวละครในการรับมือกับอบิสแพทช์นั้นๆ
ไม่ใช่เทียร์ลิสต์ภาพรวมของตัวละคร
</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRightIcon className="size-4" />
</ItemActions>
</Link>
</Item>
</div>
</CardContent>
<CardFooter>
<p className="text-xs text-center text-muted-foreground">
buzz.gunshiz.top is not affiliated with HoYoverse. Game content
and materials are trademarks and copyrights of HoYoverse.
</p>
</CardFooter>
</Card>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
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 badgesList = await tx
.select()
.from(tierlistBadges)
.orderBy(tierlistBadges.order);
const badges = badgesList.map((b) => ({
...b,
tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id),
}));
return { type: typeInfo, version, tiers, columns, badges, chars };
});
// console.log(config);
if (!config) notFound();
return <TierList editable {...config} />;
// return <pre>{JSON.stringify(config, null, 2)}</pre>;
}
+158 -40
View File
@@ -2,18 +2,32 @@
import { useDraggable } from "@dnd-kit/core";
import Image from "next/image";
import { useContext, useState } from "react";
import Link from "next/link";
import { useContext, useEffect, useState } from "react";
import AmberIcon from "#/amber.png";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import type { characters } from "@/lib/db/schema";
import type { characters, tierlistStates } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { TierListContext } from "./context";
export function Draggable({ char }: { char: typeof characters.$inferSelect }) {
const { tileSize, badgeSize } = useContext(TierListContext);
export function Draggable({
char,
state,
tier,
}: {
char: typeof characters.$inferSelect;
state?: typeof tierlistStates.$inferInsert;
tier?: string;
}) {
const { tileSize, badgeSize, badges, setState, editable } =
useContext(TierListContext);
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: char.id,
@@ -26,12 +40,41 @@ export function Draggable({ char }: { char: typeof characters.$inferSelect }) {
const [panelOpen, setPanelOpen] = useState(false);
const [comment, setComment] = useState("");
const [assignedBadges, setBadges] = useState<string[]>([]);
const [dirty, setDirty] = useState(0);
useEffect(() => {
if (dirty) return;
setComment(state?.comment || "");
setBadges(state?.badges || []);
}, [dirty, state]);
useEffect(() => {
const timeout = setTimeout(() => {
if (!dirty) return;
setDirty(0);
setState(char.id, { comment, badges: assignedBadges });
}, dirty);
return () => clearTimeout(timeout);
}, [dirty, comment, setState, char, assignedBadges]);
function toggleBadge(badge: string) {
setDirty(200);
setBadges(
assignedBadges.includes(badge)
? assignedBadges.filter((b) => b !== badge)
: [...assignedBadges, badge],
);
}
return (
<Popover open={panelOpen} onOpenChange={setPanelOpen}>
<PopoverTrigger asChild>
<button
ref={setNodeRef}
style={style}
suppressHydrationWarning
{...listeners}
{...attributes}
onContextMenu={(ev) => {
@@ -39,49 +82,124 @@ export function Draggable({ char }: { char: typeof characters.$inferSelect }) {
setPanelOpen((x) => !x);
}}
>
<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}
/>
<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">
<div className="grid grid-cols-2 gap-1 border border-dashed rounded p-1">
{Array(12)
.fill(0)
.map((_, i) => ({
id: i,
name: "A+",
image: "0199946b-666c-717f-a42b-c4ca067c3e39",
order: i,
}))
.map((e) => (
<Image
src={`/cdn/${e.image}`}
alt={e.name}
key={`B${e.id}`}
width={48}
height={48}
className="border rounded hover:brightness-110"
/>
))}
</div>
{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>
<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>
</PopoverContent>
+6 -2
View File
@@ -1,15 +1,19 @@
"use client";
import { createContext } from "react";
import type { characters, tierlistBadges } 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)[];
badges: (typeof tierlistBadges.$inferSelect & { tier: string[] })[];
tileSize: number;
badgeSize: number;
setState: (char: string, data: Partial<typeof tierlistStates.$inferInsert>) => void;
editable: boolean;
}>({
chars: [],
badges: [],
tileSize: 64,
badgeSize: 24,
setState: () => {},
editable: false
});
+7 -2
View File
@@ -49,7 +49,8 @@ export default async function TierlistPage({
? await tx
.select()
.from(characters)
.where(inArray(characters.version, ids)).orderBy(characters.order)
.where(inArray(characters.version, ids))
.orderBy(characters.order)
: ([] as (typeof characters.$inferSelect)[]);
// fetch rows, columns, badges data...
@@ -61,10 +62,14 @@ export default async function TierlistPage({
.select()
.from(tierlistColumns)
.orderBy(tierlistColumns.order);
const badges = await tx
const badgesList = await tx
.select()
.from(tierlistBadges)
.orderBy(tierlistBadges.order);
const badges = badgesList.map((b) => ({
...b,
tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id),
}));
return { type: typeInfo, version, tiers, columns, badges, chars };
});
+112 -22
View File
@@ -6,7 +6,12 @@ import {
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { Calculator, ChevronDown, ChevronUp } from "lucide-react";
import {
Calculator,
ChevronDown,
ChevronUp,
TriangleAlert,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import {
@@ -14,10 +19,12 @@ import {
type ReactNode,
type RefObject,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useLocalStorage } from "usehooks-ts";
import Favicon from "#/favicon.webp";
import { Button } from "@/components/ui/button";
@@ -35,10 +42,17 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { tlPlacements, tlState } from "@/lib/api";
import type {
characters,
tierlistBadges,
tierlistColumns,
tierlistStates,
tierlistTiers,
tierlistTypes,
tierlistVersions,
@@ -46,12 +60,6 @@ import type {
import { TierListCell } from "./cell";
import { Draggable } from "./character";
import { TierListContext } from "./context";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SimpleTooltip } from "@/components/tooltip";
function Untiered({
children,
@@ -88,13 +96,15 @@ export function TierList({
tiers,
columns,
badges,
editable = false,
}: {
type: typeof tierlistTypes.$inferSelect;
version: typeof tierlistVersions.$inferSelect;
chars: (typeof characters.$inferSelect)[];
tiers: (typeof tierlistTiers.$inferSelect)[];
columns: (typeof tierlistColumns.$inferSelect)[];
badges: (typeof tierlistBadges.$inferSelect)[];
badges: (typeof tierlistBadges.$inferSelect & { tier: string[] })[];
editable?: boolean;
}) {
const [tileSizeSetting, setTileSize] = useLocalStorage<number | null>(
"tl_tileSize",
@@ -103,6 +113,45 @@ export function TierList({
const [badgeSize, setBadgeSize] = useLocalStorage<number>("tl_badgeSize", 24);
const [tileSizeAuto, setTileSizeAuto] = useState(0);
const tileSize = tileSizeSetting || tileSizeAuto;
const [states, setStates] = useState<(typeof tierlistStates.$inferSelect)[]>(
[],
);
//#region error handling
useEffect(() => {
const handler = (ev: ErrorEvent) =>
toast("Unexpected Error Occured", {
icon: <TriangleAlert className="text-red-400" />,
description: `${ev.error.message || ev.error || "*unknown error*"}`,
});
window.addEventListener("error", handler);
return () => window.removeEventListener("error", handler);
});
//#endregion
//#region states fetching
useEffect(() => {
async function fetchStates() {
setStates(
await fetch(`/api/tl/${version.id}/states`).then((r) => r.json()),
);
}
fetchStates();
}, [version]);
useEffect(() => {
const es = new EventSource(`/api/tl/${version.id}/ev`);
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" />,
});
return () => es.close();
}, [version]);
//#endregion
const colRef = useRef<HTMLDivElement | null>(null);
const untieredRef = useRef<HTMLDivElement | null>(null);
@@ -161,23 +210,23 @@ export function TierList({
true,
);
//#region placements handling
const initialPlacements: Record<string, string[]> = {
untiered: chars.map((ch) => ch.id),
const tiered: (string | undefined)[] = Object.values({
...version.placements,
untiered: undefined,
}).flat();
const [placements, setPlacements] = useState<Record<string, string[]>>({
...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,
);
...version.placements,
untiered: chars.map((ch) => ch.id).filter((c) => !tiered.includes(c)),
});
const [dragging, setDragging] = useState<string | null>(null);
function handleDragEnd(event: DragEndEvent) {
if (!editable) return;
const { active, over } = event;
if (!over) return;
const charId = active.id as string;
@@ -190,16 +239,46 @@ export function TierList({
}
}
if (!source || source === target) return;
setPlacements({
const newPlacements = {
...placements,
[source]: placements[source].filter((id) => id !== charId),
[target]: [...placements[target], charId],
});
};
setPlacements(newPlacements);
tlPlacements(version.id, newPlacements).catch((e) =>
toast(`Sync ล้มเหลว: ${e.message || e}`, {
icon: <TriangleAlert className="text-red-400" />,
}),
);
}
//#endregion placements
return (
<TierListContext.Provider value={{ badges, chars, tileSize, badgeSize }}>
<TierListContext.Provider
value={{
badges,
chars,
tileSize,
badgeSize,
editable,
async setState(char, data) {
const newEntry = {
...states.find((e) => e.char === char),
...(Object.fromEntries(
Object.entries(data).filter(([_, v]) => v !== undefined),
) as typeof tierlistStates.$inferSelect),
list: version.id,
char,
};
setStates((s) => [...s.filter((e) => e.char !== char), newEntry]);
tlState(newEntry).catch((e) =>
toast(`Sync ล้มเหลว: ${e.message || e}`, {
icon: <TriangleAlert className="text-red-400" />,
}),
);
},
}}
>
<DndContext
onDragStart={(e) => setDragging(e.active.id as string)}
onDragEnd={handleDragEnd}
@@ -385,7 +464,12 @@ export function TierList({
return (
<TierListCell key={cellId} column={c} tier={t}>
{cellChars.map((ch) => (
<Draggable key={ch.id} char={ch} />
<Draggable
key={ch.id}
char={ch}
tier={t.id}
state={states.find((s) => s.char === ch.id)}
/>
))}
</TierListCell>
);
@@ -411,7 +495,13 @@ export function TierList({
{untieredOpen &&
placements.untiered.map((id) => {
const ch = chars.find((c) => c.id === id)!;
return <Draggable key={ch.id} char={ch} />;
return (
<Draggable
key={ch.id}
char={ch}
state={states.find((s) => s.char === ch.id)}
/>
);
})}
</Untiered>
</div>