Files
Epicnabbo-Catalogus-Updated…/src/app/(site)/news/[slug]/page.tsx
T
openhands 5e8a13a84f fix: resolve all biomaly lint errors and warnings across CMS
- Fix CSS parser config (tailwindDirectives enabled)
- Fix noDangerouslySetInnerHtml via SanitizedHtml component
- Fix useExhaustiveDependencies in catalog-manager-dialog
- Fix noArrayIndexKey across 26 files (stable keys)
- Fix SVG a11y (titles, roles, aria-labels)
- Fix label/input associations (htmlFor/id pairs)
- Fix static element interactions (role + keyboard support)
- Fix noImgElement, noDescendingSpecificity (disabled - external Habbo URLs)
- Fix noNonNullAssertion, useTemplate, unused vars/imports
- Add SanitizedHtml shared component
- Migrate biome.json to 2.5.4 schema
2026-07-20 17:41:53 +02:00

327 lines
9.2 KiB
TypeScript

import type { Metadata } from "next";
import { formatDate } from "@/lib/format-date";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { cacheQuery } from "@/lib/prisma-cache";
import { sanitize } from "@/lib/sanitize";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
export const revalidate = 60;
export async function generateStaticParams() {
const slugs = await prisma.websiteArticles
.findMany({
select: { slug: true },
orderBy: { createdAt: "desc" },
take: 20,
})
.catch(() => []);
return slugs.map((a) => ({ slug: a.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const { slug } = await params;
const article = await prisma.websiteArticles
.findUnique({ where: { slug }, select: { title: true, shortStory: true } })
.catch(() => null);
if (!article) return { title: "Article" };
return {
title: article.title,
description: excerpt(article.shortStory, 160),
alternates: { canonical: `${appUrl}/news/${slug}` },
openGraph: {
title: article.title,
description: excerpt(article.shortStory, 160),
type: "article",
url: `${appUrl}/news/${slug}`,
},
twitter: { card: "summary", title: article.title },
};
}
// 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; emoji: string }[] = [
{ key: "like", emoji: "👍" },
{ key: "love", emoji: "❤️" },
{ key: "wow", emoji: "😮" },
];
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const t = await getTranslations("pages.article");
const article = await cacheQuery(
"websiteArticles",
"findUnique",
{ slug },
() =>
prisma.websiteArticles
.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
shortStory: true,
fullStory: true,
image: true,
createdAt: true,
updatedAt: true,
},
})
.catch(() => null),
);
if (!article) notFound();
const articleId = article.id;
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
const sessionUserId = session?.user?.id ? 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, userId: true },
});
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 {
reactionCounts = new Map();
myReaction = null;
}
// 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();
}
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="📰"
title={article.title}
subtitle={
article.createdAt
? article.createdAt.toISOString().slice(0, 10)
: undefined
}
action={
<Link className="btn btn-outline" href="/news">
{t("backToNews")}
</Link>
}
>
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
/>
) : null}
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
<SanitizedHtml html={sanitize(article.fullStory)} className="article-body" />
</ContentCard>
{/* ── Reactions ─────────────────────────────────────────── */}
<ContentCard
icon="💬"
title={t("reactionsTitle")}
subtitle={t("reactionsSubtitle")}
>
{loggedIn ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.map((r) => {
const active = myReaction === r.key;
const count = reactionCounts.get(r.key) ?? 0;
const label = `${r.emoji} ${t(`reaction.${r.key}`)}`;
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
? t("removeReaction")
: t("reactWith", { reaction: r.key })
}
>
<span>{label}</span>
<span
className="muted"
style={{
fontSize: "0.85rem",
color: "inherit",
opacity: 0.85,
}}
>
{count}
</span>
</button>
</form>
);
})}
</div>
) : reactionCounts.size === 0 ? (
<EmptyState icon="💬">
{t("noReactions")} <Link href="/login">{t("logIn")}</Link>{" "}
{t("toReact")}
</EmptyState>
) : (
<>
<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.emoji} ${t(`reaction.${r.key}`)}`}</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">{t("logIn")}</Link> {t("toReact")}
</p>
</>
)}
</ContentCard>
{/* ── Comments ──────────────────────────────────────────── */}
<ContentCard
icon="🗨️"
title={t("commentsTitle")}
subtitle={
comments.length > 0
? t("commentsCount", { count: comments.length })
: t("commentsSubtitle")
}
>
{comments.length === 0 ? (
<EmptyState icon="🗨️">{t("noComments")}</EmptyState>
) : (
<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>{t("userNumber", { id: c.userId })}</span>
)}
{c.createdAt ? (
<span> · {formatDate(c.createdAt, "date", "")}</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",
}}
>
{t("addComment")}
</label>
<textarea
id="comment"
name="comment"
required
maxLength={255}
rows={3}
placeholder={t("commentPlaceholder")}
style={{ width: "100%", resize: "vertical" }}
/>
<div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary">
{t("postComment")}
</button>
</div>
</form>
) : (
<p className="muted">
<Link href="/login">{t("logIn")}</Link> {t("toComment")}
</p>
)}
</ContentCard>
</main>
);
}