Add dark mode, i18n, messenger/moderation/verify, admin CRUD parity

Web-tier features completing the AtomCMS→Next.js conversion (slice 2):

UI/UX:
- Dark mode: html.dark CSS-var overrides + ThemeSwitcher (localStorage,
  no-flash boot script) wired into the nav.
- i18n (next-intl, cookie-based / no URL routing): en + it catalogs,
  request.ts, provider in root layout, LanguageSwitcher; shell (nav,
  header, footer) fully translated. URLs + access-guard unchanged.
- globals.css: --muted/--border aliases used across admin pages.

User features:
- /messages: offline messages + friend-request accept (server action
  re-reads session, two directional rows, idempotent).
- Email verification: signed-token /verify route + sendVerification wired
  into register (best-effort, never blocks signup).
- Article reactions: toggle UI on news/[slug] + server action.
- Content moderation service (website_wordfilter + optional OpenAI
  moderations, fail-open) wired into article comments + guestbook.

Admin CRUD parity (Filament replacement):
- /admin/shop (+ new/[id]) packages CRUD + read-only orders.
- /admin/transactions read-only PayPal log.
- /admin/permissions, /admin/tags, /admin/ads (+ new/[id]),
  /admin/help-questions (+ new/[id]), /admin/radio/history,
  /admin/users/[id]/edit. All gated by requireStaff + logStaffActivity.

Verified: tsc 0, vitest 48/48, next build 0 (all routes incl. new
admin CRUD + /messages + /verify).
This commit is contained in:
Simo committed 2026-06-28 16:06:42 +02:00
1 parent 22d53d0e9c
commit 7daeccb832
45 files changed
+3312 -84

No files matched your search

+70 -21
View File
@@ -3,9 +3,18 @@ import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
export const dynamic = "force-dynamic";
// The reaction set offered by the voting UI. Must stay in sync with
// ALLOWED_REACTIONS in src/actions/article-reactions.ts.
const REACTIONS: { key: string; label: string }[] = [
{ key: "like", label: "👍 Like" },
{ key: "love", label: "❤️ Love" },
{ key: "wow", label: "😮 Wow" },
];
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
@@ -27,18 +36,26 @@ export default async function ArticlePage({
const articleId = article.id;
// Reaction counts grouped by reaction type for this article.
let reactionGroups: { reaction: string; count: number }[] = [];
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
const sessionUserId = loggedIn ? Number(session!.user!.id) : null;
// Reaction counts grouped by reaction type for this article, plus the
// signed-in user's currently-active reaction (so its button reads as pressed).
let reactionCounts = new Map<string, number>();
let myReaction: string | null = null;
try {
const rows = await prisma.websiteArticleReactions.findMany({
where: { articleId, active: true },
select: { reaction: true },
select: { reaction: true, userId: 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 }));
for (const r of rows) {
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
if (sessionUserId !== null && r.userId === sessionUserId) myReaction = r.reaction;
}
} catch {
reactionGroups = [];
reactionCounts = new Map();
myReaction = null;
}
// Comments for this article (oldest first).
@@ -68,9 +85,6 @@ export default async function ArticlePage({
}
}
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
return (
<main>
<p className="muted">
@@ -93,19 +107,54 @@ export default async function ArticlePage({
{/* ── Reactions ─────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Reactions</h2>
{reactionGroups.length === 0 ? (
<p className="muted">No reactions yet.</p>
) : (
{loggedIn ? (
<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>
))}
{REACTIONS.map((r) => {
const active = myReaction === r.key;
const count = reactionCounts.get(r.key) ?? 0;
return (
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
<input type="hidden" name="articleId" value={String(articleId)} />
<input type="hidden" name="reaction" value={r.key} />
<button
type="submit"
aria-pressed={active}
className={`btn ${active ? "btn-primary" : "btn-outline"}`}
title={active ? "Click to remove your reaction" : `React with ${r.key}`}
>
<span>{r.label}</span>
<span
className="muted"
style={{ fontSize: "0.85rem", color: "inherit", opacity: 0.85 }}
>
{count}
</span>
</button>
</form>
);
})}
</div>
) : reactionCounts.size === 0 ? (
<p className="muted">
No reactions yet. <Link href="/login">Log in</Link> to react.
</p>
) : (
<>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.filter((r) => (reactionCounts.get(r.key) ?? 0) > 0).map((r) => (
<span key={r.key} className="currency">
<span style={{ fontWeight: 800 }}>{r.label}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{reactionCounts.get(r.key) ?? 0}
</span>
</span>
))}
</div>
<p className="muted" style={{ marginTop: "0.6rem" }}>
<Link href="/login">Log in</Link> to react.
</p>
</>
)}
</section>