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 { articleSlug } from "@/lib/article-input"; import { auth } from "@/lib/auth"; import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; import { logger } from "@/lib/logger"; import { withPublicPagePerformance } from "@/lib/public-page-performance"; import { sanitize } from "@/lib/sanitize"; import { getArticleComments, getArticleReactions, getPublishedArticle, } from "@/lib/services/news-detail"; type SearchParams = Promise<{ comment?: string; reaction?: string; error?: string; commentsPage?: 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 { const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const slug = articleSlug((await params).slug); const article = await getPublishedArticle(slug).catch(() => { logger.error("Public article metadata failed", { module: "news" }); return 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: "๐Ÿ˜ฎ" }, ]; async function ArticlePage({ params, searchParams, }: { params: Promise<{ slug: string[] }>; searchParams: SearchParams; }) { const slug = articleSlug((await params).slug); const { comment, reaction, error, commentsPage } = await searchParams; const t = await getTranslations("pages.article"); let article: Awaited>; try { article = await getPublishedArticle(slug); } catch { logger.error("Public article lookup failed", { module: "news" }); return (

{t("loadError")}

{t("retry")}
); } 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; const requestedPage = comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1); const [reactions, commentsResult] = await Promise.all([ getArticleReactions(articleId, sessionUserId).catch(() => { logger.error("Public article reactions failed", { module: "news", articleId: String(articleId), }); return null; }), getArticleComments(articleId, requestedPage).catch(() => { logger.error("Public article comments failed", { module: "news", articleId: String(articleId), }); return null; }), ]); const reactionCounts = reactions?.counts ?? new Map(); const myReaction = reactions?.myReaction ?? null; const comments = commentsResult?.rows ?? []; const articleHref = `/news/${encodeURIComponent(slug)}`; const commentsHref = (page: number) => `${articleHref}?commentsPage=${page}#comments`; const retryHref = commentsPage ? `${articleHref}?${new URLSearchParams({ commentsPage })}` : articleHref; 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 (
{comment === "posted" ? (
{t("success.posted")}
) : null} {reaction === "1" ? (
{t("success.reaction")}
) : null} {errorMessage ? (
{errorMessage}
) : null} {t("backToNews")} } > {article.image ? ( ) : null} {/* Article body is rich HTML (atom uses TinyMCE) โ€” sanitised server-side. */} {/* โ”€โ”€ Reactions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {!reactions ? (

{t("reactionsUnavailable")}

{t("retry")}
) : loggedIn ? (
{REACTIONS.map((r) => { const active = myReaction === r.key; const count = reactionCounts.get(r.key) ?? 0; const label = `${r.emoji} ${t(`reaction.${r.key}`)}`; return (
); })}
) : reactionCounts.size === 0 ? ( {t("noReactions")} {t("logIn")}{" "} {t("toReact")} ) : ( <>
{REACTIONS.filter( (r) => (reactionCounts.get(r.key) ?? 0) > 0, ).map((r) => ( {`${r.emoji} ${t(`reaction.${r.key}`)}`} {reactionCounts.get(r.key) ?? 0} ))}

{t("logIn")} {t("toReact")}

)}
{/* โ”€โ”€ Comments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */}
0 ? t("commentsCount", { count: commentsResult.total }) : t("commentsSubtitle") } > {!commentsResult ? (

{t("commentsUnavailable")}

{t("retry")}
) : comments.length === 0 ? ( {t("noComments")} ) : (
{comments.map((c) => { const name = c.username; return (

{name ? ( {name} ) : ( {t("userNumber", { id: c.userId })} )} {c.createdAt ? ( ยท {formatDate(c.createdAt, "date", "")} ) : null}

{c.comment}

); })}
)} {commentsResult && commentsResult.lastPage > 1 ? ( ) : null} {loggedIn ? (