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 <code>.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).
This commit is contained in:
Simo committed 2026-06-28 21:04:34 +02:00
1 parent e004dfedaf
commit 0cd4d06ff6
21 files changed
+2443 -61

No files matched your search

+5
View File
@@ -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 <code>.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
@@ -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;
+13
View File
@@ -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)
+66
View File
@@ -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 `<code>.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<void> {
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)}`);
}
+130
View File
@@ -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<void> {
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<void> {
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<void> {
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");
}
+137
View File
@@ -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<void> {
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<void> {
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<void> {
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');
}
+79
View File
@@ -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<void> {
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");
}
+69
View File
@@ -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<void> {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment },
});
}
export async function saveVpn(formData: FormData): Promise<void> {
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");
}
+155
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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();
}
+128 -61
View File
@@ -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<ReturnType<typeof prisma.websiteBadges.findMany>> = [];
try {
badges = await prisma.websiteBadges.findMany({
@@ -16,68 +25,126 @@ export default async function AdminBadges() {
return (
<main>
<h1>Badges</h1>
<p className="muted">
Website badge catalogue (<code>website_badges</code>). Granting fires the emulator
<code> givebadge</code> RCON command and records the badge in <code>users_badges</code>.
</p>
<section className="admin-section">
<h2>Badges</h2>
<p className="muted">
Website badge catalogue (<code>website_badges</code>). Granting fires the emulator
<code> givebadge</code> RCON command and records the badge in <code>users_badges</code>.
</p>
<form action={giveBadge} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Give badge to user</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
<input name="userId" type="number" min={1} placeholder="User ID" required />
<input
name="code"
placeholder="Badge code (e.g. ADM)"
required
maxLength={32}
style={{ flex: 1, minWidth: 160 }}
list="badge-codes"
/>
<datalist id="badge-codes">
{badges.map((b) => (
<option key={String(b.id)} value={b.badgeKey}>
{b.badgeName}
</option>
))}
</datalist>
<button type="submit" className="btn btn-primary">
Give badge
</button>
</div>
</form>
{uploaded ? (
<p>
<span className="admin-badge ok">Uploaded</span> Badge image{" "}
<code>{uploaded}.gif</code> saved.
</p>
) : null}
{error ? (
<p>
<span className="admin-badge danger">Error</span> {error}
</p>
) : null}
</section>
<table>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
<th />
</tr>
</thead>
<tbody>
{badges.map((b) => (
<tr key={String(b.id)}>
<td>
<code>{b.badgeKey}</code>
</td>
<td>{b.badgeName}</td>
<td className="muted">{b.badgeDescription}</td>
<td>
<form action={giveBadge} style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input type="hidden" name="code" value={b.badgeKey} />
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: 90 }} />
<button type="submit" className="btn btn-outline">
Give
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{badges.length === 0 ? <p className="muted">No badges in the catalogue.</p> : null}
<section className="admin-section">
<h2>Upload badge image</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Saves a <code>.gif</code> badge image to the emulator badge directory
(<code>BADGE_UPLOAD_DIR</code>). The file is stored as <code>&lt;code&gt;.gif</code>.
</p>
<form action={uploadBadge} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
<input type="file" name="file" accept=".gif,.png" required />
<input
name="code"
placeholder="Badge code (e.g. ADM)"
required
maxLength={64}
pattern="[A-Za-z0-9_-]{1,64}"
style={{ flex: 1, minWidth: 160 }}
/>
<button type="submit" className="btn btn-primary">
Upload
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Give badge to user</h2>
<form action={giveBadge} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
<input name="userId" type="number" min={1} placeholder="User ID" required />
<input
name="code"
placeholder="Badge code (e.g. ADM)"
required
maxLength={32}
style={{ flex: 1, minWidth: 160 }}
list="badge-codes"
/>
<datalist id="badge-codes">
{badges.map((b) => (
<option key={String(b.id)} value={b.badgeKey}>
{b.badgeName}
</option>
))}
</datalist>
<button type="submit" className="btn btn-primary">
Give badge
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Catalogue ({badges.length})</h2>
{badges.length === 0 ? (
<p className="muted">No badges in the catalogue.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
<th />
</tr>
</thead>
<tbody>
{badges.map((b) => (
<tr key={String(b.id)}>
<td>
<code>{b.badgeKey}</code>
</td>
<td>{b.badgeName}</td>
<td className="muted">{b.badgeDescription}</td>
<td>
<form
action={giveBadge}
style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}
>
<input type="hidden" name="code" value={b.badgeKey} />
<input
name="userId"
type="number"
min={1}
placeholder="User ID"
required
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">
Give
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+2
View File
@@ -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" },
+226
View File
@@ -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 (
<main>
<nav className="muted" style={{ marginBottom: "0.5rem" }}>
<Link href="/admin/radio">Radio</Link> ·{" "}
<Link href="/admin/radio/settings">Settings</Link> ·{" "}
<Link href="/admin/radio/api-keys">API Keys</Link> ·{" "}
<Link href="/admin/radio/autodj">AutoDJ</Link> ·{" "}
<Link href="/admin/radio/embed">Embed</Link> ·{" "}
<Link href="/admin/radio/points">Points</Link> ·{" "}
<Link href="/admin/radio/monitoring">Monitoring</Link>
</nav>
<section className="admin-section">
<h1>Radio API Keys</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
API keys for external radio integrations (<code>radio_api_keys</code>). Keys are generated
server-side and shown masked — copy the full value from the database when first issued.
</p>
{dbError ? (
<div className="card" style={{ marginTop: "1rem" }}>
<p className="muted" style={{ margin: 0 }}>
Could not load API keys (database unavailable).
</p>
</div>
) : (
<div className="admin-stats">
<StatusCard label="Total keys" value={total} icon="🔑" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label="Inactive"
value={inactiveCount}
state={inactiveCount > 0 ? "warn" : "neutral"}
icon="🚫"
/>
</div>
)}
</section>
<section className="admin-section">
<h2>Generate key</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
The secret key is generated automatically with cryptographic randomness — you do not enter
it.
</p>
<form action={createApiKey} className="card">
<div className="grid cols-2">
<div>
<label htmlFor="new_name" style={{ display: "block", fontWeight: 700 }}>
Name
</label>
<input
id="new_name"
name="name"
placeholder="AzureCast bridge"
required
maxLength={255}
style={{ width: "100%" }}
/>
</div>
<div>
<label htmlFor="new_rateLimit" style={{ display: "block", fontWeight: 700 }}>
Rate limit (requests/min)
</label>
<input
id="new_rateLimit"
name="rateLimit"
type="number"
min={0}
defaultValue={300}
style={{ width: "100%" }}
/>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label htmlFor="new_allowedIps" style={{ display: "block", fontWeight: 700 }}>
Allowed IPs (optional)
</label>
<input
id="new_allowedIps"
name="allowedIps"
placeholder="comma-separated, blank = any"
maxLength={255}
style={{ width: "100%" }}
/>
</div>
</div>
<div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary">
Generate key
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Existing keys ({total})</h2>
{total === 0 ? (
<p className="muted">No API keys yet — generate one above.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Key</th>
<th>Allowed IPs</th>
<th>Rate limit</th>
<th>Last used</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={String(k.id)}>
<td className="muted">{String(k.id)}</td>
<td>{k.name}</td>
<td>
<code title="Only the first 8 characters are shown">{maskKey(k.key)}</code>
</td>
<td className="muted">{k.allowedIps || "any"}</td>
<td className="muted">{k.rateLimit}/min</td>
<td className="muted">{formatDate(k.lastUsedAt)}</td>
<td className="muted">{formatDate(k.expiresAt)}</td>
<td>
<span className={`admin-badge ${k.isActive ? "ok" : "danger"}`}>
{k.isActive ? "active" : "inactive"}
</span>
</td>
<td>
<div style={{ display: "inline-flex", gap: "0.4rem" }}>
<form action={toggleApiKey}>
<input type="hidden" name="id" value={String(k.id)} />
<button type="submit" className="btn">
{k.isActive ? "Deactivate" : "Activate"}
</button>
</form>
<form action={deleteApiKey}>
<input type="hidden" name="id" value={String(k.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+265
View File
@@ -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 (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link> ·{' '}
<Link href="/admin/radio/autodj">AutoDJ</Link>
</nav>
<section className="admin-section">
<h2>Radio AutoDJ</h2>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Fallback playlist (<code>radio_auto_dj_playlist</code>) the radio rotates
through when no live DJ is streaming. Ordered by sort order then title.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load the AutoDJ playlist (database unavailable).
</p>
</div>
) : (
<div className="admin-stats">
<StatusCard label="Tracks" value={tracks.length} icon="🎵" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="▶️"
/>
<StatusCard
label="Inactive"
value={inactiveCount}
state={inactiveCount > 0 ? 'warn' : 'neutral'}
icon="⏸️"
/>
</div>
)}
</section>
<section className="admin-section">
<h2>Add track</h2>
<form action={createTrack} className="card">
<div className="grid cols-2">
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
</label>
<input
id="new_title"
name="title"
placeholder="Track title"
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_artist" style={{ display: 'block', fontWeight: 700 }}>
Artist
</label>
<input id="new_artist" name="artist" style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_album" style={{ display: 'block', fontWeight: 700 }}>
Album
</label>
<input id="new_album" name="album" style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_artworkUrl" style={{ display: 'block', fontWeight: 700 }}>
Artwork URL
</label>
<input
id="new_artworkUrl"
name="artworkUrl"
placeholder="https://…/cover.png"
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_duration" style={{ display: 'block', fontWeight: 700 }}>
Duration (seconds)
</label>
<input
id="new_duration"
name="duration"
type="number"
min={0}
placeholder="e.g. 215"
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
</label>
<input
id="new_sortOrder"
name="sortOrder"
type="number"
defaultValue={0}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Add track
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Playlist ({tracks.length})</h2>
{tracks.length === 0 ? (
<p className="muted">No AutoDJ tracks yet — add one above.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Plays</th>
<th>Last played</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{tracks.map((t) => (
<tr key={String(t.id)}>
<td className="muted">{t.sortOrder}</td>
<td>
<span
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
>
{t.artworkUrl ? (
<img
src={t.artworkUrl}
alt=""
width={32}
height={32}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : null}
<strong>{t.title}</strong>
</span>
</td>
<td className="muted">{t.artist ?? '—'}</td>
<td className="muted">{t.album ?? '—'}</td>
<td className="muted">{formatDuration(t.duration)}</td>
<td className="muted">{t.playCount}</td>
<td className="muted">{formatDate(t.lastPlayedAt)}</td>
<td>
<span className={`admin-badge ${t.isActive ? 'ok' : 'danger'}`}>
{t.isActive ? 'active' : 'inactive'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<form action={toggleTrack}>
<input type="hidden" name="id" value={String(t.id)} />
<input type="hidden" name="isActive" value={t.isActive ? '0' : '1'} />
<button type="submit" className="btn">
{t.isActive ? 'Deactivate' : 'Activate'}
</button>
</form>
<form action={deleteTrack}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
@@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
/**
* Read-only copy box for an embed snippet. Renders the code inside a
* <textarea readOnly> so staff can select/copy it, plus a one-click copy
* button (with clipboard fallback to selecting the text).
*/
export function EmbedSnippet({
title,
description,
snippet,
}: {
title: string;
description?: string;
snippet: string;
}) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API unavailable (e.g. insecure context) — fall back to
// selecting the textarea so the user can copy manually.
const el = document.getElementById(
`snippet-${title.replace(/\s+/g, '-').toLowerCase()}`,
) as HTMLTextAreaElement | null;
el?.select();
}
}
const id = `snippet-${title.replace(/\s+/g, '-').toLowerCase()}`;
const lines = snippet.split('\n').length;
return (
<section className="admin-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
<button type="button" className="btn btn-primary" onClick={copy}>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
{description ? (
<p className="muted" style={{ marginTop: '0.4rem' }}>
{description}
</p>
) : null}
<textarea
id={id}
readOnly
value={snippet}
rows={Math.min(Math.max(lines + 1, 3), 14)}
onFocus={(e) => e.currentTarget.select()}
spellCheck={false}
style={{
width: '100%',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '0.85rem',
whiteSpace: 'pre',
resize: 'vertical',
}}
/>
</section>
);
}
+133
View File
@@ -0,0 +1,133 @@
import Link from 'next/link';
import { StatusCard } from '@/components/admin/dashboard';
import { siteSettings } from '@/lib/services/site-settings';
import { requireStaff } from '@/lib/admin/guard';
import { EmbedSnippet } from './embed-snippet';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Radio Embed' };
export default async function AdminRadioEmbedPage() {
await requireStaff();
// Read radio embed-related website_settings. siteSettings.get() already
// swallows DB errors and serves fallbacks, but wrap defensively anyway so a
// throw never blanks the page.
let streamUrl = '';
let radioName = 'Radio';
let nowPlayingApiUrl = '';
try {
streamUrl = (await siteSettings.get('radio_stream_url', '')) ?? '';
radioName =
(await siteSettings.get('radio_name', null)) ??
(await siteSettings.get('hotel_name', 'Radio')) ??
'Radio';
nowPlayingApiUrl = (await siteSettings.get('radio_now_playing_api_url', '')) ?? '';
} catch {
streamUrl = '';
}
streamUrl = streamUrl.trim();
nowPlayingApiUrl = nowPlayingApiUrl.trim();
const hasStream = streamUrl !== '';
// Copy-ready embed snippets. Built server-side from the configured stream URL
// so what staff copy matches what the preview renders.
const audioSnippet = hasStream
? `<audio controls preload="none" src="${streamUrl}">\n Your browser does not support the audio element.\n</audio>`
: '';
const iframeSnippet = hasStream
? `<iframe\n src="/radio/embed"\n title="${radioName}"\n width="320"\n height="120"\n frameborder="0"\n allow="autoplay"\n style="border:0;border-radius:8px;overflow:hidden"\n></iframe>`
: '';
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/embed">Embed</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<section className="admin-section">
<h1>Radio Embed</h1>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Copy-ready embed code for the live stream. Read-only — nothing here
writes to the database. Configure the stream URL in{' '}
<Link href="/admin/radio/settings">Radio Settings</Link> (
<code>radio_stream_url</code>).
</p>
<div className="admin-stats">
<StatusCard
label="Stream URL"
value={hasStream ? 'Configured' : 'Missing'}
state={hasStream ? 'ok' : 'danger'}
icon="📻"
/>
<StatusCard label="Radio name" value={radioName} icon="🎙️" />
<StatusCard
label="Now-playing API"
value={nowPlayingApiUrl ? 'Set' : 'Not set'}
state={nowPlayingApiUrl ? 'ok' : 'neutral'}
icon="🎵"
/>
</div>
</section>
{!hasStream ? (
<section className="admin-section">
<h2>No stream URL configured</h2>
<p className="muted">
There is no <code>radio_stream_url</code> set, so there is nothing to
embed yet. Open{' '}
<Link href="/admin/radio/settings">Radio Settings</Link> and fill in
the <strong>Main stream URL</strong> field under{' '}
<em>AzureCast &amp; Stream</em>. Once saved, return here to copy the
embed snippet and preview the player.
</p>
<p>
<Link href="/admin/radio/settings" className="btn btn-primary">
Go to Radio Settings
</Link>
</p>
</section>
) : (
<>
<section className="admin-section">
<h2>Live preview</h2>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
This is the bare <code>&lt;audio&gt;</code> player as visitors will
see it. Press play to confirm the stream is reachable.
</p>
<div className="card">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio controls preload="none" src={streamUrl} style={{ width: '100%' }}>
Your browser does not support the audio element.
</audio>
<p className="muted" style={{ margin: '0.5rem 0 0', wordBreak: 'break-all' }}>
Source: <code>{streamUrl}</code>
</p>
</div>
</section>
<EmbedSnippet
title="Audio player snippet"
description="Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed."
snippet={audioSnippet}
/>
<EmbedSnippet
title="Iframe wrapper snippet"
description="Embeds the site's own radio player page in an iframe — keeps your styling and now-playing info. Adjust width/height to taste."
snippet={iframeSnippet}
/>
</>
)}
</main>
);
}
+305
View File
@@ -0,0 +1,305 @@
import Link from 'next/link';
import { requireStaff } from '@/lib/admin/guard';
import { siteSettings } from '@/lib/services/site-settings';
import { StatusCard, DiagnosticRow } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Radio Monitoring' };
// READ-ONLY live probe of the configured radio endpoints. We never write to the
// DB here; we only read the relevant radio_* keys via siteSettings.get and do a
// best-effort, fail-soft server-side fetch of each URL with a short timeout so a
// slow/down stream provider can never hang the admin page.
const FETCH_TIMEOUT_MS = 4000;
type ProbeResult = {
/** true when the endpoint responded with a 2xx/3xx status within the timeout. */
reachable: boolean;
status: number | null;
/** Parsed JSON when the body was JSON, else null. */
json: unknown;
/** Raw text body (trimmed) when not JSON, else null. */
text: string | null;
error: string | null;
};
/**
* Fetch a URL server-side with a hard AbortController timeout. Always resolves
* (never throws) so the page renders even when the provider is unreachable.
*/
async function probe(url: string): Promise<ProbeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: { accept: 'application/json, text/plain, */*' },
});
const raw = await res.text();
let json: unknown = null;
let text: string | null = null;
const trimmed = raw.trim();
if (trimmed !== '') {
try {
json = JSON.parse(trimmed);
} catch {
text = trimmed.slice(0, 2000);
}
}
return {
reachable: res.ok,
status: res.status,
json,
text,
error: res.ok ? null : `HTTP ${res.status}`,
};
} catch (e) {
const aborted = e instanceof Error && e.name === 'AbortError';
return {
reachable: false,
status: null,
json: null,
text: null,
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : 'Fetch failed',
};
} finally {
clearTimeout(timer);
}
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function pickString(obj: Record<string, unknown>, key: string): string | null {
const v = obj[key];
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
}
/**
* Best-effort now-playing extraction. Supports the common AzureCast shape
* (now_playing.song.title / .artist), a flat { title, artist }, a bare string,
* or { now_playing: "..." }. Returns a single display string or null.
*/
function parseNowPlaying(result: ProbeResult): string | null {
if (result.json == null) {
return result.text && result.text.length <= 200 ? result.text : null;
}
const data = result.json;
if (typeof data === 'string') return data.trim() || null;
if (!isRecord(data)) return null;
// AzureCast: { now_playing: { song: { title, artist, text } } }
const np = data.now_playing;
if (typeof np === 'string' && np.trim() !== '') return np.trim();
if (isRecord(np)) {
const song = np.song;
if (isRecord(song)) {
const title = pickString(song, 'title');
const artist = pickString(song, 'artist');
if (title || artist) {
return [artist, title].filter(Boolean).join(' - ');
}
const txt = pickString(song, 'text');
if (txt) return txt;
}
}
// Flat shapes: { song: { title, artist } } or { title, artist } or { text }.
const song = data.song;
if (isRecord(song)) {
const title = pickString(song, 'title');
const artist = pickString(song, 'artist');
if (title || artist) return [artist, title].filter(Boolean).join(' - ');
}
const title = pickString(data, 'title');
const artist = pickString(data, 'artist');
if (title || artist) return [artist, title].filter(Boolean).join(' - ');
return pickString(data, 'text') ?? pickString(data, 'song') ?? null;
}
function findNumberDeep(value: unknown, keys: string[], depth = 0): number | null {
if (depth > 4) return null;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (!isRecord(value)) return null;
for (const key of keys) {
const v = value[key];
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) {
return Number(v);
}
}
for (const v of Object.values(value)) {
if (isRecord(v)) {
const found = findNumberDeep(v, keys, depth + 1);
if (found !== null) return found;
}
}
return null;
}
/**
* Best-effort listener count. Looks for listeners(.current/.total), num_listeners,
* unique_listeners, or a bare numeric body. Returns null when nothing parses.
*/
function parseListeners(result: ProbeResult): number | null {
if (result.json == null) {
if (result.text && Number.isFinite(Number(result.text.trim()))) {
return Number(result.text.trim());
}
return null;
}
const data = result.json;
if (typeof data === 'number' && Number.isFinite(data)) return data;
return findNumberDeep(data, [
'current',
'total',
'num_listeners',
'listeners',
'unique_listeners',
'count',
]);
}
function shortUrl(url: string): string {
try {
const u = new URL(url);
return u.host + (u.pathname === '/' ? '' : u.pathname);
} catch {
return url.length > 60 ? `${url.slice(0, 57)}…` : url;
}
}
export default async function AdminRadioMonitoringPage() {
await requireStaff();
let nowPlayingUrl: string | null = null;
let listenersUrl: string | null = null;
let streamUrl: string | null = null;
try {
nowPlayingUrl = (await siteSettings.get('radio_now_playing_api_url', '')) || null;
listenersUrl = (await siteSettings.get('radio_listeners_api_url', '')) || null;
streamUrl = (await siteSettings.get('radio_stream_url', '')) || null;
} catch {
nowPlayingUrl = null;
listenersUrl = null;
streamUrl = null;
}
// Probe each configured endpoint (fail-soft). The now-playing endpoint doubles
// as our "stream reachable?" signal since it reflects the live provider.
const nowPlayingResult = nowPlayingUrl ? await probe(nowPlayingUrl) : null;
const listenersResult = listenersUrl ? await probe(listenersUrl) : null;
const nowPlaying = nowPlayingResult ? parseNowPlaying(nowPlayingResult) : null;
const listeners = listenersResult ? parseListeners(listenersResult) : null;
// Stream is "online" when its primary status source (now-playing, else
// listeners) responded successfully.
const streamProbe = nowPlayingResult ?? listenersResult;
const streamOnline = streamProbe?.reachable ?? false;
const streamConfigured = Boolean(nowPlayingUrl || listenersUrl);
const endpoints: {
label: string;
url: string | null;
result: ProbeResult | null;
}[] = [
{ label: 'Now-playing API', url: nowPlayingUrl, result: nowPlayingResult },
{ label: 'Listeners API', url: listenersUrl, result: listenersResult },
];
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/monitoring">Monitoring</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<section className="admin-section">
<h2>Radio Monitoring</h2>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Read-only live probe of the configured radio endpoints
(<code>radio_now_playing_api_url</code>, <code>radio_listeners_api_url</code>).
Each request fails soft with a {FETCH_TIMEOUT_MS / 1000}s timeout — no data is
written. Configure URLs under{' '}
<Link href="/admin/radio/settings">Settings</Link>.
</p>
<div className="admin-stats">
<StatusCard
label="Stream"
value={
!streamConfigured ? 'Not configured' : streamOnline ? 'Online' : 'Offline'
}
state={!streamConfigured ? 'neutral' : streamOnline ? 'ok' : 'danger'}
hint={streamUrl ? shortUrl(streamUrl) : undefined}
icon="📡"
/>
<StatusCard
label="Now playing"
value={nowPlaying ?? '—'}
state={nowPlaying ? 'ok' : 'neutral'}
hint={!nowPlayingUrl ? 'no now-playing API set' : undefined}
icon="🎵"
/>
<StatusCard
label="Listeners"
value={listeners ?? '—'}
state={listeners != null ? 'ok' : 'neutral'}
hint={!listenersUrl ? 'no listeners API set' : undefined}
icon="🎧"
/>
</div>
</section>
<section className="admin-section">
<h2>Endpoint diagnostics</h2>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
<span className="admin-badge ok">OK</span> reachable ·{' '}
<span className="admin-badge">WARN</span> not configured ·{' '}
<span className="admin-badge danger">ERROR</span> fetch failed / timed out.
</p>
<div className="admin-diag">
{endpoints.map((ep) => {
let state: 'ok' | 'warn' | 'danger';
let detail: string;
if (!ep.url) {
state = 'warn';
detail = 'Not configured';
} else if (ep.result?.reachable) {
state = 'ok';
detail = `${shortUrl(ep.url)}${
ep.result.status != null ? ` · HTTP ${ep.result.status}` : ''
}`;
} else {
state = 'danger';
detail = `${shortUrl(ep.url)} · ${ep.result?.error ?? 'unreachable'}`;
}
return (
<DiagnosticRow
key={ep.label}
label={ep.label}
detail={detail}
state={state}
/>
);
})}
<DiagnosticRow
label="Stream URL"
detail={streamUrl ? shortUrl(streamUrl) : 'Not configured'}
state={streamUrl ? 'ok' : 'warn'}
/>
</div>
</section>
</main>
);
}
+222
View File
@@ -0,0 +1,222 @@
import Link from "next/link";
import { savePoints } from "@/actions/admin-radio-points";
import { StatusCard } from "@/components/admin/dashboard";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export const metadata = { title: "Radio Points" };
// Defaults used when the website_settings rows are missing OR the DB is
// unreachable, so the editor still renders in local dev.
const DEFAULTS = {
radio_points_enabled: "0",
radio_points_per_minute: "1",
radio_points_currency: "credits",
radio_points_max_per_day: "0",
radio_points_min_listeners: "0",
} as const;
const CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
export default async function AdminRadioPointsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string }>;
}) {
const { saved } = await searchParams;
// Read current values with siteSettings.get(defaults); fall back to DEFAULTS
// on any read error so select()-typed values stay strings.
const values = {
radio_points_enabled: await siteSettings
.get("radio_points_enabled", DEFAULTS.radio_points_enabled)
.catch(() => DEFAULTS.radio_points_enabled),
radio_points_per_minute: await siteSettings
.get("radio_points_per_minute", DEFAULTS.radio_points_per_minute)
.catch(() => DEFAULTS.radio_points_per_minute),
radio_points_currency: await siteSettings
.get("radio_points_currency", DEFAULTS.radio_points_currency)
.catch(() => DEFAULTS.radio_points_currency),
radio_points_max_per_day: await siteSettings
.get("radio_points_max_per_day", DEFAULTS.radio_points_max_per_day)
.catch(() => DEFAULTS.radio_points_max_per_day),
radio_points_min_listeners: await siteSettings
.get("radio_points_min_listeners", DEFAULTS.radio_points_min_listeners)
.catch(() => DEFAULTS.radio_points_min_listeners),
};
const enabled = (values.radio_points_enabled ?? "0") === "1";
const perMinute = values.radio_points_per_minute ?? DEFAULTS.radio_points_per_minute;
const currency = (values.radio_points_currency ?? DEFAULTS.radio_points_currency) as string;
const maxPerDay = values.radio_points_max_per_day ?? DEFAULTS.radio_points_max_per_day;
const minListeners = values.radio_points_min_listeners ?? DEFAULTS.radio_points_min_listeners;
const selectedCurrency = CURRENCIES.includes(currency as (typeof CURRENCIES)[number])
? currency
: "credits";
return (
<main>
<nav className="muted" style={{ marginBottom: "0.5rem" }}>
<Link href="/admin/radio">Radio</Link> ·{" "}
<Link href="/admin/radio/settings">Settings</Link> ·{" "}
<Link href="/admin/radio/points">Points</Link> ·{" "}
<Link href="/admin/radio/banners">Banners</Link> ·{" "}
<Link href="/admin/radio/ranks">Ranks</Link> ·{" "}
<Link href="/admin/radio/history">History</Link>
</nav>
<section className="admin-section">
<h2>Radio Listener Points</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Reward listeners with currency for time spent on the radio. Edits write
to <code>website_settings</code> (<code>radio_points_*</code> keys) and
bust the runtime settings cache. Booleans use <code>0</code> /{" "}
<code>1</code>.
</p>
{saved === "1" ? (
<p>
<span className="admin-badge ok">Saved</span>
</p>
) : null}
<div className="admin-stats">
<StatusCard
label="Points"
value={enabled ? "Enabled" : "Disabled"}
state={enabled ? "ok" : "danger"}
icon="🎧"
/>
<StatusCard
label="Per minute"
value={perMinute}
hint={`paid in ${selectedCurrency}`}
icon="⏱️"
/>
<StatusCard
label="Max / day"
value={maxPerDay === "0" ? "Unlimited" : maxPerDay}
icon="📈"
/>
<StatusCard label="Min listeners" value={minListeners} icon="👥" />
</div>
</section>
<section className="admin-section">
<h2>Settings</h2>
<form action={savePoints}>
<div className="grid cols-2">
<div>
<label htmlFor="radio_points_enabled" style={{ display: "block", fontWeight: 700 }}>
Points enabled
</label>
<select
id="radio_points_enabled"
name="radio_points_enabled"
defaultValue={enabled ? "1" : "0"}
style={{ width: "100%" }}
>
<option value="1">Enabled</option>
<option value="0">Disabled</option>
</select>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Master switch for awarding listener points.
</p>
</div>
<div>
<label htmlFor="radio_points_currency" style={{ display: "block", fontWeight: 700 }}>
Currency
</label>
<select
id="radio_points_currency"
name="radio_points_currency"
defaultValue={selectedCurrency}
style={{ width: "100%" }}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</option>
))}
</select>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Which wallet the points are paid into.
</p>
</div>
<div>
<label
htmlFor="radio_points_per_minute"
style={{ display: "block", fontWeight: 700 }}
>
Points per minute
</label>
<input
id="radio_points_per_minute"
name="radio_points_per_minute"
type="number"
min={0}
step={1}
defaultValue={perMinute}
style={{ width: "100%" }}
/>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Amount awarded for each full minute of listening.
</p>
</div>
<div>
<label
htmlFor="radio_points_max_per_day"
style={{ display: "block", fontWeight: 700 }}
>
Max points per day
</label>
<input
id="radio_points_max_per_day"
name="radio_points_max_per_day"
type="number"
min={0}
step={1}
defaultValue={maxPerDay}
style={{ width: "100%" }}
/>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Daily cap per user. Use <code>0</code> for unlimited.
</p>
</div>
<div>
<label
htmlFor="radio_points_min_listeners"
style={{ display: "block", fontWeight: 700 }}
>
Minimum listeners
</label>
<input
id="radio_points_min_listeners"
name="radio_points_min_listeners"
type="number"
min={0}
step={1}
defaultValue={minListeners}
style={{ width: "100%" }}
/>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Points are only awarded when at least this many people are tuned in.
</p>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "1rem" }}>
<button type="submit" className="btn btn-primary">
Save points settings
</button>
</div>
</form>
</section>
</main>
);
}
+163
View File
@@ -0,0 +1,163 @@
import Link from "next/link";
import { saveVpn } from "@/actions/admin-vpn";
import { StatusCard } from "@/components/admin/dashboard";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export const metadata = { title: "VPN" };
const PROVIDERS: { value: string; label: string }[] = [
{ value: "none", label: "None (disabled)" },
{ value: "proxycheck", label: "proxycheck.io" },
{ value: "ipqualityscore", label: "IPQualityScore" },
];
export default async function AdminVpn({
searchParams,
}: {
searchParams: Promise<{ saved?: string }>;
}) {
const sp = await searchParams;
// Read current values (graceful fallbacks via siteSettings / DEFAULTS).
const enabled = await siteSettings.getBool("vpn_block_enabled", false);
const provider = (await siteSettings.get("vpn_provider", "none")) ?? "none";
const apiKey = (await siteSettings.get("vpn_api_key", "")) ?? "";
const blockMessage =
(await siteSettings.get(
"vpn_block_message",
"Registrations from VPNs or proxies are not allowed.",
)) ?? "";
return (
<main>
<section className="admin-section">
<h2>VPN / Proxy Detection</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Block registrations from detected VPN/proxy IPs at sign-up. Settings
are stored in <code>website_settings</code> (booleans use{" "}
<code>0</code> / <code>1</code>). This is distinct from the raw IP
allow/deny list — manage that under <Link href="/admin/ip">IP Management</Link>.
</p>
{sp.saved ? (
<p className="admin-badge ok" style={{ display: "inline-block" }}>
VPN settings saved
</p>
) : null}
<div className="admin-stats">
<StatusCard
label="Protection"
value={enabled ? "On" : "Off"}
state={enabled ? "ok" : "danger"}
hint={enabled ? `Provider: ${provider}` : "Registrations not screened"}
icon="🛡️"
/>
</div>
</section>
<section className="admin-section">
<h2>Settings</h2>
<form action={saveVpn} className="card">
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<input
type="checkbox"
name="vpn_block_enabled"
value="1"
defaultChecked={enabled}
/>
<span style={{ fontWeight: 700 }}>
Block registrations from detected VPN/proxy IPs
</span>
</label>
</div>
<div className="grid cols-2">
<div>
<label
htmlFor="vpn_provider"
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
>
Provider
</label>
<select
id="vpn_provider"
name="vpn_provider"
defaultValue={provider}
style={{ width: "100%" }}
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Detection service used to score incoming IPs.
</p>
</div>
<div>
<label
htmlFor="vpn_api_key"
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
>
API key
</label>
<input
id="vpn_api_key"
name="vpn_api_key"
defaultValue={apiKey}
autoComplete="off"
placeholder="Provider API key"
style={{ width: "100%" }}
/>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Required by the selected provider (leave blank for none).
</p>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
<label
htmlFor="vpn_block_message"
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
>
Block message
</label>
<input
id="vpn_block_message"
name="vpn_block_message"
defaultValue={blockMessage}
placeholder="Shown to users blocked for using a VPN/proxy"
style={{ width: "100%" }}
/>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
Message displayed when a registration is rejected.
</p>
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Raw IP allow / deny list</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
VPN detection screens unknown IPs automatically. To explicitly allow
or block specific IP addresses or ASNs (overriding detection), use{" "}
<Link href="/admin/ip">IP Management</Link>.
</p>
<Link href="/admin/ip" className="btn btn-secondary">
Open IP Management
</Link>
</section>
</main>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { createBox, deleteBox, toggleBox, updateBox } from "@/actions/admin-writeable-boxes";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const metadata = { title: "Writeable boxes" };
export default async function AdminWriteableBoxes() {
const boxes = await prisma.websiteWriteableBoxes
.findMany({
orderBy: [{ position: "asc" }, { id: "asc" }],
take: 500,
})
.catch(() => []);
const activeCount = boxes.filter((b) => b.isActive).length;
return (
<main>
<section className="admin-section">
<h2>Writeable boxes</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Content panels stored in <code>website_writeable_boxes</code> and ordered by{" "}
<code>position</code>. <strong>Active</strong> boxes are shown on the public home page;
hidden ones are kept here as drafts.
</p>
<div className="admin-stats">
<StatusCard label="Total boxes" value={boxes.length} icon="🗂️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? "ok" : "neutral"}
hint="shown on the public home page"
icon="✅"
/>
</div>
</section>
<section className="admin-section">
<h2>New box</h2>
<form action={createBox} className="grid cols-2">
<div>
<label htmlFor="new_title" style={{ display: "block", fontWeight: 700 }}>
Title
</label>
<input id="new_title" name="title" required maxLength={255} style={{ width: "100%" }} />
</div>
<div>
<label htmlFor="new_icon" style={{ display: "block", fontWeight: 700 }}>
Icon (emoji)
</label>
<input
id="new_icon"
name="icon"
maxLength={255}
placeholder="📰"
style={{ width: "100%" }}
/>
</div>
<div>
<label htmlFor="new_position" style={{ display: "block", fontWeight: 700 }}>
Position
</label>
<input
id="new_position"
name="position"
type="number"
min={0}
defaultValue={boxes.length}
style={{ width: "100%" }}
/>
</div>
<div style={{ display: "flex", alignItems: "flex-end" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontWeight: 700 }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label htmlFor="new_content" style={{ display: "block", fontWeight: 700 }}>
Content (HTML)
</label>
<textarea
id="new_content"
name="content"
rows={5}
style={{ width: "100%", fontFamily: "monospace" }}
/>
</div>
<div style={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary">
+ Create box
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Boxes ({boxes.length})</h2>
{boxes.length === 0 ? (
<p className="muted">No writeable boxes yet.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{boxes.map((box) => (
<div key={String(box.id)} className="card">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.6rem",
marginBottom: "0.75rem",
}}
>
<span className="muted">#{String(box.id)}</span>
{box.icon ? <span aria-hidden>{box.icon}</span> : null}
<strong style={{ flex: 1 }}>{box.title}</strong>
<span className={`admin-badge ${box.isActive ? "ok" : "danger"}`}>
{box.isActive ? "Active" : "Hidden"}
</span>
</div>
<form action={updateBox} className="grid cols-2">
<input type="hidden" name="id" value={String(box.id)} />
<div>
<label
htmlFor={`title_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Title
</label>
<input
id={`title_${box.id}`}
name="title"
required
maxLength={255}
defaultValue={box.title}
style={{ width: "100%" }}
/>
</div>
<div>
<label
htmlFor={`icon_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Icon (emoji)
</label>
<input
id={`icon_${box.id}`}
name="icon"
maxLength={255}
defaultValue={box.icon ?? ""}
style={{ width: "100%" }}
/>
</div>
<div>
<label
htmlFor={`position_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Position
</label>
<input
id={`position_${box.id}`}
name="position"
type="number"
min={0}
defaultValue={box.position}
style={{ width: "100%" }}
/>
</div>
<div style={{ display: "flex", alignItems: "flex-end" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontWeight: 700,
}}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={box.isActive}
/>{" "}
Active
</label>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label
htmlFor={`content_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Content (HTML)
</label>
<textarea
id={`content_${box.id}`}
name="content"
rows={5}
defaultValue={box.content}
style={{ width: "100%", fontFamily: "monospace" }}
/>
</div>
<div style={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<div
style={{
display: "flex",
gap: "0.5rem",
marginTop: "0.75rem",
paddingTop: "0.75rem",
borderTop: "1px solid rgba(255,255,255,0.08)",
}}
>
<form action={toggleBox}>
<input type="hidden" name="id" value={String(box.id)} />
<input type="hidden" name="next" value={box.isActive ? "0" : "1"} />
<button type="submit" className="btn">
{box.isActive ? "Hide" : "Activate"}
</button>
</form>
<form action={deleteBox}>
<input type="hidden" name="id" value={String(box.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
</div>
))}
</div>
)}
</section>
</main>
);
}
+17
View File
@@ -22,6 +22,15 @@ export default async function HomePage() {
// DB unavailable — show the shell without articles.
}
// Staff-authored content boxes (managed at /admin/writeable-boxes).
const boxes = await prisma.websiteWriteableBoxes
.findMany({
where: { isActive: true },
orderBy: [{ position: "asc" }, { id: "asc" }],
select: { id: true, title: true, icon: true, content: true },
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
@@ -81,6 +90,14 @@ export default async function HomePage() {
</div>
)}
</ContentCard>
{boxes.map((b) => (
<ContentCard key={String(b.id)} icon={b.icon ?? "📌"} title={b.title}>
{/* Content is authored by staff in housekeeping (trusted HTML). */}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored writeable box */}
<div dangerouslySetInnerHTML={{ __html: b.content }} />
</ContentCard>
))}
</main>
);
}
+4
View File
@@ -42,6 +42,10 @@ const schema = z.object({
.transform((v) => v === "true" || v === "1"),
// Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id.
PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset.
BADGE_UPLOAD_DIR: z.string().optional(),
// Optional AI content moderation (comments / guestbook).
OPENAI_API_KEY: z.string().optional(),
// Optional alerting (jobs worker / alert service).