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

+136 -1
View File
@@ -1,18 +1,76 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postComment } from "@/actions/article-comments";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await prisma.websiteArticles.findUnique({ where: { slug } });
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try {
article = await prisma.websiteArticles.findUnique({ where: { slug } });
} catch {
article = null;
}
if (!article) notFound();
const articleId = article.id;
// Reaction counts grouped by reaction type for this article.
let reactionGroups: { reaction: string; count: number }[] = [];
try {
const rows = await prisma.websiteArticleReactions.findMany({
where: { articleId, active: true },
select: { reaction: true },
});
const tally = new Map<string, number>();
for (const r of rows) tally.set(r.reaction, (tally.get(r.reaction) ?? 0) + 1);
reactionGroups = [...tally.entries()].map(([reaction, count]) => ({ reaction, count }));
} catch {
reactionGroups = [];
}
// Comments for this article (oldest first).
let comments: Awaited<ReturnType<typeof prisma.websiteArticleComments.findMany>> = [];
try {
comments = await prisma.websiteArticleComments.findMany({
where: { articleId },
orderBy: { createdAt: "asc" },
take: 200,
});
} catch {
comments = [];
}
// Resolve usernames for the commenters (userId is the emulator users.id, Int).
const commenterIds = Array.from(new Set(comments.map((c) => c.userId)));
let usernameById = new Map<number, string>();
if (commenterIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: commenterIds } },
select: { id: true, username: true },
});
usernameById = new Map(users.map((u) => [u.id, u.username]));
} catch {
usernameById = new Map();
}
}
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
return (
<main>
<p className="muted">
@@ -23,6 +81,7 @@ export default async function ArticlePage({
<p className="muted">{article.createdAt.toISOString().slice(0, 10)}</p>
) : null}
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
@@ -30,6 +89,82 @@ export default async function ArticlePage({
/>
) : null}
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
{/* ── Reactions ─────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Reactions</h2>
{reactionGroups.length === 0 ? (
<p className="muted">No reactions yet.</p>
) : (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{reactionGroups.map((r) => (
<span key={r.reaction} className="currency">
<span style={{ fontWeight: 800 }}>{r.reaction}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{r.count}
</span>
</span>
))}
</div>
)}
</section>
{/* ── Comments ──────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Comments</h2>
{comments.length === 0 ? (
<p className="muted">No comments yet. Be the first to comment.</p>
) : (
<div className="grid" style={{ marginBottom: "1.5rem" }}>
{comments.map((c) => {
const name = usernameById.get(c.userId);
return (
<article key={String(c.id)} className="card">
<p className="muted" style={{ marginTop: 0, marginBottom: "0.35rem" }}>
{name ? (
<Link href={`/u/${name}`}>{name}</Link>
) : (
<span>User #{c.userId}</span>
)}
{c.createdAt ? <span> · {formatDate(c.createdAt)}</span> : null}
</p>
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>{c.comment}</p>
</article>
);
})}
</div>
)}
{loggedIn ? (
<form action={postComment} className="card">
<input type="hidden" name="articleId" value={String(articleId)} />
<label
htmlFor="comment"
style={{ display: "block", fontWeight: 700, marginBottom: "0.5rem" }}
>
Add a comment
</label>
<textarea
id="comment"
name="comment"
required
maxLength={255}
rows={3}
placeholder="Write your comment…"
style={{ width: "100%", resize: "vertical" }}
/>
<div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary">
Post comment
</button>
</div>
</form>
) : (
<p className="muted">
<Link href="/login">Log in</Link> to leave a comment.
</p>
)}
</section>
</main>
);
}