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

+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>
);
}