Enhance character and tierlist management: add error handling for character creation and editing; implement action logging for character updates and deletions; introduce new tierlist type creation and editing pages; update dependencies and Docker configuration.
This commit is contained in:
@@ -27,6 +27,8 @@ import { characters, element as elementEnum, versions } from "@/lib/db/schema";
|
||||
export default async function CharacterEditPage({
|
||||
params,
|
||||
}: PageProps<"/admin/char/[id]">) {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
|
||||
const { id: charId } = await params;
|
||||
const [versionList, [char]] = await Promise.all([
|
||||
db.select().from(versions).orderBy(desc(versions.id)),
|
||||
@@ -82,10 +84,16 @@ export default async function CharacterEditPage({
|
||||
await db.update(characters).set(data).where(eq(characters.id, charId));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { error: "Failed to update character in database." };
|
||||
const err = e as Error & { cause: { detail: string; message: string } };
|
||||
return {
|
||||
error:
|
||||
err?.cause?.detail ||
|
||||
err?.cause?.message ||
|
||||
"Failed to update character in database.",
|
||||
};
|
||||
}
|
||||
|
||||
actionLog(`Edited character ${charId}`, data);
|
||||
await actionLog(`Edited character ${charId}`, data);
|
||||
|
||||
revalidatePath("/admin/char");
|
||||
return { toast: "Character saved." };
|
||||
@@ -97,6 +105,8 @@ export default async function CharacterEditPage({
|
||||
|
||||
await db.delete(characters).where(eq(characters.id, charId));
|
||||
|
||||
await actionLog(`Deleted character ${charId}`, char);
|
||||
|
||||
revalidatePath("/admin/char");
|
||||
return { toast: "Character deleted.", close: true };
|
||||
}
|
||||
|
||||
@@ -82,10 +82,16 @@ export default async function CharacterCreatePage() {
|
||||
await db.insert(characters).values(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { error: "Failed to update character in database." };
|
||||
const err = e as Error & { cause: { detail: string; message: string } };
|
||||
return {
|
||||
error:
|
||||
err?.cause?.detail ||
|
||||
err?.cause?.message ||
|
||||
"Failed to update character in database.",
|
||||
};
|
||||
}
|
||||
|
||||
actionLog(`Created character ${data.id}`, data);
|
||||
await actionLog(`Created character ${data.id}`, data);
|
||||
|
||||
revalidatePath("/admin/char");
|
||||
return { toast: "Character created successfully.", close: true };
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { SaveIcon, Trash2 } from "lucide-react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
|
||||
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 { 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 { tierlistTypes } from "@/lib/db/schema";
|
||||
|
||||
export default async function CharacterCreatePage({
|
||||
params,
|
||||
}: PageProps<"/admin/tl/ver/[type]/edit">) {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
|
||||
const { type: typeId } = await params;
|
||||
const [type] = await db
|
||||
.select()
|
||||
.from(tierlistTypes)
|
||||
.where(eq(tierlistTypes.id, typeId));
|
||||
if (!type) notFound();
|
||||
|
||||
async function submit(
|
||||
form: TypedFormData<{
|
||||
name: string;
|
||||
id: string;
|
||||
order: string;
|
||||
image: string;
|
||||
}>,
|
||||
) {
|
||||
"use server";
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
|
||||
for (const field of ["name", "id", "order"] as const) {
|
||||
if (!form.get(field)) {
|
||||
return { error: `Field "${field}" is required.` };
|
||||
}
|
||||
}
|
||||
|
||||
let data: typeof tierlistTypes.$inferInsert;
|
||||
|
||||
try {
|
||||
data = {
|
||||
id: form.get("id")!,
|
||||
name: form.get("name")!,
|
||||
image: form.get("image")!,
|
||||
order: parseInt(form.get("order")!, 10),
|
||||
};
|
||||
await db
|
||||
.update(tierlistTypes)
|
||||
.set(data)
|
||||
.where(eq(tierlistTypes.id, typeId));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const err = e as Error & { cause: { detail: string; message: string } };
|
||||
return {
|
||||
error:
|
||||
err?.cause?.detail || err?.cause?.message || "Failed to update type.",
|
||||
};
|
||||
}
|
||||
|
||||
await actionLog(`Updated tierlist type ${data.id}`, data);
|
||||
|
||||
revalidatePath("/admin/tl/ver");
|
||||
return { toast: "Type saved successfully." };
|
||||
}
|
||||
|
||||
async function deleteType() {
|
||||
"use server";
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
|
||||
await db.delete(tierlistTypes).where(eq(tierlistTypes.id, typeId));
|
||||
|
||||
await actionLog(`Deleted tierlist type ${typeId}`, type);
|
||||
|
||||
revalidatePath("/admin/char");
|
||||
return { toast: "Type deleted.", close: true };
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalBase title="Create Tierlist Type">
|
||||
<FormProvider id="tl-type-create" onSubmit={submit} values={type}>
|
||||
<FormRow>
|
||||
<FormInput name="name" label="Name">
|
||||
<Input placeholder="Abyss" autoFocus />
|
||||
</FormInput>
|
||||
<FormInput name="id" label="ID" subLabel="(change = break)">
|
||||
<Input placeholder="aby" disabled />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormInput name="order" label="Order">
|
||||
<Input placeholder="1" />
|
||||
</FormInput>
|
||||
|
||||
<FormInput name="image" label="Image" subLabel="optional">
|
||||
<CdnChooser />
|
||||
</FormInput>
|
||||
<DialogFooter>
|
||||
<Button asChild variant="destructive">
|
||||
<FormAction type="action" action={deleteType}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
|
||||
export function genId({ name }: { name: string; id: string }) {
|
||||
const generated = name.toLowerCase().slice(0, 3).replace(/[^a-z0-9]+/g, "");
|
||||
return { id: generated };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
|
||||
import { CdnChooser } from "@/components/chooser";
|
||||
import { FormAction, FormInput, FormProvider } from "@/components/form";
|
||||
import { ModalBase } from "@/components/modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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 { tierlistTypes } from "@/lib/db/schema";
|
||||
import { genId } from "./overrides";
|
||||
|
||||
export default async function CharacterCreatePage() {
|
||||
const [{ maxOrder }] = await db
|
||||
.select({ maxOrder: sql<number>`MAX(${tierlistTypes.order})` })
|
||||
.from(tierlistTypes);
|
||||
|
||||
async function submit(
|
||||
form: TypedFormData<{
|
||||
name: string;
|
||||
id: string;
|
||||
order: string;
|
||||
image: string;
|
||||
}>,
|
||||
) {
|
||||
"use server";
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
|
||||
for (const field of ["name", "id", "order"] as const) {
|
||||
if (!form.get(field)) {
|
||||
return { error: `Field "${field}" is required.` };
|
||||
}
|
||||
}
|
||||
|
||||
let data: typeof tierlistTypes.$inferInsert;
|
||||
|
||||
try {
|
||||
data = {
|
||||
id: form.get("id")!,
|
||||
name: form.get("name")!,
|
||||
image: form.get("image")!,
|
||||
order: parseInt(form.get("order")!, 10),
|
||||
};
|
||||
await db.insert(tierlistTypes).values(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const err = e as Error & { cause: { detail: string; message: string } };
|
||||
return {
|
||||
error:
|
||||
err?.cause?.detail || err?.cause?.message || "Failed to create type.",
|
||||
};
|
||||
}
|
||||
|
||||
await actionLog(`Created tierlist type ${data.id}`, data);
|
||||
|
||||
revalidatePath("/admin/tl/ver");
|
||||
return { toast: "Type created successfully.", close: true };
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalBase title="Create Tierlist Type">
|
||||
<FormProvider id="tl-type-create" onSubmit={submit}>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2 grow">
|
||||
<FormInput name="name" override={genId} label="Name">
|
||||
<Input placeholder="Abyss" autoFocus />
|
||||
</FormInput>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 grow">
|
||||
<FormInput name="id" label="ID">
|
||||
<Input placeholder="aby" />
|
||||
</FormInput>
|
||||
</div>
|
||||
</div>
|
||||
<FormInput name="order" label="Order">
|
||||
<Input placeholder="1" defaultValue={maxOrder + 10} />
|
||||
</FormInput>
|
||||
<FormInput name="image" label="Image" subLabel="optional">
|
||||
<CdnChooser />
|
||||
</FormInput>
|
||||
<DialogFooter>
|
||||
<FormAction type="clear" asChild>
|
||||
<Button variant="outline">Clear</Button>
|
||||
</FormAction>
|
||||
<Button asChild>
|
||||
<FormAction
|
||||
type="submit"
|
||||
loading={
|
||||
<>
|
||||
<Spinner />
|
||||
Creating...
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
Create
|
||||
</FormAction>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</FormProvider>
|
||||
</ModalBase>
|
||||
);
|
||||
}
|
||||
+150
-57
@@ -1,11 +1,16 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Badge,
|
||||
BadgeDollarSign,
|
||||
BookUser,
|
||||
Columns3Cog,
|
||||
Computer,
|
||||
Database,
|
||||
ExternalLink,
|
||||
GitGraph,
|
||||
Grid3X3,
|
||||
IdCard,
|
||||
ListTree,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
@@ -17,7 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, Suspense } from "react";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -30,6 +35,10 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -50,7 +59,7 @@ import {
|
||||
import { NavUser } from "@/components/user";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { versions } from "@/lib/db/schema";
|
||||
import { tierlistTypes, tierlistVersions, versions } from "@/lib/db/schema";
|
||||
import { SidebarLink, VersionCreateDialogForm } from "./client";
|
||||
|
||||
export default async function AdminLayout({
|
||||
@@ -66,12 +75,6 @@ export default async function AdminLayout({
|
||||
.select()
|
||||
.from(versions)
|
||||
.catch(() => [{ id: "..", name: "Error Fetching List." }]);
|
||||
const health: {
|
||||
database: boolean;
|
||||
enka: boolean;
|
||||
amber: boolean;
|
||||
red: boolean;
|
||||
} = await fetch("http://localhost:3000/api/health").then((r) => r.json());
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -96,55 +99,17 @@ export default async function AdminLayout({
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="pb-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex gap-1 [&>svg]:size-4 [&>svg]:shrink-0">
|
||||
{/* Database */}
|
||||
<SimpleTooltip text="Database">
|
||||
<Database
|
||||
className={
|
||||
health.database
|
||||
? "text-emerald-400"
|
||||
: "text-red-400"
|
||||
}
|
||||
/>
|
||||
</SimpleTooltip>{" "}
|
||||
{/* Enka */}
|
||||
<SimpleTooltip text="Enka Network API">
|
||||
<UserRoundSearch
|
||||
className={
|
||||
health.enka ? "text-emerald-400" : "text-red-400"
|
||||
}
|
||||
/>
|
||||
</SimpleTooltip>{" "}
|
||||
{/* Amber */}
|
||||
<SimpleTooltip text="Project Amber">
|
||||
<BookUser
|
||||
className={
|
||||
health.amber ? "text-emerald-400" : "text-red-400"
|
||||
}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
{/* Cache */}
|
||||
<SimpleTooltip text="Redis Cache/SSE">
|
||||
<ArrowLeftRight
|
||||
className={
|
||||
health.red ? "text-emerald-400" : "text-red-400"
|
||||
}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span>
|
||||
{Object.values(health).some((x) => !x)
|
||||
? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา`
|
||||
: "ปกติทุกอย่าง"}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<Suspense
|
||||
fallback={
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>Loading...</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
}
|
||||
>
|
||||
<HealthStatus />
|
||||
</Suspense>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
@@ -219,6 +184,20 @@ export default async function AdminLayout({
|
||||
<SidebarGroupLabel>Global</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<Suspense
|
||||
fallback={
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<ExternalLink />
|
||||
Admin Pages
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
}
|
||||
>
|
||||
<AdminShortcuts />
|
||||
</Suspense>
|
||||
<SidebarMenuItem>
|
||||
<SidebarLink href="/admin/char">
|
||||
<SquareUserRound />
|
||||
@@ -257,4 +236,118 @@ export default async function AdminLayout({
|
||||
);
|
||||
}
|
||||
|
||||
async function HealthStatus() {
|
||||
const health: {
|
||||
database: boolean;
|
||||
enka: boolean;
|
||||
amber: boolean;
|
||||
red: boolean;
|
||||
} = await fetch("http://localhost:3000/api/health").then((r) => r.json());
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex gap-1 [&>svg]:size-4 [&>svg]:shrink-0">
|
||||
{/* Database */}
|
||||
<SimpleTooltip text="Database">
|
||||
<Database
|
||||
className={
|
||||
health.database ? "text-emerald-400" : "text-red-400"
|
||||
}
|
||||
/>
|
||||
</SimpleTooltip>{" "}
|
||||
{/* Enka */}
|
||||
<SimpleTooltip text="Enka Network API">
|
||||
<UserRoundSearch
|
||||
className={health.enka ? "text-emerald-400" : "text-red-400"}
|
||||
/>
|
||||
</SimpleTooltip>{" "}
|
||||
{/* Amber */}
|
||||
<SimpleTooltip text="Project Amber">
|
||||
<BookUser
|
||||
className={health.amber ? "text-emerald-400" : "text-red-400"}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
{/* Cache */}
|
||||
<SimpleTooltip text="Redis Cache/SSE">
|
||||
<ArrowLeftRight
|
||||
className={health.red ? "text-emerald-400" : "text-red-400"}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span>
|
||||
{Object.values(health).some((x) => !x)
|
||||
? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา`
|
||||
: "ปกติทุกอย่าง"}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
async function AdminShortcuts() {
|
||||
const versions = await db
|
||||
.select({
|
||||
name: sql<string>`${tierlistTypes.name} || ' ' || ${tierlistVersions.name}`.as(
|
||||
"name",
|
||||
),
|
||||
url: sql<string>`${tierlistVersions.type} || '/' || ${tierlistVersions.id}`,
|
||||
})
|
||||
.from(tierlistVersions)
|
||||
.orderBy(tierlistTypes.order, tierlistVersions.order)
|
||||
.innerJoin(tierlistTypes, eq(tierlistTypes.id, tierlistVersions.type))
|
||||
.catch(() => []);
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ExternalLink />
|
||||
Admin Pages
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="rounded-lg"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/artifact/admin">
|
||||
<IdCard />
|
||||
Artifact
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/rubgram/admin">
|
||||
<BadgeDollarSign />
|
||||
Rubgram
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Grid3X3 />
|
||||
Tierlist
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{versions.map((v) => (
|
||||
<DropdownMenuItem key={v.url} asChild>
|
||||
<Link href={`/tl/${v.url}/admin`}>{v.name}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, FormIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { stringify } from "yaml";
|
||||
import SearchBox, { type Filters, type ParsedQuery } from "@/components/search";
|
||||
@@ -30,7 +30,7 @@ export default function AuditLogViewer({
|
||||
|
||||
const filters: Filters = {
|
||||
user: () => users.map((u) => u.email),
|
||||
has: () => ["artifact", "tierlist", "rubgram", "file"],
|
||||
has: () => ["artifact", "tierlist", "rubgram", "character", "file"],
|
||||
};
|
||||
|
||||
const [query, setQuery] = useState<ParsedQuery>();
|
||||
@@ -178,7 +178,7 @@ export default function AuditLogViewer({
|
||||
{log.text}{" "}
|
||||
{!isExpanded && log.details ? (
|
||||
<SimpleTooltip text="Click to see details">
|
||||
<CircleAlert className="opacity-80 size-4" />
|
||||
<FormIcon className="opacity-80 size-4" />
|
||||
</SimpleTooltip>
|
||||
) : (
|
||||
""
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function toggleEnka(state: boolean) {
|
||||
.values({ enka: state })
|
||||
.onConflictDoUpdate({ target: settings.id, set: { enka: state } });
|
||||
|
||||
actionLog("Changed a settings", { enka: state });
|
||||
await actionLog("Changed a settings", { enka: state });
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/artifact");
|
||||
}
|
||||
@@ -30,6 +30,6 @@ export async function syncAmber() {
|
||||
const res = await promisify(exec)("bun util/sync 2>&1", {
|
||||
env: { ...env, NO_AUTH_CHECK: "1" },
|
||||
});
|
||||
actionLog("Triggered an Amber sync", { result: res.stdout });
|
||||
await actionLog("Triggered an Amber sync", { result: res.stdout });
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { desc, not } 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";
|
||||
|
||||
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.id))
|
||||
.where(not(tierlistVersions.hidden));
|
||||
const vers = types.map((t) => ({
|
||||
...t,
|
||||
versions: versionsList.filter((v) => v.type === t.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="grow-0 flex flex-col pt-5 gap-2 mx-2">
|
||||
<Button className="w-fit" asChild>
|
||||
<Link href={`/admin/tl/ver/create`}>
|
||||
<PlusIcon />
|
||||
<span>Create</span>
|
||||
</Link>
|
||||
</Button>
|
||||
{vers.map((t) => (
|
||||
<div className="flex flex-col gap-1 group/type" key={t.id}>
|
||||
<div className="font-bold text-4xl flex items-center gap-2">
|
||||
<div className="px-2 py-1 border w-min rounded-md bg-[#2228] flex">
|
||||
{t.name} <Kbd>{t.id}</Kbd>
|
||||
</div>
|
||||
<div className="md:opacity-0 group-hover/type:opacity-100 transition-opacity flex gap-1">
|
||||
<SimpleTooltip text="Create version">
|
||||
<Button asChild>
|
||||
<Link href={`/admin/tl/${t.id}/create`}>
|
||||
<PlusIcon />
|
||||
<span className="md:hidden">Create</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 gap-2 flex-wrap">
|
||||
{t.versions.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className={
|
||||
e.image
|
||||
? "relative aspect-video w-[calc(100svw-16px)] md:w-60 rounded-sm border group/ver"
|
||||
: "relative flex items-center justify-center aspect-video w-[calc(100svw-16px)] md:w-60 border rounded-sm bg-[#1118] backdrop-blur-xl font-bold text-4xl group/ver"
|
||||
}
|
||||
>
|
||||
{e.image ? (
|
||||
<>
|
||||
<Image
|
||||
src={`/cdn/${e.image}`}
|
||||
alt={e.name}
|
||||
fill
|
||||
className="rounded-sm border object-cover bg-primary -z-1"
|
||||
/>
|
||||
<div className="flex absolute w-full justify-center bottom-0 py-1 bg-card rounded-b-sm gap-1">
|
||||
{e.name} <Kbd>{e.id}</Kbd>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
e.name
|
||||
)}
|
||||
<div className="md:opacity-0 group-hover/ver:opacity-100 transition-opacity flex gap-1 absolute top-1 right-1">
|
||||
<SimpleTooltip text="Open in admin">
|
||||
<Button variant="outline" asChild className="bg-card!">
|
||||
<Link href={`/tl/${t.id}/${e.id}/admin`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
@@ -1168,7 +1168,7 @@
|
||||
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["[email protected]44.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
"lucide-react": ["[email protected]62.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
|
||||
+18
-8
@@ -270,6 +270,7 @@ type FormInputProps = {
|
||||
label?: string;
|
||||
subLabel?: string;
|
||||
children: React.ReactElement;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FormInput({
|
||||
@@ -278,6 +279,7 @@ export function FormInput({
|
||||
children,
|
||||
label,
|
||||
subLabel,
|
||||
className,
|
||||
}: FormInputProps) {
|
||||
const { values, setValue, updateValues } = useFormContext();
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
@@ -336,15 +338,19 @@ export function FormInput({
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && (
|
||||
<Label htmlFor={`forminput-${name}`}>
|
||||
{label}{" "}
|
||||
{subLabel && (
|
||||
<span className="font-normal opacity-70">{subLabel}</span>
|
||||
)}
|
||||
</Label>
|
||||
{label ? (
|
||||
<div className={cn("flex flex-col gap-2 grow", className)}>
|
||||
<Label htmlFor={`forminput-${name}`}>
|
||||
{label}{" "}
|
||||
{subLabel && (
|
||||
<span className="font-normal opacity-70">{subLabel}</span>
|
||||
)}
|
||||
</Label>
|
||||
{React.cloneElement(children, nextProps)}
|
||||
</div>
|
||||
) : (
|
||||
React.cloneElement(children, nextProps)
|
||||
)}
|
||||
{React.cloneElement(children, nextProps)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -428,3 +434,7 @@ export function FormAction({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormRow({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("flex gap-2", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
db:
|
||||
image: pgautoupgrade/pgautoupgrade:trixie
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_PASSWORD: dev
|
||||
PGDATA: /pgdata
|
||||
volumes:
|
||||
- pgdata:/pgdata
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -q -U postgres -d postgres"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- redis:/data
|
||||
command: --save 60 1 --loglevel warning
|
||||
redis-commander:
|
||||
depends_on:
|
||||
- redis
|
||||
container_name: redis-commander
|
||||
hostname: redis-commander
|
||||
image: ghcr.io/joeferner/redis-commander:latest
|
||||
restart: always
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
ports:
|
||||
- "6380:8081"
|
||||
user: redis
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
drizzle:
|
||||
redis:
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
Reference in New Issue
Block a user