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:
1 parent
e96b606e1e
commit
e8be0461d8
28 files changed
+2347
-5
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user