From e8be0461d818beb4250526117e08ae9f68103ca7 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 13:44:23 +0200 Subject: [PATCH] Add niche admin + public expansions + self-host Nunito font (batches 3-4) Built via two more parallel agent workflows (read schema -> return files), integrated + verified. Admin: /admin/emulator (emulator_settings + emulator_texts key/value editor), /admin/badges (give-badge via RCON), /admin/rare-values (CRUD), /admin/housekeeping (CRUD), /admin/email-templates (CRUD), /admin/photos (moderation). Public: /rares (+[category]), /leaderboard (credits/diamonds/duckets), /guilds (+[id] members), /redeem (voucher -> sendCurrency). Expanded: /u/[username] now shows badges + photos + guestbook (post form); /news/[slug] now shows reactions + comments (comment form). Reactions tallied in JS (Prisma groupBy typing avoided). Self-hosted Nunito via next/font/google wired to --font-nunito (the atom theme font, no runtime external fetch). Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build exit 0. --- src/actions/admin-badges.ts | 41 ++++++ src/actions/admin-email-templates.ts | 68 +++++++++ src/actions/admin-emulator.ts | 36 +++++ src/actions/admin-housekeeping.ts | 47 ++++++ src/actions/admin-photos.ts | 19 +++ src/actions/admin-rare-values.ts | 88 ++++++++++++ src/actions/article-comments.ts | 62 ++++++++ src/actions/guestbook.ts | 50 +++++++ src/actions/voucher.ts | 125 ++++++++++++++++ src/app/admin/badges/page.tsx | 83 +++++++++++ src/app/admin/email-templates/page.tsx | 138 ++++++++++++++++++ src/app/admin/emulator/page.tsx | 174 ++++++++++++++++++++++ src/app/admin/housekeeping/page.tsx | 79 ++++++++++ src/app/admin/layout.tsx | 6 + src/app/admin/photos/page.tsx | 54 +++++++ src/app/admin/rare-values/page.tsx | 147 +++++++++++++++++++ src/app/globals.css | 2 +- src/app/guilds/[id]/page.tsx | 191 +++++++++++++++++++++++++ src/app/guilds/page.tsx | 72 ++++++++++ src/app/layout.tsx | 11 +- src/app/leaderboard/page.tsx | 155 ++++++++++++++++++++ src/app/news/[slug]/page.tsx | 137 +++++++++++++++++- src/app/rares/[category]/page.tsx | 171 ++++++++++++++++++++++ src/app/rares/page.tsx | 96 +++++++++++++ src/app/redeem/RedeemForm.tsx | 49 +++++++ src/app/redeem/page.tsx | 61 ++++++++ src/app/u/[username]/page.tsx | 178 ++++++++++++++++++++++- src/components/site-header.tsx | 12 ++ 28 files changed, 2347 insertions(+), 5 deletions(-) create mode 100644 src/actions/admin-badges.ts create mode 100644 src/actions/admin-email-templates.ts create mode 100644 src/actions/admin-emulator.ts create mode 100644 src/actions/admin-housekeeping.ts create mode 100644 src/actions/admin-photos.ts create mode 100644 src/actions/admin-rare-values.ts create mode 100644 src/actions/article-comments.ts create mode 100644 src/actions/guestbook.ts create mode 100644 src/actions/voucher.ts create mode 100644 src/app/admin/badges/page.tsx create mode 100644 src/app/admin/email-templates/page.tsx create mode 100644 src/app/admin/emulator/page.tsx create mode 100644 src/app/admin/housekeeping/page.tsx create mode 100644 src/app/admin/photos/page.tsx create mode 100644 src/app/admin/rare-values/page.tsx create mode 100644 src/app/guilds/[id]/page.tsx create mode 100644 src/app/guilds/page.tsx create mode 100644 src/app/leaderboard/page.tsx create mode 100644 src/app/rares/[category]/page.tsx create mode 100644 src/app/rares/page.tsx create mode 100644 src/app/redeem/RedeemForm.tsx create mode 100644 src/app/redeem/page.tsx diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts new file mode 100644 index 00000000..33717271 --- /dev/null +++ b/src/actions/admin-badges.ts @@ -0,0 +1,41 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; + +export async function giveBadge(formData: FormData): Promise { + await requireStaff(); + + const userId = Number(formData.get("userId")); + const code = String(formData.get("code") ?? "").trim().slice(0, 32); + if (!(userId > 0) || code.length === 0) return; + + // Fire the emulator command so the badge appears live for online users. + await rcon.giveBadge(userId, code); + + // Persist the badge directly so it survives a relog / offline grant. + // users_badges has no unique (user_id, badge_code) constraint, so guard + // against duplicates and compute the next free slot ourselves. + try { + const existing = await prisma.usersBadges.findFirst({ + where: { userId, badgeCode: code }, + select: { id: true }, + }); + if (!existing) { + const max = await prisma.usersBadges.aggregate({ + where: { userId }, + _max: { slotId: true }, + }); + const slotId = (max._max.slotId ?? 0) + 1; + await prisma.usersBadges.create({ + data: { userId, slotId, badgeCode: code }, + }); + } + } catch { + // Best-effort: the RCON grant already succeeded for online users. + } + + revalidatePath("/admin/badges"); +} diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts new file mode 100644 index 00000000..a0a5917d --- /dev/null +++ b/src/actions/admin-email-templates.ts @@ -0,0 +1,68 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +export async function createEmailTemplate(formData: FormData): Promise { + await requireStaff(); + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + const subject = String(formData.get("subject") ?? "").trim().slice(0, 255); + const body = String(formData.get("body") ?? ""); + const variablesRaw = String(formData.get("variables") ?? "").trim(); + const isActive = formData.get("isActive") != null; + if (!name || !subject || !body) return; + + await prisma.emailTemplates.create({ + data: { + name, + subject, + body, + variables: variablesRaw || null, + isActive, + }, + }); + revalidatePath("/admin/email-templates"); +} + +export async function updateEmailTemplate(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!raw) return; + let id: bigint; + try { + id = BigInt(raw); + } catch { + return; + } + const subject = String(formData.get("subject") ?? "").trim().slice(0, 255); + const body = String(formData.get("body") ?? ""); + const variablesRaw = String(formData.get("variables") ?? "").trim(); + const isActive = formData.get("isActive") != null; + if (!subject || !body) return; + + await prisma.emailTemplates.update({ + where: { id }, + data: { + subject, + body, + variables: variablesRaw || null, + isActive, + }, + }); + revalidatePath("/admin/email-templates"); +} + +export async function deleteEmailTemplate(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!raw) return; + let id: bigint; + try { + id = BigInt(raw); + } catch { + return; + } + await prisma.emailTemplates.delete({ where: { id } }); + revalidatePath("/admin/email-templates"); +} diff --git a/src/actions/admin-emulator.ts b/src/actions/admin-emulator.ts new file mode 100644 index 00000000..ee4881e9 --- /dev/null +++ b/src/actions/admin-emulator.ts @@ -0,0 +1,36 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512). +// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096). +// Both tables are emulator-owned; we only ever read/update existing rows or add new +// keys via upsert. We never migrate or drop them. + +export async function updateEmulatorSetting(formData: FormData): Promise { + await requireStaff(); + const key = String(formData.get("key") ?? "").trim().slice(0, 100); + const value = String(formData.get("value") ?? "").slice(0, 512); + if (!key) return; + await prisma.emulatorSettings.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + revalidatePath("/admin/emulator"); +} + +export async function updateEmulatorText(formData: FormData): Promise { + await requireStaff(); + const key = String(formData.get("key") ?? "").trim().slice(0, 100); + const value = String(formData.get("value") ?? "").slice(0, 4096); + if (!key) return; + await prisma.emulatorTexts.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + revalidatePath("/admin/emulator"); +} diff --git a/src/actions/admin-housekeeping.ts b/src/actions/admin-housekeeping.ts new file mode 100644 index 00000000..7f8ce945 --- /dev/null +++ b/src/actions/admin-housekeeping.ts @@ -0,0 +1,47 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +/** + * Create or update a housekeeping permission (keyed by the unique `permission` + * string). Mirrors AtomCMS' housekeeping permission management. + */ +export async function upsertPermission(formData: FormData): Promise { + await requireStaff(); + + const permission = String(formData.get("permission") ?? "").trim().slice(0, 255); + const minRank = Number(formData.get("minRank")); + const descriptionRaw = String(formData.get("description") ?? "").trim().slice(0, 255); + const description = descriptionRaw.length > 0 ? descriptionRaw : null; + + if (!permission || !Number.isFinite(minRank) || minRank < 0) return; + + try { + await prisma.websiteHousekeepingPermissions.upsert({ + where: { permission }, + update: { minRank, description }, + create: { permission, minRank, description }, + }); + } catch { + // Swallow: duplicate/constraint issues shouldn't crash the action. + } + + revalidatePath("/admin/housekeeping"); +} + +export async function deletePermission(formData: FormData): Promise { + await requireStaff(); + + const raw = String(formData.get("id") ?? ""); + if (!raw) return; + + try { + await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } }); + } catch { + // Already gone / invalid id. + } + + revalidatePath("/admin/housekeeping"); +} diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts new file mode 100644 index 00000000..31735bbd --- /dev/null +++ b/src/actions/admin-photos.ts @@ -0,0 +1,19 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +export async function deletePhoto(formData: FormData): Promise { + await requireStaff(); + const id = Number(formData.get("id")); + if (!(id > 0)) return; + + try { + await prisma.cameraWeb.delete({ where: { id } }); + } catch { + // Record may have already been removed; ignore. + } + + revalidatePath("/admin/photos"); +} diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts new file mode 100644 index 00000000..d2fa97f9 --- /dev/null +++ b/src/actions/admin-rare-values.ts @@ -0,0 +1,88 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +export async function createCategory(formData: FormData): Promise { + await requireStaff(); + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + const badge = String(formData.get("badge") ?? "").trim().slice(0, 255); + const priorityRaw = Number(formData.get("priority")); + const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1; + if (!name || !badge) return; + + try { + await prisma.websiteRareValueCategories.create({ + data: { name, badge, priority }, + }); + } catch { + // Unique name collision or DB error — ignore, page will re-render unchanged. + } + revalidatePath("/admin/rare-values"); +} + +export async function deleteCategory(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!/^\d+$/.test(raw)) return; + const id = BigInt(raw); + + try { + // Remove the category's values first to avoid orphaned rows. + await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } }); + await prisma.websiteRareValueCategories.delete({ where: { id } }); + } catch { + // Not found or DB error — ignore. + } + revalidatePath("/admin/rare-values"); +} + +export async function createValue(formData: FormData): Promise { + await requireStaff(); + const categoryRaw = String(formData.get("categoryId") ?? ""); + if (!/^\d+$/.test(categoryRaw)) return; + const categoryId = BigInt(categoryRaw); + + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + const furnitureIcon = String(formData.get("furnitureIcon") ?? "").trim().slice(0, 255); + if (!name || !furnitureIcon) return; + + const itemIdRaw = Number(formData.get("itemId")); + const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null; + + const creditValueRaw = String(formData.get("creditValue") ?? "").trim().slice(0, 255); + const currencyValueRaw = String(formData.get("currencyValue") ?? "").trim().slice(0, 255); + const currencyType = String(formData.get("currencyType") ?? "diamonds").trim().slice(0, 255) || "diamonds"; + + try { + await prisma.websiteRareValues.create({ + data: { + categoryId, + itemId, + name, + creditValue: creditValueRaw || null, + currencyValue: currencyValueRaw || null, + currencyType, + furnitureIcon, + }, + }); + } catch { + // DB error — ignore. + } + revalidatePath("/admin/rare-values"); +} + +export async function deleteValue(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!/^\d+$/.test(raw)) return; + const id = BigInt(raw); + + try { + await prisma.websiteRareValues.delete({ where: { id } }); + } catch { + // Not found or DB error — ignore. + } + revalidatePath("/admin/rare-values"); +} diff --git a/src/actions/article-comments.ts b/src/actions/article-comments.ts new file mode 100644 index 00000000..9420213e --- /dev/null +++ b/src/actions/article-comments.ts @@ -0,0 +1,62 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// website_article_comments.comment is VARCHAR(255); keep the write within bounds. +const COMMENT_MAX = 255; + +/** + * Post a comment on a news article as the SIGNED-IN user. The author id is read + * from the session (re-fetched via auth()), never from the submitted FormData, + * so a crafted form cannot post as another account. The articleId comes from the + * form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT). + */ +export async function postComment(formData: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) return; + + const userId = Number(session.user.id); + if (!Number.isFinite(userId)) return; + + const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX); + if (!comment) return; + + const articleIdRaw = String(formData.get("articleId") ?? "").trim(); + if (!/^\d+$/.test(articleIdRaw)) return; + + let articleId: bigint; + try { + articleId = BigInt(articleIdRaw); + } catch { + return; + } + + let slug: string | null = null; + try { + // Confirm the article exists (and grab its slug for revalidation). + const article = await prisma.websiteArticles.findUnique({ + where: { id: articleId }, + select: { slug: true }, + }); + if (!article) return; + slug = article.slug; + + const now = new Date(); + await prisma.websiteArticleComments.create({ + data: { + articleId, + userId, + comment, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + if (slug) revalidatePath(`/news/${slug}`); +} diff --git a/src/actions/guestbook.ts b/src/actions/guestbook.ts new file mode 100644 index 00000000..279e2888 --- /dev/null +++ b/src/actions/guestbook.ts @@ -0,0 +1,50 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Emulator/CMS column message is VARCHAR(255); keep the write within bounds. +const MESSAGE_MAX = 255; + +/** + * Post a guestbook entry on a profile. + * + * The AUTHOR (userId) is re-read from the session via auth() and is never + * trusted from the submitted FormData, so a crafted form cannot impersonate + * another account. Only the PROFILE OWNER id (whose guestbook is written) is + * taken from the form, and we resolve a profile username from the form purely + * to revalidate the right page. + */ +export async function postGuestbook(formData: FormData): Promise { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) return; + + const profileId = Number(formData.get("profileId")); + if (!Number.isInteger(profileId) || profileId <= 0) return; + + const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX); + if (!message) return; + + // Optional: used only to revalidate the correct profile route. + const username = String(formData.get("username") ?? "").trim(); + + const now = new Date(); + try { + await prisma.websiteUserGuestbooks.create({ + data: { + profileId, + userId, + message, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + if (username) revalidatePath(`/u/${username}`); +} diff --git a/src/actions/voucher.ts b/src/actions/voucher.ts new file mode 100644 index 00000000..e28082d3 --- /dev/null +++ b/src/actions/voucher.ts @@ -0,0 +1,125 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; +import { sendCurrency } from "@/lib/services/send-currency"; + +/** Feedback returned to the client component via useActionState. */ +export type RedeemState = { ok: boolean; message: string } | null; + +/** + * Redeem a shop voucher for the SIGNED-IN user. Faithful to AtomCMS's + * ShopVoucherController: + * - the user id is re-read from the session (auth()), NEVER from FormData, + * so a crafted form cannot redeem on another account; + * - a code that is missing or expired is rejected; + * - each voucher may be redeemed once per user (website_used_shop_vouchers); + * - on success the reward `amount` is granted, the used-row is inserted, + * use_count is incremented, and the voucher is expired once max_uses is hit. + * + * The reward is delivered through sendCurrency({ rcon, db: prisma }); the + * voucher schema carries a single `amount`, granted as the website credits + * wallet currency. + */ +export async function redeem( + _prev: RedeemState, + formData: FormData, +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { ok: false, message: "You must be signed in to redeem a voucher." }; + } + + const userId = Number(session.user.id); + if (!Number.isFinite(userId)) { + return { ok: false, message: "Your session is invalid. Please sign in again." }; + } + + const code = String(formData.get("code") ?? "").trim(); + if (!code) { + return { ok: false, message: "Please enter a voucher code." }; + } + + // Look up the code (website_shop_vouchers.code is unique). + let voucher: { + id: bigint; + amount: number; + maxUses: number; + useCount: number; + expiresAt: Date | null; + } | null = null; + try { + voucher = await prisma.websiteShopVouchers.findUnique({ + where: { code }, + select: { id: true, amount: true, maxUses: true, useCount: true, expiresAt: true }, + }); + } catch { + return { ok: false, message: "We couldn't reach the server. Please try again." }; + } + + // Not found OR already expired -> generic "no active voucher" (matches AtomCMS). + if (!voucher || (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())) { + return { ok: false, message: "No active voucher with the given code was found." }; + } + + // One redemption per user. + try { + const already = await prisma.websiteUsedShopVouchers.findFirst({ + where: { userId, voucherId: voucher.id }, + select: { id: true }, + }); + if (already) { + return { ok: false, message: "You can only use each shop voucher once." }; + } + } catch { + return { ok: false, message: "We couldn't reach the server. Please try again." }; + } + + // Record the redemption first so a successful grant can never be double-claimed. + try { + await prisma.websiteUsedShopVouchers.create({ + data: { userId, voucherId: voucher.id }, + }); + } catch { + // Most likely a race (another tab redeemed it) — treat as already used. + return { ok: false, message: "You can only use each shop voucher once." }; + } + + // Grant the reward. The voucher carries a single amount, delivered as credits. + try { + await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount); + } catch { + // sendCurrency already falls back to a direct DB write; if it still throws, + // the used-row stands and the balance simply wasn't credited — surface that. + return { + ok: false, + message: "Your voucher was accepted but the reward could not be delivered. Contact staff.", + }; + } + + // Bump use_count and expire the voucher once the cap is reached. + try { + const updated = await prisma.websiteShopVouchers.update({ + where: { id: voucher.id }, + data: { useCount: { increment: 1 } }, + select: { maxUses: true, useCount: true }, + }); + if (updated.maxUses && updated.useCount >= updated.maxUses) { + await prisma.websiteShopVouchers.update({ + where: { id: voucher.id }, + data: { expiresAt: new Date() }, + }); + } + } catch { + // Reward already delivered; the counter bump is best-effort. + } + + revalidatePath("/redeem"); + + return { + ok: true, + message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`, + }; +} diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx new file mode 100644 index 00000000..58dd658d --- /dev/null +++ b/src/app/admin/badges/page.tsx @@ -0,0 +1,83 @@ +import { giveBadge } from "@/actions/admin-badges"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function AdminBadges() { + let badges: Awaited> = []; + try { + badges = await prisma.websiteBadges.findMany({ + orderBy: { badgeName: "asc" }, + take: 500, + }); + } catch { + badges = []; + } + + return ( +
+

Badges

+

+ Website badge catalogue (website_badges). Granting fires the emulator + givebadge RCON command and records the badge in users_badges. +

+ +
+

Give badge to user

+
+ + + + {badges.map((b) => ( + + ))} + + +
+
+ + + + + + + + + + + {badges.map((b) => ( + + + + + + + ))} + +
CodeNameDescription +
+ {b.badgeKey} + {b.badgeName}{b.badgeDescription} +
+ + + +
+
+ {badges.length === 0 ?

No badges in the catalogue.

: null} +
+ ); +} diff --git a/src/app/admin/email-templates/page.tsx b/src/app/admin/email-templates/page.tsx new file mode 100644 index 00000000..85ae75a6 --- /dev/null +++ b/src/app/admin/email-templates/page.tsx @@ -0,0 +1,138 @@ +import { + createEmailTemplate, + deleteEmailTemplate, + updateEmailTemplate, +} from "@/actions/admin-email-templates"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function AdminEmailTemplates() { + let templates: Awaited> = []; + try { + templates = await prisma.emailTemplates.findMany({ orderBy: { name: "asc" } }); + } catch { + templates = []; + } + + return ( +
+

Email Templates

+

+ Transactional email bodies. Use {"{{ placeholders }}"} in the body and list + the available ones in the variables field (comma-separated). +

+ +
+

New template

+
+ + + + +
+