feat(admin): tierlist editor
This commit is contained in:
@@ -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
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+137
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user