Add niche admin + public expansions + self-host Nunito font (batches 3-4)

Built via two more parallel agent workflows (read schema -> return files),
integrated + verified.

Admin: /admin/emulator (emulator_settings + emulator_texts key/value editor),
/admin/badges (give-badge via RCON), /admin/rare-values (CRUD), /admin/housekeeping
(CRUD), /admin/email-templates (CRUD), /admin/photos (moderation).
Public: /rares (+[category]), /leaderboard (credits/diamonds/duckets),
/guilds (+[id] members), /redeem (voucher -> sendCurrency).
Expanded: /u/[username] now shows badges + photos + guestbook (post form);
/news/[slug] now shows reactions + comments (comment form). Reactions tallied
in JS (Prisma groupBy typing avoided).
Self-hosted Nunito via next/font/google wired to --font-nunito (the atom theme
font, no runtime external fetch). Header + admin nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0.
This commit is contained in:
Simo committed 2026-06-28 13:44:23 +02:00
1 parent e96b606e1e
commit e8be0461d8
28 files changed
+2347 -5

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import { giveBadge } from "@/actions/admin-badges";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminBadges() {
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>> = [];
try {
badges = await prisma.websiteBadges.findMany({
orderBy: { badgeName: "asc" },
take: 500,
});
} catch {
badges = [];
}
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>
<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>
<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}
</main>
);
}
+138
View File
@@ -0,0 +1,138 @@
import {
createEmailTemplate,
deleteEmailTemplate,
updateEmailTemplate,
} from "@/actions/admin-email-templates";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminEmailTemplates() {
let templates: Awaited<ReturnType<typeof prisma.emailTemplates.findMany>> = [];
try {
templates = await prisma.emailTemplates.findMany({ orderBy: { name: "asc" } });
} catch {
templates = [];
}
return (
<main>
<h1>Email Templates</h1>
<p className="muted">
Transactional email bodies. Use <code>{"{{ placeholders }}"}</code> in the body and list
the available ones in the variables field (comma-separated).
</p>
<form
action={createEmailTemplate}
className="card"
style={{ marginBottom: "1.5rem" }}
>
<h3 style={{ marginTop: 0 }}>New template</h3>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginBottom: "0.5rem",
}}
>
<input name="name" placeholder="name (unique)" required style={{ minWidth: 180 }} />
<input
name="subject"
placeholder="subject"
required
style={{ flex: 1, minWidth: 220 }}
/>
<input name="variables" placeholder="variables (comma-separated)" style={{ minWidth: 200 }} />
<label style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}>
<input type="checkbox" name="isActive" defaultChecked style={{ width: "auto" }} />
<span className="muted">Active</span>
</label>
</div>
<textarea
name="body"
placeholder="Email body (HTML or text)"
required
rows={5}
style={{ width: "100%", resize: "vertical", marginBottom: "0.5rem" }}
/>
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
<div className="grid">
{templates.map((t) => {
const id = String(t.id);
return (
<div key={id} className="card">
<form action={updateEmailTemplate}>
<input type="hidden" name="id" value={id} />
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<strong style={{ minWidth: 160 }}>{t.name}</strong>
<input
name="subject"
defaultValue={t.subject}
placeholder="subject"
required
style={{ flex: 1, minWidth: 220 }}
/>
<input
name="variables"
defaultValue={t.variables ?? ""}
placeholder="variables"
style={{ minWidth: 180 }}
/>
<label
style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}
>
<input
type="checkbox"
name="isActive"
defaultChecked={t.isActive}
style={{ width: "auto" }}
/>
<span className="muted">Active</span>
</label>
</div>
<textarea
name="body"
defaultValue={t.body}
required
rows={6}
style={{ width: "100%", resize: "vertical", marginBottom: "0.5rem" }}
/>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary">
Save
</button>
{t.updatedAt ? (
<span className="muted">
Updated {new Date(t.updatedAt).toISOString().slice(0, 16).replace("T", " ")}
</span>
) : null}
</div>
</form>
<form action={deleteEmailTemplate} style={{ marginTop: "0.5rem" }}>
<input type="hidden" name="id" value={id} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
);
})}
</div>
{templates.length === 0 ? <p className="muted">No email templates yet.</p> : null}
</main>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const TEXTS_LIMIT = 300;
export default async function AdminEmulator() {
let settings: { key: string; value: string }[] = [];
try {
settings = await prisma.emulatorSettings.findMany({ orderBy: { key: "asc" } });
} catch {
settings = [];
}
let texts: { key: string; value: string }[] = [];
let textsCount = 0;
try {
textsCount = await prisma.emulatorTexts.count();
} catch {
textsCount = 0;
}
try {
texts = await prisma.emulatorTexts.findMany({
orderBy: { key: "asc" },
take: TEXTS_LIMIT,
});
} catch {
texts = [];
}
const textsTruncated = textsCount > texts.length;
return (
<main>
<h1>Emulator Config</h1>
<p className="muted">
Live key/value config read by the Arcturus emulator from
<code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect
on the next emulator read (some keys require an emulator reload).
</p>
<h2 style={{ marginTop: "1.5rem" }}>Settings</h2>
<p className="muted">{settings.length} keys.</p>
<form
action={updateEmulatorSetting}
className="card"
style={{ marginBottom: "1.5rem" }}
>
<h3 style={{ marginTop: 0 }}>Add / overwrite setting</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" maxLength={100} required />
<input
name="value"
placeholder="value"
maxLength={512}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<table>
<thead>
<tr>
<th style={{ width: 320 }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{settings.map((s) => (
<tr key={s.key}>
<td>
<strong>{s.key}</strong>
</td>
<td>
<form
action={updateEmulatorSetting}
style={{ display: "flex", gap: "0.5rem" }}
>
<input type="hidden" name="key" value={s.key} />
<input
name="value"
defaultValue={s.value}
maxLength={512}
style={{ flex: 1 }}
/>
<button type="submit" className="btn btn-outline">
Save
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{settings.length === 0 ? (
<p className="muted">No emulator settings found.</p>
) : null}
<h2 style={{ marginTop: "2.5rem" }}>Texts</h2>
<p className="muted">
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p>
<form
action={updateEmulatorText}
className="card"
style={{ marginBottom: "1.5rem" }}
>
<h3 style={{ marginTop: 0 }}>Add / overwrite text</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" maxLength={100} required />
<input
name="value"
placeholder="value"
maxLength={4096}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<table>
<thead>
<tr>
<th style={{ width: 320 }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{texts.map((t) => (
<tr key={t.key}>
<td>
<strong>{t.key}</strong>
</td>
<td>
<form
action={updateEmulatorText}
style={{ display: "flex", gap: "0.5rem" }}
>
<input type="hidden" name="key" value={t.key} />
<input
name="value"
defaultValue={t.value}
maxLength={4096}
style={{ flex: 1 }}
/>
<button type="submit" className="btn btn-outline">
Save
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{texts.length === 0 ? (
<p className="muted">No emulator texts found.</p>
) : null}
{textsTruncated ? (
<p className="muted">
Only the first {texts.length} of {textsCount} text keys are shown. Use the
add/overwrite form above to edit a key beyond this list.
</p>
) : null}
</main>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminHousekeeping() {
let permissions: Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
> = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
});
} catch {
permissions = [];
}
return (
<main>
<h1>Housekeeping Permissions</h1>
<p className="muted">
Minimum rank required to access each housekeeping permission key. Saving an
existing key overwrites its min rank and description.
</p>
<form action={upsertPermission} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
<table>
<thead>
<tr>
<th>Permission</th>
<th>Description</th>
<th style={{ width: 200 }}>Min rank</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<td><strong>{p.permission}</strong></td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
<input type="hidden" name="permission" value={p.permission} />
<input type="hidden" name="description" value={p.description ?? ""} />
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">Save</button>
</form>
</td>
<td>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">✕</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{permissions.length === 0 ? (
<p className="muted">No housekeeping permissions defined yet.</p>
) : null}
</main>
);
}
+6
View File
@@ -18,15 +18,21 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/users">Users</Link>
<Link href="/admin/articles">Articles</Link>
<Link href="/admin/catalog">Catalog</Link>
<Link href="/admin/badges">Badges</Link>
<Link href="/admin/radio">Radio</Link>
<Link href="/admin/achievements">Achievements</Link>
<Link href="/admin/rare-values">Rare Values</Link>
<Link href="/admin/photos">Photos</Link>
<Link href="/admin/bans">Bans</Link>
<Link href="/admin/applications">Applications</Link>
<Link href="/admin/wordfilter">Word Filter</Link>
<Link href="/admin/ip">IP Management</Link>
<Link href="/admin/logs">Logs</Link>
<Link href="/admin/teams">Teams</Link>
<Link href="/admin/housekeeping">Housekeeping</Link>
<Link href="/admin/permissions">Permissions</Link>
<Link href="/admin/emulator">Emulator</Link>
<Link href="/admin/email-templates">Email</Link>
<Link href="/admin/settings">Settings</Link>
</nav>
</aside>
+54
View File
@@ -0,0 +1,54 @@
import { deletePhoto } from "@/actions/admin-photos";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminPhotos() {
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
try {
photos = await prisma.cameraWeb.findMany({
orderBy: { timestamp: "desc" },
take: 60,
});
} catch {
photos = [];
}
return (
<main>
<h1>Photo Moderation</h1>
<p className="muted">
Recent in-game camera photos. Deleting removes the record from{" "}
<code>camera_web</code>.
</p>
<div className="grid cols-3">
{photos.map((p) => (
<div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={`Photo ${String(p.id)}`}
style={{ width: "100%", height: 200, objectFit: "cover", display: "block", background: "#f1f5f9" }}
/>
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}>
<strong>#{String(p.id)}</strong>
<span className="muted">Room {p.roomId}</span>
</div>
<p className="muted" style={{ margin: "0.3rem 0 0" }}>
User {p.userId} ·{" "}
{new Date(p.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")}
</p>
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
</form>
</div>
</div>
))}
</div>
{photos.length === 0 ? <p className="muted">No photos found.</p> : null}
</main>
);
}
+147
View File
@@ -0,0 +1,147 @@
import {
createCategory,
createValue,
deleteCategory,
deleteValue,
} from "@/actions/admin-rare-values";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const CURRENCY_TYPES = ["diamonds", "credits", "duckets", "points"];
export default async function AdminRareValues() {
let categories: Awaited<ReturnType<typeof prisma.websiteRareValueCategories.findMany>> = [];
try {
categories = await prisma.websiteRareValueCategories.findMany({
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
} catch {
categories = [];
}
let values: Awaited<ReturnType<typeof prisma.websiteRareValues.findMany>> = [];
try {
values = await prisma.websiteRareValues.findMany({
orderBy: { name: "asc" },
});
} catch {
values = [];
}
const valuesByCategory = new Map<string, typeof values>();
for (const v of values) {
const key = String(v.categoryId);
const list = valuesByCategory.get(key);
if (list) list.push(v);
else valuesByCategory.set(key, [v]);
}
return (
<main>
<h1>Rare Values</h1>
<p className="muted">
Catalog of rare furni and their trade values, grouped by category. Each category is
rendered as a tab on the public rare values page.
</p>
<form action={createCategory} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New category</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="name" placeholder="Category name" required style={{ flex: 1, minWidth: 160 }} />
<input name="badge" placeholder="Badge code" required />
<input name="priority" type="number" min={1} defaultValue={1} placeholder="Priority" style={{ width: 110 }} />
<button type="submit" className="btn btn-primary">Add category</button>
</div>
</form>
{categories.length === 0 ? (
<p className="muted">No categories yet. Add one above to start.</p>
) : null}
<div className="grid">
{categories.map((cat) => {
const catId = String(cat.id);
const rows = valuesByCategory.get(catId) ?? [];
return (
<div key={catId} className="card">
<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "baseline",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline" }}>
<strong>{cat.name}</strong>
<span className="muted">badge {cat.badge}</span>
<span className="muted">priority {cat.priority}</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<button type="submit" className="btn btn-danger">Delete category</button>
</form>
</div>
<table style={{ marginTop: "0.75rem" }}>
<thead>
<tr>
<th>Name</th>
<th>Item ID</th>
<th>Credit value</th>
<th>Currency value</th>
<th>Currency</th>
<th>Icon</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((v) => (
<tr key={String(v.id)}>
<td>{v.name}</td>
<td className="muted">{v.itemId ?? "—"}</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td className="muted">{v.currencyType}</td>
<td className="muted">{v.furnitureIcon}</td>
<td>
<form action={deleteValue}>
<input type="hidden" name="id" value={String(v.id)} />
<button type="submit" className="btn btn-danger">✕</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 ? (
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
No values in this category yet.
</p>
) : null}
<form action={createValue} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="categoryId" value={catId} />
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="name" placeholder="Item name" required style={{ flex: 1, minWidth: 140 }} />
<input name="itemId" type="number" min={1} placeholder="Item ID" style={{ width: 110 }} />
<input name="furnitureIcon" placeholder="Furniture icon" required style={{ minWidth: 140 }} />
<input name="creditValue" placeholder="Credit value" style={{ width: 130 }} />
<input name="currencyValue" placeholder="Currency value" style={{ width: 130 }} />
<select name="currencyType" defaultValue="diamonds">
{CURRENCY_TYPES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button type="submit" className="btn btn-primary">Add value</button>
</div>
</form>
</div>
);
})}
</div>
</main>
);
}