feat(admin): tierlist editor
Build and Push Image / build-backend (push) Successful in 6m13s
Build and Push Image / build-frontend (push) Successful in 7m33s
Build and Push Image / push (push) Successful in 1m16s

This commit is contained in:
2026-08-06 03:00:13 +07:00 Unverified
parent 6eb4b499f7
commit 8c14999c59
22 changed files with 2038 additions and 274 deletions
@@ -0,0 +1,160 @@
import { and, eq, isNull, or } from "drizzle-orm";
import { SaveIcon, Trash2 } from "lucide-react";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { CdnChooser } from "@/components/chooser";
import { FormAction, FormInput, FormProvider } from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistBadges, tierlistTypes } from "@/lib/db/schema";
export default async function TlBadgeEditPage({
params,
}: {
params: Promise<{ type: string; badge: string }>;
}) {
if (!(await adminCheck())) redirect("/login");
const { type: typeId, badge: badgeId } = await params;
const [[type], [badge]] = await Promise.all([
db.select().from(tierlistTypes).where(eq(tierlistTypes.id, typeId)),
db
.select()
.from(tierlistBadges)
.where(
and(
eq(tierlistBadges.id, badgeId),
or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)),
),
),
]);
if (!type) notFound();
if (!badge) notFound();
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "order", "scope"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
}
let data: Partial<typeof tierlistBadges.$inferInsert>;
try {
data = {
name: form.get("name") as string,
image: (form.get("image") as string) || null,
order: parseInt(form.get("order") as string, 10),
type: form.get("scope") === "global" ? null : typeId,
};
await db
.update(tierlistBadges)
.set(data)
.where(eq(tierlistBadges.id, badgeId));
} catch (error) {
console.error(error);
const err = error as Error & {
cause?: { detail?: string; message?: string };
};
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to update badge.",
};
}
await actionLog(`Updated tierlist badge ${badgeId}`, data);
revalidatePath(`/admin/tl/${typeId}`);
revalidatePath(`/tl/${typeId}`);
return { toast: "Badge saved successfully.", close: true };
}
async function deleteBadge() {
"use server";
if (!(await adminCheck())) redirect("/login");
await db.delete(tierlistBadges).where(eq(tierlistBadges.id, badgeId));
await actionLog(`Deleted tierlist badge ${badgeId}`, badge);
revalidatePath(`/admin/tl/${typeId}`);
revalidatePath(`/tl/${typeId}`);
return { toast: "Badge deleted.", close: true };
}
return (
<ModalBase title={`Edit ${type.name} Badge`}>
<FormProvider
id={`tl-badge-${typeId}-${badgeId}`}
onSubmit={submit}
values={{ ...badge, scope: badge.type === null ? "global" : "local" }}
>
<FormInput name="name" label="Name">
<Input placeholder="SP" autoFocus />
</FormInput>
<FormInput name="order" label="Order">
<Input placeholder="10" />
</FormInput>
<FormInput name="scope" label="Scope">
<Select>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select badge scope" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{type.name} local</SelectItem>
<SelectItem value="global">Global</SelectItem>
</SelectContent>
</Select>
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<Button asChild variant="destructive">
<FormAction type="action" action={deleteBadge}>
<Trash2 />
Delete
</FormAction>
</Button>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<SaveIcon />
Save
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -0,0 +1,130 @@
import { eq, isNull, or, sql } from "drizzle-orm";
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { CdnChooser } from "@/components/chooser";
import { FormAction, FormInput, FormProvider } from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistBadges, tierlistTypes } from "@/lib/db/schema";
export default async function TlBadgeCreatePage({
params,
}: {
params: Promise<{ type: string }>;
}) {
if (!(await adminCheck())) redirect("/login");
const { type: typeId } = await params;
const [[type], [{ maxOrder }]] = await Promise.all([
db.select().from(tierlistTypes).where(eq(tierlistTypes.id, typeId)),
db
.select({
maxOrder: sql<number>`COALESCE(MAX(${tierlistBadges.order}), 0)`,
})
.from(tierlistBadges)
.where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId))),
]);
if (!type) notFound();
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "order", "scope"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
}
let data: typeof tierlistBadges.$inferInsert;
try {
data = {
name: form.get("name") as string,
image: (form.get("image") as string) || null,
order: parseInt(form.get("order") as string, 10),
type: form.get("scope") === "global" ? null : typeId,
};
await db.insert(tierlistBadges).values(data);
} catch (error) {
console.error(error);
const err = error as Error & {
cause?: { detail?: string; message?: string };
};
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to create badge.",
};
}
await actionLog(`Created tierlist badge for ${typeId}`, data);
revalidatePath(`/admin/tl/${typeId}`);
revalidatePath(`/tl/${typeId}`);
return { toast: "Badge created successfully.", close: true };
}
return (
<ModalBase title={`Create ${type.name} Badge`}>
<FormProvider id={`tl-badge-${typeId}-create`} onSubmit={submit}>
<FormInput name="name" label="Name">
<Input placeholder="SP" autoFocus />
</FormInput>
<FormInput name="order" label="Order">
<Input placeholder="10" defaultValue={(maxOrder ?? 0) + 10} />
</FormInput>
<FormInput name="scope" label="Scope">
<Select defaultValue="local">
<SelectTrigger className="w-full">
<SelectValue placeholder="Select badge scope" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{type.name} local</SelectItem>
<SelectItem value="global">Global</SelectItem>
</SelectContent>
</Select>
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<PlusIcon />
Create
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -0,0 +1,140 @@
import { eq } from "drizzle-orm";
import { SaveIcon, Trash2 } from "lucide-react";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistColumns } from "@/lib/db/schema";
export default async function TlLayoutColumnEditPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!(await adminCheck())) redirect("/login");
const { id } = await params;
const [column] = await db
.select()
.from(tierlistColumns)
.where(eq(tierlistColumns.id, id));
if (!column) notFound();
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["id", "name", "order"] as const) {
if (!form.get(field)) return { error: `Field "${field}" is required.` };
}
let data: typeof tierlistColumns.$inferInsert;
try {
data = {
id: form.get("id") as string,
name: form.get("name") as string,
image: (form.get("image") as string) || null,
order: parseInt(form.get("order") as string, 10),
};
await db
.update(tierlistColumns)
.set(data)
.where(eq(tierlistColumns.id, id));
} catch (error) {
console.error(error);
const err = error as Error & {
cause?: { detail?: string; message?: string };
};
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to update column.",
};
}
await actionLog(`Updated tierlist column ${id}`, data);
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
return { toast: "Column saved successfully.", close: true };
}
async function deleteColumn() {
"use server";
if (!(await adminCheck())) redirect("/login");
await db.delete(tierlistColumns).where(eq(tierlistColumns.id, id));
await actionLog(`Deleted tierlist column ${id}`, column);
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
return { toast: "Column deleted.", close: true };
}
return (
<ModalBase title="Edit Column">
<FormProvider
id={`tl-layout-column-${id}`}
onSubmit={submit}
values={column}
>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="Support" autoFocus />
</FormInput>
<FormInput name="id" label="ID">
<Input placeholder="sup" disabled />
</FormInput>
</FormRow>
<FormInput name="order" label="Order">
<Input placeholder="10" />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<Button asChild variant="destructive">
<FormAction type="action" action={deleteColumn}>
<Trash2 />
Delete
</FormAction>
</Button>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<SaveIcon />
Save
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -0,0 +1,110 @@
import { sql } from "drizzle-orm";
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistColumns } from "@/lib/db/schema";
export default async function TlLayoutColumnCreatePage() {
if (!(await adminCheck())) redirect("/login");
const [{ maxOrder }] = await db
.select({
maxOrder: sql<number>`COALESCE(MAX(${tierlistColumns.order}), 0)`,
})
.from(tierlistColumns);
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["id", "name", "order"] as const) {
if (!form.get(field)) return { error: `Field "${field}" is required.` };
}
let data: typeof tierlistColumns.$inferInsert;
try {
data = {
id: form.get("id") as string,
name: form.get("name") as string,
image: (form.get("image") as string) || null,
order: parseInt(form.get("order") as string, 10),
};
await db.insert(tierlistColumns).values(data);
} catch (error) {
console.error(error);
const err = error as Error & {
cause?: { detail?: string; message?: string };
};
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to create column.",
};
}
await actionLog(`Created tierlist column ${data.id}`, data);
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
return { toast: "Column created successfully.", close: true };
}
return (
<ModalBase title="Create Column">
<FormProvider id="tl-layout-column-create" onSubmit={submit}>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="Support" autoFocus />
</FormInput>
<FormInput name="id" label="ID">
<Input placeholder="sup" />
</FormInput>
</FormRow>
<FormInput name="order" label="Order">
<Input placeholder="10" defaultValue={(maxOrder ?? 0) + 10} />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<PlusIcon />
Create
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -0,0 +1,150 @@
import { eq } from "drizzle-orm";
import { SaveIcon, Trash2 } from "lucide-react";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistTiers } from "@/lib/db/schema";
function parseBadges(value: FormDataEntryValue | null) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export default async function TlLayoutTierEditPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!(await adminCheck())) redirect("/login");
const { id } = await params;
const [tier] = await db
.select()
.from(tierlistTiers)
.where(eq(tierlistTiers.id, id));
if (!tier) notFound();
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["id", "name", "order"] as const) {
if (!form.get(field)) return { error: `Field "${field}" is required.` };
}
let data: typeof tierlistTiers.$inferInsert;
try {
data = {
id: form.get("id") as string,
name: form.get("name") as string,
image: (form.get("image") as string) || null,
order: parseInt(form.get("order") as string, 10),
badges: parseBadges(form.get("badges")),
};
await db.update(tierlistTiers).set(data).where(eq(tierlistTiers.id, id));
} catch (error) {
console.error(error);
const err = error as Error & {
cause?: { detail?: string; message?: string };
};
return {
error:
err?.cause?.detail || err?.cause?.message || "Failed to update tier.",
};
}
await actionLog(`Updated tierlist tier ${id}`, data);
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
return { toast: "Tier saved successfully.", close: true };
}
async function deleteTier() {
"use server";
if (!(await adminCheck())) redirect("/login");
await db.delete(tierlistTiers).where(eq(tierlistTiers.id, id));
await actionLog(`Deleted tierlist tier ${id}`, tier);
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
return { toast: "Tier deleted.", close: true };
}
return (
<ModalBase title="Edit Tier">
<FormProvider
id={`tl-layout-tier-${id}`}
onSubmit={submit}
values={{ ...tier, badges: tier.badges?.join(", ") || "" }}
>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="SS" autoFocus />
</FormInput>
<FormInput name="id" label="ID">
<Input placeholder="ss" disabled />
</FormInput>
</FormRow>
<FormInput name="order" label="Order">
<Input placeholder="10" />
</FormInput>
<FormInput
name="badges"
label="Badges"
subLabel="comma separated badge ids"
>
<Input placeholder="sss, ssp, ssm" />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<Button asChild variant="destructive">
<FormAction type="action" action={deleteTier}>
<Trash2 />
Delete
</FormAction>
</Button>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<SaveIcon />
Save
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -0,0 +1,121 @@
import { sql } from "drizzle-orm";
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistTiers } from "@/lib/db/schema";
function parseBadges(value: FormDataEntryValue | null) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export default async function TlLayoutTierCreatePage() {
if (!(await adminCheck())) redirect("/login");
const [{ maxOrder }] = await db
.select({ maxOrder: sql<number>`COALESCE(MAX(${tierlistTiers.order}), 0)` })
.from(tierlistTiers);
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["id", "name", "order"] as const) {
if (!form.get(field)) return { error: `Field "${field}" is required.` };
}
let data: typeof tierlistTiers.$inferInsert;
try {
data = {
id: form.get("id") as string,
name: form.get("name") as string,
image: (form.get("image") as string) || null,
order: parseInt(form.get("order") as string, 10),
badges: parseBadges(form.get("badges")),
};
await db.insert(tierlistTiers).values(data);
} catch (error) {
console.error(error);
const err = error as Error & {
cause?: { detail?: string; message?: string };
};
return {
error:
err?.cause?.detail || err?.cause?.message || "Failed to create tier.",
};
}
await actionLog(`Created tierlist tier ${data.id}`, data);
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
return { toast: "Tier created successfully.", close: true };
}
return (
<ModalBase title="Create Tier">
<FormProvider id="tl-layout-tier-create" onSubmit={submit}>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="SS" autoFocus />
</FormInput>
<FormInput name="id" label="ID">
<Input placeholder="ss" />
</FormInput>
</FormRow>
<FormInput name="order" label="Order">
<Input placeholder="10" defaultValue={(maxOrder ?? 0) + 10} />
</FormInput>
<FormInput
name="badges"
label="Badges"
subLabel="comma separated badge ids"
>
<Input placeholder="sss, ssp, ssm" />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<PlusIcon />
Create
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+57 -20
View File
@@ -1,22 +1,22 @@
import { desc, eq, sql } from "drizzle-orm";
import { count, desc, eq, sql } from "drizzle-orm";
import {
ArrowLeftRight,
Badge,
BadgeDollarSign,
BitcoinIcon,
BookUser,
Columns3Cog,
Columns3CogIcon,
Compass,
Computer,
Database,
ExternalLink,
GitGraph,
Grid3X3,
IdCard,
Layout,
ListTree,
MoreHorizontal,
Package,
PlusCircle,
PlusIcon,
ScrollText,
Settings,
SquareUserRound,
@@ -54,6 +54,7 @@ import {
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
@@ -92,6 +93,18 @@ export default async function AdminLayout({
.innerJoin(tierlistTypes, eq(tierlistTypes.id, tierlistVersions.type))
.catch(() => []);
const tlTypes = db
.select({
id: tierlistTypes.id,
name: tierlistTypes.name,
count: count(tierlistVersions.id),
})
.from(tierlistTypes)
.orderBy(tierlistTypes.id)
.leftJoin(tierlistVersions, eq(tierlistTypes.id, tierlistVersions.type))
.groupBy(tierlistTypes.id)
.catch(() => []);
return (
<SidebarProvider>
<Sidebar variant="floating">
@@ -129,27 +142,24 @@ export default async function AdminLayout({
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Tierlist [WIP]</SidebarGroupLabel>
<SidebarGroupLabel>Tierlist</SidebarGroupLabel>
<SimpleTooltip text="Create new type...">
<SidebarGroupAction asChild>
<Link href="/admin/tl/ver/create">
<PlusIcon />
</Link>
</SidebarGroupAction>
</SimpleTooltip>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLink href="/admin/tl/ver">
<GitGraph />
Versions
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarLink href="/admin/tl/badges" disabled>
<Badge />
Badges
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarLink href="/admin/tl/layout" disabled>
<Columns3Cog />
Layout
<SidebarLink href="/admin/tl/layout">
<Columns3CogIcon /> Layout Editor
</SidebarLink>
</SidebarMenuItem>
<Suspense>
<TierlistList types={tlTypes} />
</Suspense>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -257,6 +267,7 @@ export default async function AdminLayout({
<Suspense>
<AdminNavbarLoader
adminShortcuts={tlVersions}
tierlistTypes={tlTypes}
tierlistVersions={vers}
/>
</Suspense>
@@ -322,6 +333,7 @@ async function HealthStatus() {
async function AdminNavbarLoader({
adminShortcuts,
tierlistTypes,
tierlistVersions,
}: {
adminShortcuts: Promise<
@@ -330,6 +342,13 @@ async function AdminNavbarLoader({
url: string;
}[]
>;
tierlistTypes: Promise<
{
id: string;
name: string;
count: number;
}[]
>;
tierlistVersions: Promise<
{
id: string;
@@ -340,11 +359,29 @@ async function AdminNavbarLoader({
return (
<AdminNavbar
adminShortcuts={await adminShortcuts}
tierlistTypes={await tierlistTypes}
tierlistVersions={await tierlistVersions}
/>
);
}
async function TierlistList({
types: prom,
}: {
types: Promise<{ id: string; name: string; count: number }[]>;
}) {
const types = await prom;
return types.map((t) => (
<SidebarMenuItem key={t.id}>
<SidebarLink href={`/admin/tl/${t.id}`}>
<Layout />
{t.name}
<SidebarMenuBadge>{t.count}</SidebarMenuBadge>
</SidebarLink>
</SidebarMenuItem>
));
}
async function VersionsList({
versions: prom,
}: {
+12 -4
View File
@@ -2,7 +2,6 @@
import {
AlignJustify,
Badge,
BadgeDollarSign,
BitcoinIcon,
ChevronRight,
@@ -10,10 +9,10 @@ import {
Compass,
ExternalLink,
Form,
GitGraph,
Grid3x3,
Home,
IdCard,
Layout,
List,
Package,
Plus,
@@ -158,12 +157,18 @@ function DropdownMenu({
export function AdminNavbar({
adminShortcuts,
tierlistTypes,
tierlistVersions,
}: {
adminShortcuts: {
name: string;
url: string;
}[];
tierlistTypes: {
id: string;
name: string;
count: number;
}[];
tierlistVersions: {
id: string;
name: string;
@@ -181,9 +186,12 @@ export function AdminNavbar({
name: "Tierlist",
icon: Table,
sub: [
{ name: "Versions", icon: GitGraph, href: "/admin/tl/ver" },
{ name: "Badges", icon: Badge, href: "/admin/tl/badges" },
{ name: "Layout", icon: Columns3Cog, href: "/admin/tl/layout" },
...tierlistTypes.map((type) => ({
name: type.name,
icon: Layout,
href: `/admin/tl/${type.id}`,
})),
],
},
{ name: "Guide", icon: Compass, href: "/admin/guide" },
+82
View File
@@ -0,0 +1,82 @@
"use server";
import { and, eq, isNull, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistBadges } from "@/lib/db/schema";
import { midpointOrder, normalizedOrders } from "../components/order";
function sameIds(left: string[], right: string[]) {
if (left.length !== right.length) return false;
const a = [...left].sort();
const b = [...right].sort();
return a.every((id, index) => id === b[index]);
}
export async function reorderBadges(args: {
typeId: string;
activeId: string;
ids: string[];
}) {
if (!(await adminCheck())) redirect("/login");
const { typeId, activeId, ids } = args;
await db.transaction(async (tx) => {
const badges = await tx
.select()
.from(tierlistBadges)
.where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)))
.orderBy(tierlistBadges.order, tierlistBadges.id);
const visibleIds = badges.map((badge) => badge.id);
if (!sameIds(visibleIds, ids)) {
throw new Error("Badge order is out of date. Refresh and try again.");
}
const index = ids.indexOf(activeId);
if (index === -1) throw new Error("Moved badge is missing.");
const prev =
index > 0
? badges.find((badge) => badge.id === ids[index - 1])
: undefined;
const next =
index < ids.length - 1
? badges.find((badge) => badge.id === ids[index + 1])
: undefined;
const nextOrder = midpointOrder(prev?.order, next?.order);
if (nextOrder == null) {
for (const item of normalizedOrders(ids)) {
await tx
.update(tierlistBadges)
.set({ order: item.order })
.where(eq(tierlistBadges.id, item.id));
}
} else {
await tx
.update(tierlistBadges)
.set({ order: nextOrder })
.where(
and(
eq(tierlistBadges.id, activeId),
or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)),
),
);
}
});
await actionLog(`Reordered tierlist badges for ${typeId}`, {
typeId,
activeId,
ids,
});
revalidatePath(`/admin/tl/${typeId}`);
revalidatePath(`/tl/${typeId}`);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { GripVertical, PencilLine, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { ComponentProps } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import type { tierlistBadges } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { reorderFlatItems } from "../components/order";
import { SortableGrid, SortableGridItem } from "../components/sortable-grid";
import { reorderBadges } from "./actions";
export function BadgeSection({
typeId,
typeName,
badges: initialBadges,
}: {
typeId: string;
typeName: string;
badges: (typeof tierlistBadges.$inferSelect)[];
}) {
const [badges, setBadges] = useState(initialBadges);
const [pending, startTransition] = useTransition();
const badgeSize = 48;
function commitReorder(activeId: string, overId: string) {
const next = reorderFlatItems(badges, activeId, overId);
if (next === badges) return;
const previous = badges;
setBadges(next);
startTransition(() => {
reorderBadges({
typeId,
activeId,
ids: next.map((badge) => badge.id),
}).catch((error) => {
console.error(error);
setBadges(previous);
toast.error(
error instanceof Error ? error.message : "Failed to reorder badges.",
);
});
});
}
return (
<div className="flex flex-1 flex-col gap-1 rounded-md border bg-card/50 p-1">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1">
Badges
<span className="text-muted-foreground">
(blue = global, green = {typeName} local)
</span>
</span>
<Button size="sm" asChild>
<Link href={`/admin/tl/badge/${typeId}/create`}>
<PlusIcon /> Add badge
</Link>
</Button>
</div>
<SortableGrid
items={badges.map((badge) => badge.id)}
onReorder={commitReorder}
disabled={pending}
overlay={(id) => {
const badge = badges.find((entry) => entry.id === id);
return badge ? (
<BadgeTile
badge={badge}
typeId={typeId}
badgeSize={badgeSize}
overlay
/>
) : null;
}}
>
<div className="flex flex-wrap gap-2">
{badges.map((badge) => (
<SortableGridItem key={badge.id} id={badge.id} disabled={pending}>
{({ attributes, listeners, setNodeRef, style, isDragging }) => (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "z-10")}
>
<BadgeTile
badge={badge}
typeId={typeId}
badgeSize={badgeSize}
dragHandle={{ ...attributes, ...listeners }}
/>
</div>
)}
</SortableGridItem>
))}
</div>
</SortableGrid>
</div>
);
}
function BadgeTile({
badge,
typeId,
badgeSize,
dragHandle,
overlay = false,
}: {
badge: typeof tierlistBadges.$inferSelect;
typeId: string;
badgeSize: number;
dragHandle?: ComponentProps<"button">;
overlay?: boolean;
}) {
const borderClass =
badge.type === null ? "border-blue-500" : "border-emerald-400";
return (
<div className="group/badge relative">
<button
type="button"
className="absolute top-1 left-1 z-10 cursor-grab rounded-sm border bg-card/90 p-0.5 text-muted-foreground active:cursor-grabbing"
{...dragHandle}
>
<GripVertical className="size-3" />
</button>
{!overlay && (
<div className="absolute top-1 right-1 z-10 opacity-0 transition-opacity group-hover/badge:opacity-100">
<SimpleTooltip text="Edit badge">
<Button
variant="outline"
size="icon"
asChild
className="size-6 bg-card!"
>
<Link href={`/admin/tl/badge/${typeId}/${badge.id}/edit`}>
<PencilLine className="size-3" />
</Link>
</Button>
</SimpleTooltip>
</div>
)}
{badge.image ? (
<Image
className={cn(
"rounded border bg-[#2225] backdrop-blur-sm",
borderClass,
overlay && "shadow-lg",
)}
src={`/cdn/${badge.image}`}
alt={badge.name}
width={badgeSize}
height={badgeSize}
/>
) : (
<div
style={{ width: badgeSize, height: badgeSize }}
className={cn(
"flex items-center justify-center rounded border bg-[#2228] px-1 text-center text-xs font-bold",
borderClass,
overlay && "shadow-lg",
)}
>
{badge.name}
</div>
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { desc, eq } from "drizzle-orm";
import { ExternalLink, Lock, PencilLine, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { db } from "@/lib/db";
import {
tierlistBadges,
tierlistTypes,
tierlistVersions,
} from "@/lib/db/schema";
import { syncTierlistSnapshots } from "@/lib/tierlist";
import { cn } from "@/lib/utils";
import { BadgeSection } from "./badge-section";
export default async function TierlistManagerPage({
params,
}: PageProps<"/admin/tl/[ver]">) {
const { ver } = await params;
await syncTierlistSnapshots(ver);
const [[type], versionsList, badges] = await Promise.all([
db.select().from(tierlistTypes).where(eq(tierlistTypes.id, ver)),
db
.select()
.from(tierlistVersions)
.orderBy(desc(tierlistVersions.order))
.where(eq(tierlistVersions.type, ver)),
db
.select()
.from(tierlistBadges)
.orderBy(tierlistBadges.order, tierlistBadges.id),
]);
const t = {
...type,
versions: versionsList,
};
return (
<div className="mx-2 flex grow-0 flex-col gap-2 pt-5">
<div className="group/type flex flex-col gap-1">
<div className="flex w-fit flex-wrap items-center gap-2 text-4xl font-semibold">
<div className="flex flex-auto flex-wrap items-baseline gap-x-2 rounded-md border bg-[#2228] px-2 py-1">
<span className="text-nowrap">{t.name}</span>
<span className="flex items-center gap-1">
<Kbd>{t.id}</Kbd>
<span className="text-xs text-muted-foreground">{t.mode}</span>
</span>
</div>
<div className="flex gap-1 transition-opacity group-hover/type:opacity-100 md:opacity-0">
<SimpleTooltip text="Add version">
<Button asChild>
<Link href={`/admin/tl/ver/${t.id}/create`}>
<PlusIcon />
<span className="md:hidden">Add</span>
</Link>
</Button>
</SimpleTooltip>
<SimpleTooltip text="Edit type">
<Button variant="outline" asChild>
<Link href={`/admin/tl/ver/${t.id}/edit`}>
<PencilLine />
<span className="md:hidden">Edit</span>
</Link>
</Button>
</SimpleTooltip>
</div>
</div>
<div className="flex flex-wrap gap-2">
{t.versions.map((e) => (
<div
key={e.id}
className={cn(
e.image
? "group/ver relative aspect-video w-[calc(100svw-16px)] rounded-sm border md:w-60"
: "group/ver relative flex aspect-video w-[calc(100svw-16px)] items-center justify-center rounded-sm border bg-[#1118] text-4xl font-bold backdrop-blur-xl md:w-60",
e.hidden && "opacity-50",
)}
>
{e.image ? (
<>
<Image
src={`/cdn/${e.image}`}
alt={e.name}
fill
className="-z-1 rounded-sm border bg-primary object-cover"
/>
<div className="absolute bottom-0 flex w-full justify-center gap-1 rounded-b-sm bg-card py-1">
{e.name} <Kbd>{e.id}</Kbd>
</div>
</>
) : (
e.name
)}
<div className="absolute top-1 right-1 flex gap-1 transition-opacity group-hover/ver:opacity-100 md:opacity-0">
<SimpleTooltip text="Open in admin">
<Button variant="outline" asChild className="bg-card!">
<Link href={`/tl/${t.id}/${e.id}/admin`} target="_blank">
<ExternalLink />
<span className="md:hidden">Open</span>
</Link>
</Button>
</SimpleTooltip>
<SimpleTooltip text="Edit version">
<Button variant="outline" asChild className="bg-card!">
<Link href={`/admin/tl/ver/${t.id}/${e.id}/edit`}>
<PencilLine />
<span className="md:hidden">Edit</span>
</Link>
</Button>
</SimpleTooltip>
</div>
{e.snapshot && (
<div className="absolute right-1 bottom-1 rounded-sm border bg-card/90 p-1 text-muted-foreground">
<SimpleTooltip text="This version is using a frozen snapshot">
<Lock className="size-4" />
</SimpleTooltip>
</div>
)}
</div>
))}
</div>
</div>
<div className="h-px bg-foreground/25"></div>
<div className="flex">
<div className="flex flex-1 flex-col gap-2">
<BadgeSection
typeId={ver}
typeName={type.name}
badges={badges.filter((badge) => (badge.type ?? ver) === ver)}
/>
</div>
<div className="flex flex-2"></div>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
export const ORDER_STEP = 10;
export function reorderFlatItems<T extends { id: string }>(
items: T[],
activeId: string,
overId: string,
) {
if (activeId === overId) return items;
const oldIndex = items.findIndex((item) => item.id === activeId);
const newIndex = items.findIndex((item) => item.id === overId);
if (oldIndex === -1 || newIndex === -1) return items;
return arrayMove(items, oldIndex, newIndex);
}
export function midpointOrder(prev?: number, next?: number) {
if (prev == null && next == null) return ORDER_STEP;
if (prev == null) return next! - ORDER_STEP;
if (next == null) return prev + ORDER_STEP;
const middle = Math.floor((prev + next) / 2);
if (middle === prev || middle === next) return null;
return middle;
}
export function normalizedOrders(ids: string[]) {
return ids.map((id, index) => ({
id,
order: (index + 1) * ORDER_STEP,
}));
}
function arrayMove<T>(arr: T[], oldIndex: number, newIndex: number) {
return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]);
}
@@ -0,0 +1,92 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
type DraggableAttributes,
DragOverlay,
} from "@dnd-kit/core";
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
import {
rectSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable";
import type { ReactNode } from "react";
import { useState } from "react";
export function SortableGrid({
items,
onReorder,
children,
overlay,
disabled = false,
}: {
items: string[];
onReorder: (activeId: string, overId: string) => void;
children?: ReactNode;
overlay?: (id: string) => ReactNode;
disabled?: boolean;
}) {
const [dragging, setDragging] = useState<string | null>(null);
function handleDragEnd(event: DragEndEvent) {
setDragging(null);
if (disabled || !event.over) return;
onReorder(event.active.id as string, event.over.id as string);
}
return (
<DndContext
collisionDetection={closestCenter}
onDragStart={(event) => setDragging(event.active.id as string)}
onDragCancel={() => setDragging(null)}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
{children}
</SortableContext>
<DragOverlay>{dragging && overlay?.(dragging)}</DragOverlay>
</DndContext>
);
}
export function SortableGridItem({
id,
disabled = false,
children,
}: {
id: string;
disabled?: boolean;
children: (props: {
listeners: SyntheticListenerMap | undefined;
attributes: DraggableAttributes | undefined;
setNodeRef: (node: HTMLElement | null) => void;
style: React.CSSProperties | undefined;
isDragging: boolean;
}) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
return children({
listeners,
attributes,
setNodeRef,
isDragging,
style: {
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})`
: undefined,
transition,
opacity: isDragging ? 0.5 : 1,
},
});
}
+76
View File
@@ -0,0 +1,76 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
import { midpointOrder, normalizedOrders } from "../components/order";
function sameIds(left: string[], right: string[]) {
if (left.length !== right.length) return false;
const a = [...left].sort();
const b = [...right].sort();
return a.every((id, index) => id === b[index]);
}
async function reorderTable(
ids: string[],
activeId: string,
type: "tiers" | "columns",
) {
const table = type === "tiers" ? tierlistTiers : tierlistColumns;
await db.transaction(async (tx) => {
const items = await tx.select().from(table).orderBy(table.order, table.id);
const visibleIds = items.map((item) => item.id);
if (!sameIds(visibleIds, ids)) {
throw new Error("Layout order is out of date. Refresh and try again.");
}
const index = ids.indexOf(activeId);
if (index === -1) throw new Error("Moved item is missing.");
const prev =
index > 0 ? items.find((item) => item.id === ids[index - 1]) : undefined;
const next =
index < ids.length - 1
? items.find((item) => item.id === ids[index + 1])
: undefined;
const nextOrder = midpointOrder(prev?.order, next?.order);
if (nextOrder == null) {
for (const item of normalizedOrders(ids)) {
await tx
.update(table)
.set({ order: item.order })
.where(eq(table.id, item.id));
}
} else {
await tx
.update(table)
.set({ order: nextOrder })
.where(eq(table.id, activeId));
}
});
await actionLog(`Reordered tierlist ${type}`, { activeId, ids });
revalidatePath("/admin/tl/layout");
revalidatePath("/tl");
}
export async function reorderTiers(args: { activeId: string; ids: string[] }) {
if (!(await adminCheck())) redirect("/login");
return reorderTable(args.ids, args.activeId, "tiers");
}
export async function reorderColumns(args: {
activeId: string;
ids: string[];
}) {
if (!(await adminCheck())) redirect("/login");
return reorderTable(args.ids, args.activeId, "columns");
}
+344
View File
@@ -0,0 +1,344 @@
"use client";
import { GripVertical, PencilLine, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { ComponentProps } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { reorderFlatItems } from "../components/order";
import { SortableGrid, SortableGridItem } from "../components/sortable-grid";
import { reorderColumns, reorderTiers } from "./actions";
type Tier = typeof tierlistTiers.$inferSelect;
type Column = typeof tierlistColumns.$inferSelect;
export function LayoutEditor({
tiers: initialTiers,
columns: initialColumns,
}: {
tiers: Tier[];
columns: Column[];
}) {
const [tiers, setTiers] = useState(initialTiers);
const [columns, setColumns] = useState(initialColumns);
const [pending, startTransition] = useTransition();
function commitReorder<T extends { id: string }>(
items: T[],
setItems: (items: T[]) => void,
activeId: string,
overId: string,
persist: (args: { activeId: string; ids: string[] }) => Promise<void>,
label: string,
) {
const next = reorderFlatItems(items, activeId, overId);
if (next === items) return;
const previous = items;
setItems(next);
startTransition(() => {
persist({ activeId, ids: next.map((item) => item.id) }).catch((error) => {
console.error(error);
setItems(previous);
toast.error(
error instanceof Error
? error.message
: `Failed to reorder ${label}.`,
);
});
});
}
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<div
className="grid min-w-fit *:border *:border-white/10"
style={{
gridTemplateColumns: `minmax(180px, 240px) repeat(${Math.max(columns.length, 1)}, minmax(180px, 1fr)) 72px`,
gridTemplateRows: `minmax(88px, auto) repeat(${Math.max(tiers.length, 1)}, minmax(108px, auto)) 72px`,
}}
>
<div className="bg-[#0005]" />
<SortableGrid
items={columns.map((column) => column.id)}
onReorder={(activeId, overId) =>
commitReorder(
columns,
setColumns,
activeId,
overId,
reorderColumns,
"columns",
)
}
disabled={pending}
overlay={(id) => {
const column = columns.find((entry) => entry.id === id);
return column ? (
<LayoutNode
item={column}
href={`/admin/tl/layout/column/${column.id}/edit`}
orientation="column"
overlay
/>
) : null;
}}
>
{columns.length ? (
columns.map((column) => (
<SortableGridItem
key={column.id}
id={column.id}
disabled={pending}
>
{({
attributes,
listeners,
setNodeRef,
style,
isDragging,
}) => (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "z-10")}
>
<LayoutNode
item={column}
href={`/admin/tl/layout/column/${column.id}/edit`}
orientation="column"
dragHandle={{ ...attributes, ...listeners }}
/>
</div>
)}
</SortableGridItem>
))
) : (
<div className="flex items-center justify-center bg-[#0005] p-3 text-sm text-muted-foreground">
Add your first column
</div>
)}
</SortableGrid>
<AddSlot href="/admin/tl/layout/column/create" label="Add column" />
{tiers.length ? (
<SortableGrid
items={tiers.map((tier) => tier.id)}
onReorder={(activeId, overId) =>
commitReorder(
tiers,
setTiers,
activeId,
overId,
reorderTiers,
"tiers",
)
}
disabled={pending}
overlay={(id) => {
const tier = tiers.find((entry) => entry.id === id);
return tier ? (
<LayoutNode
item={tier}
href={`/admin/tl/layout/tier/${tier.id}/edit`}
orientation="tier"
badges={tier.badges}
overlay
/>
) : null;
}}
>
{tiers.map((tier) => (
<TierRow
key={tier.id}
tier={tier}
columnIds={
columns.length
? columns.map((column) => column.id)
: ["empty"]
}
pending={pending}
/>
))}
</SortableGrid>
) : (
<>
<div className="flex items-center justify-center bg-[#0005] p-3 text-sm text-muted-foreground">
Add your first tier
</div>
{(columns.length
? columns.map((column) => column.id)
: ["empty"]
).map((id) => (
<BlankCell key={`empty-tier-cell-${id}`} />
))}
</>
)}
<AddSlot href="/admin/tl/layout/tier/create" label="Add tier" />
{(columns.length
? columns.map((column) => column.id)
: ["empty"]
).map((id) => (
<ThinCell key={`footer-${id}`} />
))}
<ThinCell />
</div>
</div>
</div>
);
}
function TierRow({
tier,
columnIds,
pending,
}: {
tier: Tier;
columnIds: string[];
pending: boolean;
}) {
return (
<>
<SortableGridItem id={tier.id} disabled={pending}>
{({ attributes, listeners, setNodeRef, style, isDragging }) => (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "z-10")}
>
<LayoutNode
item={tier}
href={`/admin/tl/layout/tier/${tier.id}/edit`}
orientation="tier"
badges={tier.badges}
dragHandle={{ ...attributes, ...listeners }}
/>
</div>
)}
</SortableGridItem>
{columnIds.map((columnId) => (
<BlankCell key={`${tier.id}-${columnId}`} />
))}
<BlankCell />
</>
);
}
function BlankCell() {
return <div className="min-h-27 bg-[#0003]" />;
}
function ThinCell() {
return <div className="min-h-18 bg-[#0003]" />;
}
function AddSlot({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="group/add flex items-center justify-center bg-[#0004] text-muted-foreground transition-colors hover:bg-[#0007] hover:text-foreground"
>
<SimpleTooltip text={label}>
<PlusIcon className="size-8 transition-transform group-hover/add:scale-110" />
</SimpleTooltip>
</Link>
);
}
function LayoutNode({
item,
href,
dragHandle,
orientation,
badges,
overlay = false,
}: {
item: { id: string; name: string; image: string | null };
href?: string;
dragHandle?: ComponentProps<"button">;
orientation: "tier" | "column";
badges?: string[] | null;
overlay?: boolean;
}) {
return (
<div
className={cn(
"group/layout relative flex h-full min-h-22 items-center gap-3 bg-[#0005] p-3",
overlay && "shadow-lg",
orientation === "column" && "justify-center text-center",
)}
>
<button
type="button"
className="absolute top-2 left-2 z-10 cursor-grab rounded-sm border bg-card/90 p-0.5 text-muted-foreground active:cursor-grabbing"
{...dragHandle}
>
<GripVertical className="size-3" />
</button>
{href && !overlay && (
<div className="absolute top-2 right-2 opacity-0 transition-opacity group-hover/layout:opacity-100">
<SimpleTooltip text="Edit">
<Button
variant="outline"
size="icon"
asChild
className="size-7 bg-card!"
>
<Link href={href}>
<PencilLine className="size-3" />
</Link>
</Button>
</SimpleTooltip>
</div>
)}
<div
className={cn(
"flex size-16 shrink-0 items-center justify-center rounded border bg-[#2228]",
orientation === "column" && "mx-auto mt-3",
)}
>
{item.image ? (
<Image
src={`/cdn/${item.image}`}
alt={item.name}
width={64}
height={64}
className="max-h-16 w-auto object-contain"
/>
) : (
<span className="text-xl font-bold">{item.name[0] ?? "?"}</span>
)}
</div>
<div className={cn("min-w-0", orientation === "column" && "w-full")}>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="truncate text-lg font-semibold">{item.name}</div>
<Kbd>{item.id}</Kbd>
</div>
{orientation === "tier" && (
<div className="mt-1 flex flex-wrap gap-1">
{badges?.length ? (
<KbdGroup className="flex-wrap gap-1">
{badges.map((badge) => (
<Kbd key={badge}>{badge}</Kbd>
))}
</KbdGroup>
) : (
<span className="text-sm text-muted-foreground">No badges</span>
)}
</div>
)}
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
import { LayoutEditor } from "./client";
export default async function TierlistLayoutPage() {
if (!(await adminCheck())) redirect("/login");
const [tiers, columns] = await Promise.all([
db
.select()
.from(tierlistTiers)
.orderBy(tierlistTiers.order, tierlistTiers.id),
db
.select()
.from(tierlistColumns)
.orderBy(tierlistColumns.order, tierlistColumns.id),
]);
return <LayoutEditor tiers={tiers} columns={columns} />;
}
export const dynamic = "force-dynamic";
-111
View File
@@ -1,111 +0,0 @@
import { desc } from "drizzle-orm";
import { ExternalLink, PencilLine, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
export default async function TierlistManagerPage() {
const types = await db.select().from(tierlistTypes).orderBy(tierlistTypes.id);
const versionsList = await db
.select()
.from(tierlistVersions)
.orderBy(desc(tierlistVersions.order));
const vers = types.map((t) => ({
...t,
versions: versionsList.filter((v) => v.type === t.id),
}));
return (
<div className="mx-2 flex grow-0 flex-col gap-2 pt-5">
<Button className="w-fit" asChild>
<Link href={`/admin/tl/ver/create`}>
<PlusIcon />
<span>Create type</span>
</Link>
</Button>
{vers.map((t) => (
<div className="group/type flex flex-col gap-1" key={t.id}>
<div className="flex w-fit flex-wrap items-center gap-2 text-4xl font-semibold">
<div className="flex flex-auto flex-wrap items-baseline gap-x-2 rounded-md border bg-[#2228] px-2 py-1">
<span className="text-nowrap">{t.name}</span>
<span className="flex items-center gap-1">
<Kbd>{t.id}</Kbd>
<span className="text-xs text-muted-foreground">{t.mode}</span>
</span>
</div>
<div className="flex gap-1 transition-opacity group-hover/type:opacity-100 md:opacity-0">
<SimpleTooltip text="Add version">
<Button asChild>
<Link href={`/admin/tl/ver/${t.id}/create`}>
<PlusIcon />
<span className="md:hidden">Add</span>
</Link>
</Button>
</SimpleTooltip>
<SimpleTooltip text="Edit type">
<Button variant="outline" asChild>
<Link href={`/admin/tl/ver/${t.id}/edit`}>
<PencilLine />
<span className="md:hidden">Edit</span>
</Link>
</Button>
</SimpleTooltip>
</div>
</div>
<div className="flex flex-wrap gap-2">
{t.versions.map((e) => (
<div
key={e.id}
className={cn(
e.image
? "group/ver relative aspect-video w-[calc(100svw-16px)] rounded-sm border md:w-60"
: "group/ver relative flex aspect-video w-[calc(100svw-16px)] items-center justify-center rounded-sm border bg-[#1118] text-4xl font-bold backdrop-blur-xl md:w-60",
e.hidden && "opacity-50",
)}
>
{e.image ? (
<>
<Image
src={`/cdn/${e.image}`}
alt={e.name}
fill
className="-z-1 rounded-sm border bg-primary object-cover"
/>
<div className="absolute bottom-0 flex w-full justify-center gap-1 rounded-b-sm bg-card py-1">
{e.name} <Kbd>{e.id}</Kbd>
</div>
</>
) : (
e.name
)}
<div className="absolute top-1 right-1 flex gap-1 transition-opacity group-hover/ver:opacity-100 md:opacity-0">
<SimpleTooltip text="Open in admin">
<Button variant="outline" asChild className="bg-card!">
<Link href={`/tl/${t.id}/${e.id}/admin`} target="_blank">
<ExternalLink />
<span className="md:hidden">Open</span>
</Link>
</Button>
</SimpleTooltip>
<SimpleTooltip text="Edit version">
<Button variant="outline" asChild className="bg-card!">
<Link href={`/admin/tl/ver/${t.id}/${e.id}/edit`}>
<PencilLine />
<span className="md:hidden">Edit</span>
</Link>
</Button>
</SimpleTooltip>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
+2 -69
View File
@@ -1,17 +1,7 @@
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { ViewTransition } from "react";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import {
characters,
tierlistBadges,
tierlistColumns,
tierlistTiers,
tierlistTypes,
tierlistVersions,
versions,
} from "@/lib/db/schema";
import { getTierlistConfig } from "@/lib/tierlist";
import { TierList } from "../tierlist";
export default async function TierlistPage({
@@ -21,64 +11,7 @@ export default async function TierlistPage({
}) {
if (!(await adminCheck())) redirect("/login");
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)
.where(
or(isNull(tierlistBadges.type), eq(tierlistBadges.type, version.type)),
);
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 };
});
const config = await getTierlistConfig(type, ver);
if (!config)
// console.log(config);
notFound();
+3 -2
View File
@@ -7,7 +7,6 @@ import {
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";
@@ -69,7 +68,9 @@ export function SortableDraggable({
} = useSortable({ id, disabled: !editable });
const style = {
transform: CSS.Transform.toString(transform),
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})`
: undefined,
transition,
opacity: isDragging ? 0.5 : 1,
};
+4 -68
View File
@@ -1,16 +1,9 @@
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import {
characters,
tierlistBadges,
tierlistColumns,
tierlistTiers,
tierlistTypes,
tierlistVersions,
versions,
} from "@/lib/db/schema";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
import { getTierlistConfig } from "@/lib/tierlist";
import { TierList } from "./tierlist";
export async function generateMetadata({
@@ -48,64 +41,7 @@ export default async function TierlistPage({
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, columns, badgesList] = await Promise.all([
tx.select().from(tierlistTiers).orderBy(tierlistTiers.order),
tx.select().from(tierlistColumns).orderBy(tierlistColumns.order),
tx
.select()
.from(tierlistBadges)
.orderBy(tierlistBadges.order)
.where(
or(
isNull(tierlistBadges.type),
eq(tierlistBadges.type, version.type),
),
),
]);
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);
const config = await getTierlistConfig(type, ver);
if (!config) notFound();
return <TierList {...config} />;
// return <pre>{JSON.stringify(config, null, 2)}</pre>;