diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx new file mode 100644 index 00000000..148f4f4c --- /dev/null +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -0,0 +1,399 @@ +import { connection } from "next/server"; +import { and, asc, desc, eq, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; +import Link from "next/link"; +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 { 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"; + +// TODO: Cache Components adoption. Refactor this route so this opt-out can be removed. +// See: https://nextjs.org/docs/app/guides/migrating-to-cache-components +export const instant = false; + + +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(eq(WebsiteArticles.slug, slug)) + .limit(1) + .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, + 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(eq(WebsiteArticles.slug, slug)) + .limit(1) + .catch(() => null); + 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 ? ( +
+ + + +