Files
EpicNext-Cms/src/app/(site)/news/[...slug]/page.tsx
T
openhands 30c95b1a5c
CI / runtime-diagnostics (push) Skipped
CI / release (push) Skipped
CI / check (push) Failing after 0s
CI / deploy (push) Skipped
feat: comprehensive CMS improvements
- Fix DOMPurify SSR crash (use isomorphic-dompurify)
- Fix SanitizedHtml to sanitize by default
- Add auth guards to studio/catalog maintenance pages
- Add update/edit to vouchers CRUD
- Add update/edit to rare-values CRUD
- Add approve workflow to applications page
- Add edit form to guilds detail page
- Add SEO metadata to all public pages (21 pages)
- Fix mobile nav accessibility (focus trap, aria attributes)
- Fix missing labels and table accessibility
- Add dynamic imports for heavy client components (6 components)
- Fix silent error swallowing (40+ locations)
- Add content scheduling for articles (publishAt, status)
- Wire up 12 missing webhook notification triggers
- Add global search to admin panel
- Add bulk actions to admin users table
- Fix JSON formatting and a11y issues
2026-09-03 16:00:32 +02:00

413 lines
11 KiB
TypeScript

import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { auth } from "@/lib/auth";
import { cachedQuery } from "@/lib/cached-db";
import {
db,
User,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { sanitize } from "@/lib/sanitize";
type SearchParams = Promise<{
comment?: string;
reaction?: string;
error?: string;
}>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
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 db
.select({
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1)
.catch(() => []);
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,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: SearchParams;
}) {
const { slug } = await params;
const { comment, reaction, error } = await searchParams;
const t = await getTranslations("pages.article");
const article = await cachedQuery(
`article:slug:${slug}`,
async () => {
const [row] = await db
.select({
id: WebsiteArticles.id,
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
updatedAt: WebsiteArticles.updatedAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1)
.catch(() => []);
return row ?? null;
},
60,
);
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 db
.select({
reaction: WebsiteArticleReactions.reaction,
userId: WebsiteArticleReactions.userId,
})
.from(WebsiteArticleReactions)
.where(
and(
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.active, 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: (typeof WebsiteArticleComments.$inferSelect)[] = [];
try {
comments = await db
.select()
.from(WebsiteArticleComments)
.where(eq(WebsiteArticleComments.articleId, articleId))
.orderBy(asc(WebsiteArticleComments.createdAt))
.limit(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 db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, commenterIds));
usernameById = new Map(users.map((u) => [u.id, u.username]));
} catch {
usernameById = new Map();
}
}
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "moderated"
? t("errors.moderated")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error === "not_found"
? t("errors.notFound")
: error
? t("errors.error")
: null;
return (
<main className="page-grid">
{comment === "posted" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
</div>
) : null}
{reaction === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.reaction")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="📰"
title={article.title}
subtitle={
article.createdAt ? formatDate(article.createdAt, "date") : 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=""
decoding="async"
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="slug" value={article.slug} />
<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)} />
<input type="hidden" name="slug" value={slug} />
<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>
);
}