From 0cd4d06ff6554e92b449e8aee66fdb109a5803df Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 21:04:34 +0200 Subject: [PATCH] Fill the remaining gaps: badge upload, radio tools, VPN, writeable boxes Built the admin tools previously listed as missing: - Badge upload (/admin/badges): uploads a .gif into the emulator's badge dir via BADGE_UPLOAD_DIR (node:fs); validated code/type/size, logged. Made configurable rather than skipped. - Radio tools: /admin/radio/api-keys (CRUD, server-generated keys), /admin/radio/autodj (Auto-DJ playlist CRUD), /admin/radio/embed (embed snippet generator), /admin/radio/points (points settings), /admin/radio/monitoring (live stream/now-playing/listeners status). radio_api_keys + radio_auto_dj_playlist already had real columns. - /admin/vpn: VPN/proxy detection config (block toggle + provider + key), complementing /admin/ip's raw blacklist. - Writeable boxes: new website_writeable_boxes table (model + migration 0006) + /admin/writeable-boxes CRUD; active boxes render on the public home page. env: BADGE_UPLOAD_DIR. Verified live (prod, amx_test): all 8 pages render with real data; a test writeable box appeared on the public home and was reverted. tsc 0, vitest 49/49, next build 0 (7 new admin routes). --- .env.example | 5 + prisma/migrations/0006_writeable_boxes.sql | 13 + prisma/schema.prisma | 13 + src/actions/admin-badge-upload.ts | 66 +++++ src/actions/admin-radio-api-keys.ts | 130 +++++++++ src/actions/admin-radio-autodj.ts | 137 +++++++++ src/actions/admin-radio-points.ts | 79 +++++ src/actions/admin-vpn.ts | 69 +++++ src/actions/admin-writeable-boxes.ts | 155 ++++++++++ src/app/admin/badges/page.tsx | 189 ++++++++---- src/app/admin/layout.tsx | 2 + src/app/admin/radio/api-keys/page.tsx | 226 +++++++++++++++ src/app/admin/radio/autodj/page.tsx | 265 +++++++++++++++++ src/app/admin/radio/embed/embed-snippet.tsx | 69 +++++ src/app/admin/radio/embed/page.tsx | 133 +++++++++ src/app/admin/radio/monitoring/page.tsx | 305 ++++++++++++++++++++ src/app/admin/radio/points/page.tsx | 222 ++++++++++++++ src/app/admin/vpn/page.tsx | 163 +++++++++++ src/app/admin/writeable-boxes/page.tsx | 242 ++++++++++++++++ src/app/page.tsx | 17 ++ src/env.ts | 4 + 21 files changed, 2443 insertions(+), 61 deletions(-) create mode 100644 prisma/migrations/0006_writeable_boxes.sql create mode 100644 src/actions/admin-badge-upload.ts create mode 100644 src/actions/admin-radio-api-keys.ts create mode 100644 src/actions/admin-radio-autodj.ts create mode 100644 src/actions/admin-radio-points.ts create mode 100644 src/actions/admin-vpn.ts create mode 100644 src/actions/admin-writeable-boxes.ts create mode 100644 src/app/admin/radio/api-keys/page.tsx create mode 100644 src/app/admin/radio/autodj/page.tsx create mode 100644 src/app/admin/radio/embed/embed-snippet.tsx create mode 100644 src/app/admin/radio/embed/page.tsx create mode 100644 src/app/admin/radio/monitoring/page.tsx create mode 100644 src/app/admin/radio/points/page.tsx create mode 100644 src/app/admin/vpn/page.tsx create mode 100644 src/app/admin/writeable-boxes/page.tsx diff --git a/.env.example b/.env.example index 1a1c51da..85b2a611 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ CONVERT_PASSWORDS=false # column). Existing accounts in either format still verify on login. PASSWORD_HASH=bcrypt +# Filesystem directory the badge uploader (/admin/badges) writes .gif into +# — the emulator's badge image folder (e.g. .../assets/c_images/album1584). +# Leave unset to disable badge uploads. +BADGE_UPLOAD_DIR= + # RCON link to the Arcturus emulator RCON_HOST=127.0.0.1 RCON_PORT=3001 diff --git a/prisma/migrations/0006_writeable_boxes.sql b/prisma/migrations/0006_writeable_boxes.sql new file mode 100644 index 00000000..7fe98168 --- /dev/null +++ b/prisma/migrations/0006_writeable_boxes.sql @@ -0,0 +1,13 @@ +-- CMS-owned editable content boxes (AtomCMS WriteableBox). Rendered on the +-- public site and managed from housekeeping. Idempotent (MariaDB). +CREATE TABLE IF NOT EXISTS website_writeable_boxes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + icon VARCHAR(255) NULL, + content TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c80989b8..3c64cc35 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1674,6 +1674,19 @@ model WebsiteWordfilter { @@map("website_wordfilter") } +model WebsiteWriteableBoxes { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + title String @db.VarChar(255) + icon String? @db.VarChar(255) + content String @db.Text + position Int @default(0) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime? @map("created_at") @db.Timestamp(0) + updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + + @@map("website_writeable_boxes") +} + model WebsiteBetaCodes { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt code String @unique @db.VarChar(255) diff --git a/src/actions/admin-badge-upload.ts b/src/actions/admin-badge-upload.ts new file mode 100644 index 00000000..6c9f4dbe --- /dev/null +++ b/src/actions/admin-badge-upload.ts @@ -0,0 +1,66 @@ +"use server"; + +import path from "node:path"; +import { writeFile } from "node:fs/promises"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// Writes a badge image to the configured emulator badge directory. The path is +// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's +// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only +// ever stores .gif badges, so every upload is normalised to `.gif`. + +const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/; +const MAX_BYTES = 1024 * 1024; // 1MB +const ALLOWED_TYPES = new Set(["image/gif", "image/png"]); + +function back(param: string, value: string): never { + redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`); +} + +export async function uploadBadge(formData: FormData): Promise { + const staff = await requireStaff(); + + const dir = process.env.BADGE_UPLOAD_DIR; + if (!dir) { + back("error", "Badge upload directory not configured"); + } + + const code = String(formData.get("code") ?? "").trim(); + if (!CODE_RE.test(code)) { + back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)"); + } + + const file = formData.get("file"); + if (!(file instanceof File)) { + back("error", "No file uploaded"); + } + + if (file.size === 0) { + back("error", "Uploaded file is empty"); + } + if (file.size > MAX_BYTES) { + back("error", "File too large (max 1MB)"); + } + if (!ALLOWED_TYPES.has(file.type)) { + back("error", "File must be a GIF or PNG image"); + } + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const target = path.join(dir, `${code}.gif`); + await writeFile(target, buffer); + } catch { + back("error", "Could not write the badge file to disk"); + } + + await logStaffActivity({ + staffId: staff.id, + action: "badge_upload", + description: `Uploaded badge image "${code}.gif"`, + targetType: "badge", + }); + + redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`); +} diff --git a/src/actions/admin-radio-api-keys.ts b/src/actions/admin-radio-api-keys.ts new file mode 100644 index 00000000..53b2e308 --- /dev/null +++ b/src/actions/admin-radio-api-keys.ts @@ -0,0 +1,130 @@ +"use server"; + +import { randomBytes } from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// Radio API keys (radio_api_keys). External integrations (AzureCast bridges, +// widgets, bots) authenticate with a server-generated key. The key itself is +// minted here with crypto.randomBytes — never accepted from the form — and the +// `permissions` JSON column is intentionally left untouched by this CMS slice. + +function str(raw: FormDataEntryValue | null): string { + return typeof raw === "string" ? raw : ""; +} + +/** Parse a BigInt id from a form value, or null when blank/invalid. */ +function parseId(raw: FormDataEntryValue | null): bigint | null { + const s = str(raw).trim(); + if (!s) return null; + try { + return BigInt(s); + } catch { + return null; + } +} + +/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */ +function intOr(raw: FormDataEntryValue | null, fallback: number): number { + const n = Number(str(raw).trim()); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.floor(n); +} + +export async function createApiKey(formData: FormData): Promise { + const staff = await requireStaff(); + + const name = str(formData.get("name")).trim().slice(0, 255); + if (!name) return; + + const rateLimit = intOr(formData.get("rateLimit"), 300); + const allowedIps = str(formData.get("allowedIps")).trim().slice(0, 255) || null; + + // Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)). + const key = randomBytes(24).toString("hex"); + + const now = new Date(); + try { + const created = await prisma.radioApiKeys.create({ + data: { + name, + key, + allowedIps, + rateLimit, + isActive: true, + createdAt: now, + updatedAt: now, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "radio_api_key_create", + description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`, + targetType: "radio_api_key", + targetId: Number(created.id), + }); + } catch { + // Unique-key collision (astronomically unlikely) or DB down — fail soft. + return; + } + + revalidatePath("/admin/radio/api-keys"); + redirect("/admin/radio/api-keys?created=1"); +} + +export async function toggleApiKey(formData: FormData): Promise { + const staff = await requireStaff(); + + const id = parseId(formData.get("id")); + if (id == null) return; + + try { + const existing = await prisma.radioApiKeys.findUnique({ + where: { id }, + select: { name: true, isActive: true }, + }); + if (!existing) return; + + const next = !existing.isActive; + await prisma.radioApiKeys.update({ + where: { id }, + data: { isActive: next, updatedAt: new Date() }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "radio_api_key_toggle", + description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`, + targetType: "radio_api_key", + targetId: Number(id), + }); + } catch { + return; + } + + revalidatePath("/admin/radio/api-keys"); +} + +export async function deleteApiKey(formData: FormData): Promise { + const staff = await requireStaff(); + + const id = parseId(formData.get("id")); + if (id == null) return; + + try { + await prisma.radioApiKeys.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: "radio_api_key_delete", + description: `Deleted radio API key #${id}`, + targetType: "radio_api_key", + targetId: Number(id), + }); + } catch { + return; + } + + revalidatePath("/admin/radio/api-keys"); +} diff --git a/src/actions/admin-radio-autodj.ts b/src/actions/admin-radio-autodj.ts new file mode 100644 index 00000000..82969e05 --- /dev/null +++ b/src/actions/admin-radio-autodj.ts @@ -0,0 +1,137 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireStaff } from '@/lib/admin/guard'; +import { prisma } from '@/lib/prisma'; +import { logStaffActivity } from '@/lib/services/staff-activity'; + +// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the +// fallback playlist the radio rotates through when no live DJ is streaming. +// Faithful to AtomCMS: a flat list of tracks ordered by sort_order then title. + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** Parse a FormData field into a positive BigInt id, or null when invalid. */ +function parseId(raw: FormDataEntryValue | null): bigint | null { + if (typeof raw !== 'string' || raw.trim() === '') return null; + try { + const id = BigInt(raw.trim()); + return id > 0n ? id : null; + } catch { + return null; + } +} + +function str(raw: FormDataEntryValue | null): string { + return typeof raw === 'string' ? raw : ''; +} + +/** Checkbox/select truthiness: '1', 'true', 'on' → true. */ +function bool(raw: FormDataEntryValue | null): boolean { + const v = str(raw).trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'on'; +} + +/** Parse a non-negative UnsignedInt, falling back to 0. */ +function reqUInt(raw: FormDataEntryValue | null): number { + const n = Number(str(raw).trim()); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.trunc(n); +} + +/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */ +function optUInt(raw: FormDataEntryValue | null): number | null { + const s = str(raw).trim(); + if (s === '') return null; + const n = Number(s); + if (!Number.isFinite(n) || n < 0) return null; + return Math.trunc(n); +} + +// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ──────────────────────── + +export async function createTrack(formData: FormData): Promise { + const staff = await requireStaff(); + const title = str(formData.get('title')).trim().slice(0, 255); + if (!title) return; + + const artist = str(formData.get('artist')).trim().slice(0, 255); + const album = str(formData.get('album')).trim().slice(0, 255); + const artworkUrl = str(formData.get('artworkUrl')).trim().slice(0, 255); + const duration = optUInt(formData.get('duration')); + const sortOrder = reqUInt(formData.get('sortOrder')); + const isActive = bool(formData.get('isActive')); + const now = new Date(); + + try { + const created = await prisma.radioAutoDjPlaylist.create({ + data: { + title, + artist: artist || null, + album: album || null, + artworkUrl: artworkUrl || null, + duration, + sortOrder, + isActive, + createdAt: now, + updatedAt: now, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: 'radio_autodj_create', + description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ''}`, + targetType: 'radio_auto_dj_track', + targetId: Number(created.id), + }); + } catch { + // Fail soft — DB unavailable; re-render without throwing. + } + revalidatePath('/admin/radio/autodj'); +} + +export async function toggleTrack(formData: FormData): Promise { + const staff = await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + + // The form posts the desired next state so the toggle is idempotent. + const isActive = bool(formData.get('isActive')); + + try { + await prisma.radioAutoDjPlaylist.update({ + where: { id }, + data: { isActive, updatedAt: new Date() }, + }); + await logStaffActivity({ + staffId: staff.id, + action: 'radio_autodj_toggle', + description: `${isActive ? 'Activated' : 'Deactivated'} AutoDJ track #${id}`, + targetType: 'radio_auto_dj_track', + targetId: Number(id), + }); + } catch { + // Row may be gone; ignore. + } + revalidatePath('/admin/radio/autodj'); +} + +export async function deleteTrack(formData: FormData): Promise { + const staff = await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + + try { + await prisma.radioAutoDjPlaylist.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: 'radio_autodj_delete', + description: `Deleted AutoDJ track #${id}`, + targetType: 'radio_auto_dj_track', + targetId: Number(id), + }); + } catch { + // Already deleted; ignore. + } + revalidatePath('/admin/radio/autodj'); +} diff --git a/src/actions/admin-radio-points.ts b/src/actions/admin-radio-points.ts new file mode 100644 index 00000000..b9951982 --- /dev/null +++ b/src/actions/admin-radio-points.ts @@ -0,0 +1,79 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { siteSettings } from "@/lib/services/site-settings"; + +// Radio listener-points settings (website_settings radio_points_* keys). +// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in +// website_settings that reward listeners for time spent on the radio. Booleans +// use the string '0' / '1'. Busts the siteSettings cache so the public radio +// pages pick the change up immediately. + +const POINTS_KEYS = [ + "radio_points_enabled", + "radio_points_per_minute", + "radio_points_currency", + "radio_points_max_per_day", + "radio_points_min_listeners", +] as const; + +const ALLOWED_CURRENCIES = new Set(["credits", "duckets", "diamonds", "points"]); + +function str(raw: FormDataEntryValue | null): string { + return typeof raw === "string" ? raw : ""; +} + +/** Checkbox/select truthiness → '1' / '0'. */ +function boolStr(raw: FormDataEntryValue | null): "0" | "1" { + const v = str(raw).trim().toLowerCase(); + return v === "1" || v === "true" || v === "on" ? "1" : "0"; +} + +/** Clamp a form value to a non-negative integer string (defaulting to 0). */ +function intStr(raw: FormDataEntryValue | null): string { + const n = Number(str(raw).trim()); + if (!Number.isFinite(n) || n < 0) return "0"; + return String(Math.floor(n)); +} + +export async function savePoints(formData: FormData): Promise { + const staff = await requireStaff(); + + const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase(); + const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits"; + + const values: Record<(typeof POINTS_KEYS)[number], string> = { + radio_points_enabled: boolStr(formData.get("radio_points_enabled")), + radio_points_per_minute: intStr(formData.get("radio_points_per_minute")), + radio_points_currency: currency, + radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")), + radio_points_min_listeners: intStr(formData.get("radio_points_min_listeners")), + }; + + try { + await prisma.$transaction( + POINTS_KEYS.map((key) => + prisma.websiteSetting.upsert({ + where: { key }, + update: { value: values[key] }, + create: { key, value: values[key], comment: "Radio points" }, + }), + ), + ); + siteSettings.reload(); + await logStaffActivity({ + staffId: staff.id, + action: "radio_points_update", + description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`, + }); + } catch { + // DB unavailable — fail soft so the action does not throw. + } + + revalidatePath("/admin/radio/points"); + redirect("/admin/radio/points?saved=1"); +} diff --git a/src/actions/admin-vpn.ts b/src/actions/admin-vpn.ts new file mode 100644 index 00000000..711ffb15 --- /dev/null +++ b/src/actions/admin-vpn.ts @@ -0,0 +1,69 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// VPN / proxy detection config. Stored as website_settings key/value rows +// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to +// AtomCMS's setting() convention. This is registration-time protection only; +// the raw IP allow/deny list lives under /admin/ip (website_ip_*). + +const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]); + +/** Upsert one website_settings key with a stable housekeeping comment. */ +async function writeSetting(key: string, value: string, comment: string): Promise { + await prisma.websiteSetting.upsert({ + where: { key }, + update: { value }, + create: { key, value, comment }, + }); +} + +export async function saveVpn(formData: FormData): Promise { + const staff = await requireStaff(); + + // Toggle: an unchecked checkbox submits nothing, so absence === disabled. + const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== ""; + + const providerRaw = String(formData.get("vpn_provider") ?? "").trim().toLowerCase(); + const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none"; + + const apiKey = String(formData.get("vpn_api_key") ?? "").trim().slice(0, 255); + const blockMessage = String(formData.get("vpn_block_message") ?? "").trim().slice(0, 255); + + try { + await writeSetting( + "vpn_block_enabled", + enabled ? "1" : "0", + "Block registrations from detected VPN/proxy IPs (0=no, 1=yes)", + ); + await writeSetting( + "vpn_provider", + provider, + "VPN/proxy detection provider (none/proxycheck/ipqualityscore)", + ); + await writeSetting("vpn_api_key", apiKey, "API key for the VPN/proxy detection provider"); + await writeSetting( + "vpn_block_message", + blockMessage, + "Message shown to users blocked for using a VPN/proxy", + ); + + siteSettings.reload(); + await logStaffActivity({ + staffId: staff.id, + action: "vpn_update", + description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`, + }); + revalidatePath("/admin/vpn"); + } catch { + // DB unavailable — fail soft so the action does not throw; the page + // re-renders the current (stored) state. + } + + redirect("/admin/vpn?saved=1"); +} diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts new file mode 100644 index 00000000..c45f0a96 --- /dev/null +++ b/src/actions/admin-writeable-boxes.ts @@ -0,0 +1,155 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// Writeable boxes (website_writeable_boxes). CMS-owned table backing the +// content panels rendered on the public home page. Active boxes (is_active) +// are the ones shown publicly, ordered by `position`. + +/** Parse a non-negative Int form value, falling back to 0. */ +function reqInt(formData: FormData, key: string): number { + const raw = String(formData.get(key) ?? "").trim(); + if (raw === "") return 0; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} + +/** Parse the BigInt `id` form value, returning null when blank/invalid. */ +function parseId(formData: FormData): bigint | null { + const raw = String(formData.get("id") ?? "").trim(); + if (!raw) return null; + try { + return BigInt(raw); + } catch { + return null; + } +} + +function revalidate(): void { + revalidatePath("/admin/writeable-boxes"); + // Active boxes render on the public home page (root layout). + revalidatePath("/", "layout"); +} + +export async function createBox(formData: FormData): Promise { + const staff = await requireStaff(); + + const title = String(formData.get("title") ?? "").trim().slice(0, 255); + if (!title) return; + + const now = new Date(); + try { + const created = await prisma.websiteWriteableBoxes.create({ + data: { + title, + icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null, + content: String(formData.get("content") ?? ""), + position: reqInt(formData, "position"), + isActive: String(formData.get("isActive") ?? "") === "1", + createdAt: now, + updatedAt: now, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "writeable_box_create", + description: `Created writeable box "${title}" (#${created.id})`, + targetType: "writeable_box", + targetId: Number(created.id), + }); + } catch { + // DB unavailable — swallow and re-render. + return; + } + + revalidate(); +} + +export async function updateBox(formData: FormData): Promise { + const staff = await requireStaff(); + + const id = parseId(formData); + if (id == null) return; + + const title = String(formData.get("title") ?? "").trim().slice(0, 255); + if (!title) return; + + try { + await prisma.websiteWriteableBoxes.update({ + where: { id }, + data: { + title, + icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null, + content: String(formData.get("content") ?? ""), + position: reqInt(formData, "position"), + isActive: String(formData.get("isActive") ?? "") === "1", + updatedAt: new Date(), + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "writeable_box_update", + description: `Updated writeable box #${id} ("${title}")`, + targetType: "writeable_box", + targetId: Number(id), + }); + } catch { + return; + } + + revalidate(); +} + +export async function deleteBox(formData: FormData): Promise { + const staff = await requireStaff(); + + const id = parseId(formData); + if (id == null) return; + + try { + await prisma.websiteWriteableBoxes.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: "writeable_box_delete", + description: `Deleted writeable box #${id}`, + targetType: "writeable_box", + targetId: Number(id), + }); + } catch { + return; + } + + revalidate(); +} + +export async function toggleBox(formData: FormData): Promise { + const staff = await requireStaff(); + + const id = parseId(formData); + if (id == null) return; + + // `next` carries the desired state ("1" to activate, anything else to hide). + const next = String(formData.get("next") ?? "") === "1"; + + try { + await prisma.websiteWriteableBoxes.update({ + where: { id }, + data: { isActive: next, updatedAt: new Date() }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "writeable_box_toggle", + description: `${next ? "Activated" : "Hid"} writeable box #${id}`, + targetType: "writeable_box", + targetId: Number(id), + }); + } catch { + return; + } + + revalidate(); +} diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx index 58dd658d..9641fc93 100644 --- a/src/app/admin/badges/page.tsx +++ b/src/app/admin/badges/page.tsx @@ -1,9 +1,18 @@ import { giveBadge } from "@/actions/admin-badges"; +import { uploadBadge } from "@/actions/admin-badge-upload"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; -export default async function AdminBadges() { +export const metadata = { title: "Badges" }; + +export default async function AdminBadges({ + searchParams, +}: { + searchParams: Promise<{ uploaded?: string; error?: string }>; +}) { + const { uploaded, error } = await searchParams; + let badges: Awaited> = []; try { badges = await prisma.websiteBadges.findMany({ @@ -16,68 +25,126 @@ export default async function AdminBadges() { return (
-

Badges

-

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

+
+

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) => ( - - ))} - - -
-
+ {uploaded ? ( +

+ Uploaded Badge image{" "} + {uploaded}.gif saved. +

+ ) : null} + {error ? ( +

+ Error {error} +

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

No badges in the catalogue.

: null} +
+

Upload badge image

+

+ Saves a .gif badge image to the emulator badge directory + (BADGE_UPLOAD_DIR). The file is stored as <code>.gif. +

+
+
+ + + +
+
+
+ +
+

Give badge to user

+
+
+ + + + {badges.map((b) => ( + + ))} + + +
+
+
+ +
+

Catalogue ({badges.length})

+ {badges.length === 0 ? ( +

No badges in the catalogue.

+ ) : ( +
+ + + + + + + + + + {badges.map((b) => ( + + + + + + + ))} + +
CodeNameDescription +
+ {b.badgeKey} + {b.badgeName}{b.badgeDescription} +
+ + + +
+
+
+ )} +
); } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 574d6bf2..38eb774c 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -22,6 +22,7 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[] { href: "/admin/navigation", label: "Navigator" }, { href: "/admin/help-questions", label: "Help center" }, { href: "/admin/ads", label: "Advertisements" }, + { href: "/admin/writeable-boxes", label: "Writeable boxes" }, { href: "/admin/photos", label: "Photos" }, ], }, @@ -31,6 +32,7 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[] { href: "/admin/users", label: "Users" }, { href: "/admin/bans", label: "Bans" }, { href: "/admin/ip", label: "IP management" }, + { href: "/admin/vpn", label: "VPN" }, { href: "/admin/applications", label: "Applications" }, { href: "/admin/teams", label: "Teams" }, { href: "/admin/permissions", label: "Permissions" }, diff --git a/src/app/admin/radio/api-keys/page.tsx b/src/app/admin/radio/api-keys/page.tsx new file mode 100644 index 00000000..cac59ca2 --- /dev/null +++ b/src/app/admin/radio/api-keys/page.tsx @@ -0,0 +1,226 @@ +import Link from "next/link"; +import { createApiKey, deleteApiKey, toggleApiKey } from "@/actions/admin-radio-api-keys"; +import { StatusCard } from "@/components/admin/dashboard"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export const metadata = { title: "Radio API Keys" }; + +type ApiKey = { + id: bigint; + name: string; + key: string; + allowedIps: string | null; + rateLimit: number; + lastUsedAt: Date | null; + expiresAt: Date | null; + isActive: boolean; + createdAt: Date | null; +}; + +function formatDate(d: Date | null): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—"; +} + +/** Show only the first 8 chars of the secret; the rest stays masked. */ +function maskKey(key: string): string { + return key.length > 8 ? `${key.slice(0, 8)}…` : key; +} + +export default async function AdminRadioApiKeysPage() { + let keys: ApiKey[] = []; + let dbError = false; + + try { + keys = await prisma.radioApiKeys + .findMany({ + select: { + id: true, + name: true, + key: true, + allowedIps: true, + rateLimit: true, + lastUsedAt: true, + expiresAt: true, + isActive: true, + createdAt: true, + }, + orderBy: [{ isActive: "desc" }, { id: "desc" }], + take: 500, + }) + .catch(() => { + dbError = true; + return []; + }); + } catch { + dbError = true; + keys = []; + } + + const total = keys.length; + const activeCount = keys.filter((k) => k.isActive).length; + const inactiveCount = total - activeCount; + + return ( +
+ + +
+

Radio API Keys

+

+ API keys for external radio integrations (radio_api_keys). Keys are generated + server-side and shown masked — copy the full value from the database when first issued. +

+ + {dbError ? ( +
+

+ Could not load API keys (database unavailable). +

+
+ ) : ( +
+ + 0 ? "ok" : "neutral"} + icon="✅" + /> + 0 ? "warn" : "neutral"} + icon="🚫" + /> +
+ )} +
+ +
+

Generate key

+

+ The secret key is generated automatically with cryptographic randomness — you do not enter + it. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

Existing keys ({total})

+ {total === 0 ? ( +

No API keys yet — generate one above.

+ ) : ( +
+ + + + + + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + + + + + ))} + +
#NameKeyAllowed IPsRate limitLast usedExpiresStatusActions
{String(k.id)}{k.name} + {maskKey(k.key)} + {k.allowedIps || "any"}{k.rateLimit}/min{formatDate(k.lastUsedAt)}{formatDate(k.expiresAt)} + + {k.isActive ? "active" : "inactive"} + + +
+
+ + +
+
+ + +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/radio/autodj/page.tsx b/src/app/admin/radio/autodj/page.tsx new file mode 100644 index 00000000..17fd9591 --- /dev/null +++ b/src/app/admin/radio/autodj/page.tsx @@ -0,0 +1,265 @@ +import Link from 'next/link'; +import { createTrack, deleteTrack, toggleTrack } from '@/actions/admin-radio-autodj'; +import { StatusCard } from '@/components/admin/dashboard'; +import { prisma } from '@/lib/prisma'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { title: 'Radio AutoDJ' }; + +type Track = { + id: bigint; + title: string; + artist: string | null; + album: string | null; + artworkUrl: string | null; + duration: number | null; + playCount: number; + lastPlayedAt: Date | null; + isActive: boolean; + sortOrder: number; +}; + +/** Render an integer second count as m:ss, or an em dash when unset. */ +function formatDuration(seconds: number | null): string { + if (seconds == null || seconds < 0) return '—'; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function formatDate(d: Date | null): string { + return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—'; +} + +export default async function AdminRadioAutoDjPage() { + let tracks: Track[] = []; + let dbError = false; + + try { + tracks = await prisma.radioAutoDjPlaylist + .findMany({ + select: { + id: true, + title: true, + artist: true, + album: true, + artworkUrl: true, + duration: true, + playCount: true, + lastPlayedAt: true, + isActive: true, + sortOrder: true, + }, + orderBy: [{ sortOrder: 'asc' }, { title: 'asc' }], + }) + .catch(() => { + dbError = true; + return []; + }); + } catch { + dbError = true; + tracks = []; + } + + const activeCount = tracks.filter((t) => t.isActive).length; + const inactiveCount = tracks.length - activeCount; + + return ( +
+ + +
+

Radio AutoDJ

+

+ Fallback playlist (radio_auto_dj_playlist) the radio rotates + through when no live DJ is streaming. Ordered by sort order then title. +

+ + {dbError ? ( +
+

+ Could not load the AutoDJ playlist (database unavailable). +

+
+ ) : ( +
+ + 0 ? 'ok' : 'neutral'} + icon="▶️" + /> + 0 ? 'warn' : 'neutral'} + icon="⏸️" + /> +
+ )} +
+ +
+

Add track

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ +
+

Playlist ({tracks.length})

+ {tracks.length === 0 ? ( +

No AutoDJ tracks yet — add one above.

+ ) : ( +
+ + + + + + + + + + + + + + + + {tracks.map((t) => ( + + + + + + + + + + + + ))} + +
OrderTitleArtistAlbumDurationPlaysLast playedStatusActions
{t.sortOrder} + + {t.artworkUrl ? ( + + ) : null} + {t.title} + + {t.artist ?? '—'}{t.album ?? '—'}{formatDuration(t.duration)}{t.playCount}{formatDate(t.lastPlayedAt)} + + {t.isActive ? 'active' : 'inactive'} + + +
+
+ + + +
+
+ + +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/radio/embed/embed-snippet.tsx b/src/app/admin/radio/embed/embed-snippet.tsx new file mode 100644 index 00000000..9b54077b --- /dev/null +++ b/src/app/admin/radio/embed/embed-snippet.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useState } from 'react'; + +/** + * Read-only copy box for an embed snippet. Renders the code inside a + *