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:
1 parent
22d53d0e9c
commit
7daeccb832
45 files changed
+3312
-84
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user