From 5861d9f1f5f4546ffd7956cf1bb3cefc5a2592de Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 12:52:38 +0200 Subject: [PATCH] Add admin Articles (CRUD) + Bans resources - Articles: list / new / edit / delete (websiteArticles), unique-slug generation via slugify() (pure, unit-tested); staff-gated server actions. - Bans: list active bans (ban_expire > now), create ban (writes the bans row + RCON disconnect) with type/duration/reason, lift ban; staff-gated actions. - Admin nav extended (Articles, Bans). Verified: tsc exit 0, vitest 48/48, next build exit 0 (7 /admin routes). --- src/actions/admin-articles.ts | 64 ++++++++++++++++++++++++++++ src/actions/admin-bans.ts | 51 ++++++++++++++++++++++ src/app/admin/articles/[id]/page.tsx | 42 ++++++++++++++++++ src/app/admin/articles/new/page.tsx | 20 +++++++++ src/app/admin/articles/page.tsx | 41 ++++++++++++++++++ src/app/admin/bans/page.tsx | 64 ++++++++++++++++++++++++++++ src/app/admin/layout.tsx | 2 + src/lib/format.test.ts | 14 +++++- src/lib/format.ts | 13 ++++++ 9 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/actions/admin-articles.ts create mode 100644 src/actions/admin-bans.ts create mode 100644 src/app/admin/articles/[id]/page.tsx create mode 100644 src/app/admin/articles/new/page.tsx create mode 100644 src/app/admin/articles/page.tsx create mode 100644 src/app/admin/bans/page.tsx diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts new file mode 100644 index 00000000..9d3f2394 --- /dev/null +++ b/src/actions/admin-articles.ts @@ -0,0 +1,64 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { slugify } from "@/lib/format"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +async function uniqueSlug(title: string): Promise { + const base = slugify(title); + let slug = base; + let n = 2; + while (await prisma.websiteArticles.findUnique({ where: { slug } })) { + slug = `${base}-${n++}`; + } + return slug; +} + +export async function createArticle(formData: FormData): Promise { + const staff = await requireStaff(); + const title = String(formData.get("title") ?? "").trim(); + const shortStory = String(formData.get("shortStory") ?? "").trim(); + const fullStory = String(formData.get("fullStory") ?? "").trim(); + const image = String(formData.get("image") ?? "").trim(); + if (!title) return; + + const now = new Date(); + await prisma.websiteArticles.create({ + data: { + slug: await uniqueSlug(title), + title: title.slice(0, 255), + shortStory: shortStory.slice(0, 255), + fullStory, + image: image.slice(0, 255), + userId: String(staff.id), + createdAt: now, + updatedAt: now, + }, + }); + redirect("/admin/articles"); +} + +export async function updateArticle(formData: FormData): Promise { + await requireStaff(); + const id = BigInt(String(formData.get("id"))); + await prisma.websiteArticles.update({ + where: { id }, + data: { + title: String(formData.get("title") ?? "").trim().slice(0, 255), + shortStory: String(formData.get("shortStory") ?? "").trim().slice(0, 255), + fullStory: String(formData.get("fullStory") ?? "").trim(), + image: String(formData.get("image") ?? "").trim().slice(0, 255), + updatedAt: new Date(), + }, + }); + revalidatePath(`/admin/articles/${id}`); + redirect("/admin/articles"); +} + +export async function deleteArticle(formData: FormData): Promise { + await requireStaff(); + await prisma.websiteArticles.delete({ where: { id: BigInt(String(formData.get("id"))) } }); + redirect("/admin/articles"); +} diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts new file mode 100644 index 00000000..1c7777dc --- /dev/null +++ b/src/actions/admin-bans.ts @@ -0,0 +1,51 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; + +type BanType = "account" | "ip" | "machine" | "super"; +const BAN_TYPES: ReadonlySet = new Set(["account", "ip", "machine", "super"]); +const PERMANENT_SECONDS = 100 * 365 * 24 * 3600; + +export async function createBan(formData: FormData): Promise { + const staff = await requireStaff(); + const userId = Number(formData.get("userId")); + const reason = String(formData.get("reason") ?? "").trim().slice(0, 200) || "Banned"; + const hours = Number(formData.get("hours")); + const type = String(formData.get("type")); + if (!(userId > 0) || !BAN_TYPES.has(type)) return; + + const now = Math.floor(Date.now() / 1000); + const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); + + await prisma.ban.create({ + data: { + userId, + ip: "", + machineId: "", + userStaffId: staff.id, + timestamp: now, + banExpire, + banReason: reason, + type: type as BanType, + cfhTopic: -1, + }, + }); + + if (user) await rcon.disconnectUser(userId, user.username); + revalidatePath("/admin/bans"); +} + +export async function liftBan(formData: FormData): Promise { + await requireStaff(); + const id = Number(formData.get("id")); + if (id > 0) await prisma.ban.delete({ where: { id } }); + revalidatePath("/admin/bans"); +} diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx new file mode 100644 index 00000000..07c5d5f9 --- /dev/null +++ b/src/app/admin/articles/[id]/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { deleteArticle, updateArticle } from "@/actions/admin-articles"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function EditArticle({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + let article: Awaited> = null; + try { + article = await prisma.websiteArticles.findUnique({ where: { id: BigInt(id) } }); + } catch { + notFound(); + } + if (!article) notFound(); + + return ( +
+

+ ← Articles +

+

Edit article

+
+ + + +