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 { 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(); 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(); 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 (
{comment === "posted" ? (
{t("success.posted")}
) : null} {reaction === "1" ? (
{t("success.reaction")}
) : null} {errorMessage ? (
{errorMessage}
) : null} {t("backToNews")} } > {article.image ? ( // eslint-disable-next-line @next/next/no-img-element ) : null} {/* Article body is rich HTML (atom uses TinyMCE) โ€” sanitised server-side. */} {/* โ”€โ”€ Reactions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {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: comments.length }) : t("commentsSubtitle") } > {comments.length === 0 ? ( {t("noComments")} ) : (
{comments.map((c) => { const name = usernameById.get(c.userId); return (

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

{c.comment}

); })}
)} {loggedIn ? (