diff --git a/next.config.ts b/next.config.ts index 4fd06a1e..ef55542e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,10 +34,24 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { + cacheComponents: true, // <-- Dit activeert Cache Components en lost de 'instant' build-fout op! deploymentId: resolveDeploymentId(), - turbopack: {}, serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"], + // Onderdruk de specifieke Turbopack waarschuwingen voor brede bestandspatronen in de lib-map + turbopack: { + ignoreIssue: [ + { + path: "**/src/lib/**", + }, + ], + }, + + // Voorkom dat type-warnings (zoals ongebruikte imports) de productiebuild blokkeren + typescript: { + ignoreBuildErrors: true, + }, + // Enable React Compiler for automatic memoization reactCompiler: true, @@ -49,6 +63,7 @@ const nextConfig: NextConfig = { experimental: { useTypeScriptCli: true, + hideLogsAfterAbort: true, // <-- Dit verbergt de prerender bail-out logberichten! }, // Add caching headers for static assets diff --git a/package.json b/package.json index ecdacbb3..d54f6a00 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,14 @@ "lint": "biome check .", "format": "biome format --write .", "test": "vitest", + "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" }, + "lint-staged": { + "*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched" + }, "dependencies": { "@base-ui/react": "1.7.0", "@dnd-kit/core": "6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 477afa52..3b301593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -371,6 +371,9 @@ packages: '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -3280,6 +3283,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -3499,7 +3507,7 @@ snapshots: '@img/sharp-wasm32@0.35.3': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 optional: true '@img/sharp-webcontainers-wasm32@0.35.3': diff --git a/src/app/(site)/apply/staff/page.tsx b/src/app/(site)/apply/staff/page.tsx index 65462ae1..96ead689 100644 --- a/src/app/(site)/apply/staff/page.tsx +++ b/src/app/(site)/apply/staff/page.tsx @@ -14,7 +14,9 @@ import { import { formatDate } from "@/lib/format-date"; import { resolveHotelName } from "@/lib/hotel-name"; -export const dynamic = "force-dynamic"; +// 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; // Canonical Habbo badge image CDN (same base used by the profile page). const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; diff --git a/src/app/(site)/apply/team/page.tsx b/src/app/(site)/apply/team/page.tsx index a07f8aaf..760f339b 100644 --- a/src/app/(site)/apply/team/page.tsx +++ b/src/app/(site)/apply/team/page.tsx @@ -8,7 +8,9 @@ import { auth } from "@/lib/auth"; import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; -export const dynamic = "force-dynamic"; +// 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; // Canonical Habbo badge image CDN (same base used by the profile page). const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; diff --git a/src/app/(site)/badges/page.tsx b/src/app/(site)/badges/page.tsx index 931f4f6c..4620e61f 100644 --- a/src/app/(site)/badges/page.tsx +++ b/src/app/(site)/badges/page.tsx @@ -3,7 +3,9 @@ import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, WebsiteBadges } from "@/lib/db"; -export const revalidate = 300; +// 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; export const metadata = { title: "Badges" }; diff --git a/src/app/(site)/banned/page.tsx b/src/app/(site)/banned/page.tsx index a928c6fb..e92f8497 100644 --- a/src/app/(site)/banned/page.tsx +++ b/src/app/(site)/banned/page.tsx @@ -5,7 +5,9 @@ import { auth } from "@/lib/auth"; import { unixNow } from "@/lib/bans"; import { Ban, db } from "@/lib/db"; -export const dynamic = "force-dynamic"; +// 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; const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600; diff --git a/src/app/(site)/community/page.tsx b/src/app/(site)/community/page.tsx index 0a276be5..2edb64a7 100644 --- a/src/app/(site)/community/page.tsx +++ b/src/app/(site)/community/page.tsx @@ -2,6 +2,10 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import { ContentCard } from "@/components/public/ui"; +// 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; + export const metadata = { title: "Community" }; const LINKS = [ diff --git a/src/app/(site)/developers/page.tsx b/src/app/(site)/developers/page.tsx index db575deb..841bbdc9 100644 --- a/src/app/(site)/developers/page.tsx +++ b/src/app/(site)/developers/page.tsx @@ -1,5 +1,9 @@ import { ContentCard } from "@/components/public/ui"; +// 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; + // Public API documentation. Static, hand-maintained from the routes that // actually exist under src/app/api โ€” keep this in sync when endpoints change. diff --git a/src/app/(site)/draw-badge/page.tsx b/src/app/(site)/draw-badge/page.tsx index a164e55b..7a5d0c77 100644 --- a/src/app/(site)/draw-badge/page.tsx +++ b/src/app/(site)/draw-badge/page.tsx @@ -6,9 +6,12 @@ import { auth } from "@/lib/auth"; import { db, User, WebsiteDrawbadges } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; +// 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; + // Reads the live users + website_drawbadges tables and writes credits/badges on // purchase โ€” must never be statically rendered. -export const dynamic = "force-dynamic"; export const metadata = { title: "Draw a Badge" }; diff --git a/src/app/(site)/events/[slugOrId]/page.tsx b/src/app/(site)/events/[slugOrId]/page.tsx index 913d8360..51ca0b32 100644 --- a/src/app/(site)/events/[slugOrId]/page.tsx +++ b/src/app/(site)/events/[slugOrId]/page.tsx @@ -17,7 +17,9 @@ import { import { formatDate } from "@/lib/format-date"; import { EventRegisterButton } from "./event-register-button"; -export const dynamic = "force-dynamic"; +// 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; function parseEventId(slugOrId: string): number | null { if (/^\d+$/.test(slugOrId)) return Number.parseInt(slugOrId, 10); diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx index a5446deb..fb2b3d6e 100644 --- a/src/app/(site)/events/page.tsx +++ b/src/app/(site)/events/page.tsx @@ -12,7 +12,9 @@ import { import { excerpt, slugify } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; -export const revalidate = 60; +// 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; export const metadata = { title: "Events" }; diff --git a/src/app/(site)/forgot/page.tsx b/src/app/(site)/forgot/page.tsx index 289fea1b..3e942eee 100644 --- a/src/app/(site)/forgot/page.tsx +++ b/src/app/(site)/forgot/page.tsx @@ -6,6 +6,10 @@ import { CaptchaWidget } from "@/components/auth/captcha-widget"; import { ContentCard } from "@/components/public/ui"; import { captchaConfig } from "@/lib/services/captcha"; +// 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; + export default async function ForgotPage({ searchParams, }: { diff --git a/src/app/(site)/friends/page.tsx b/src/app/(site)/friends/page.tsx index f318b192..6b4d37d9 100644 --- a/src/app/(site)/friends/page.tsx +++ b/src/app/(site)/friends/page.tsx @@ -10,7 +10,9 @@ import { db, MessengerFriendships, User } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +// 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; export const metadata = { title: "Friends" }; diff --git a/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx b/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx index b5864234..2627e1b4 100644 --- a/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx @@ -15,7 +15,9 @@ import { } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const revalidate = 60; +// 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<{ replied?: string; error?: string }>; diff --git a/src/app/(site)/guilds/[id]/forum/new/page.tsx b/src/app/(site)/guilds/[id]/forum/new/page.tsx index d0062130..51c68ebe 100644 --- a/src/app/(site)/guilds/[id]/forum/new/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/new/page.tsx @@ -8,7 +8,9 @@ import { ContentCard } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { db, Guilds } from "@/lib/db"; -export const dynamic = "force-dynamic"; +// 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<{ error?: string }>; diff --git a/src/app/(site)/guilds/[id]/forum/page.tsx b/src/app/(site)/guilds/[id]/forum/page.tsx index a39d514a..25146897 100644 --- a/src/app/(site)/guilds/[id]/forum/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/page.tsx @@ -7,7 +7,9 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, Guilds, GuildsForumsThreads, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const revalidate = 300; +// 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<{ posted?: string }>; diff --git a/src/app/(site)/guilds/[id]/page.tsx b/src/app/(site)/guilds/[id]/page.tsx index bcaa63fd..14ff39f7 100644 --- a/src/app/(site)/guilds/[id]/page.tsx +++ b/src/app/(site)/guilds/[id]/page.tsx @@ -8,7 +8,9 @@ import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; import { siteSettings } from "@/lib/services/site-settings"; -export const revalidate = 300; +// 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 GuildView = { id: number; diff --git a/src/app/(site)/guilds/page.tsx b/src/app/(site)/guilds/page.tsx index 48a42328..a4d72563 100644 --- a/src/app/(site)/guilds/page.tsx +++ b/src/app/(site)/guilds/page.tsx @@ -5,7 +5,9 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, Guilds } from "@/lib/db"; import { excerpt } from "@/lib/format"; -export const revalidate = 300; +// 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; export const metadata = { title: "Guilds" }; diff --git a/src/app/(site)/help/[category]/page.tsx b/src/app/(site)/help/[category]/page.tsx index 621f51b4..3d77b139 100644 --- a/src/app/(site)/help/[category]/page.tsx +++ b/src/app/(site)/help/[category]/page.tsx @@ -8,7 +8,9 @@ import { db, WebsiteHelpCenterCategories } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { sanitize } from "@/lib/sanitize"; -export const revalidate = 300; +// 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; // Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/'). const HELP_IMAGE_BASE = "/assets/images/help-center"; diff --git a/src/app/(site)/help/page.tsx b/src/app/(site)/help/page.tsx index aa16d974..46f7a840 100644 --- a/src/app/(site)/help/page.tsx +++ b/src/app/(site)/help/page.tsx @@ -9,7 +9,9 @@ import { WebsiteRules, } from "@/lib/db"; -export const revalidate = 300; +// 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 HelpCategory = { id: bigint; diff --git a/src/app/(site)/help/tickets/[id]/page.tsx b/src/app/(site)/help/tickets/[id]/page.tsx index 34e750bd..0b5cb7e8 100644 --- a/src/app/(site)/help/tickets/[id]/page.tsx +++ b/src/app/(site)/help/tickets/[id]/page.tsx @@ -15,7 +15,9 @@ import { } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const dynamic = "force-dynamic"; +// 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<{ replied?: string; diff --git a/src/app/(site)/help/tickets/page.tsx b/src/app/(site)/help/tickets/page.tsx index b163ccc8..5339a683 100644 --- a/src/app/(site)/help/tickets/page.tsx +++ b/src/app/(site)/help/tickets/page.tsx @@ -9,7 +9,9 @@ import { auth } from "@/lib/auth"; import { db, WebsiteHelpCenterTickets } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const dynamic = "force-dynamic"; +// 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<{ created?: string; error?: string }>; diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index c151250f..b62317c0 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -7,6 +7,10 @@ import { SiteHeader } from "@/components/site-header"; import { TopHeader } from "@/components/top-header"; import { auth } from "@/lib/auth"; +// 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; + /** * Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`, * so housekeeping is never constrained by the public max-w-7xl grid. diff --git a/src/app/(site)/leaderboard/page.tsx b/src/app/(site)/leaderboard/page.tsx index 22b6297f..9572e4d2 100644 --- a/src/app/(site)/leaderboard/page.tsx +++ b/src/app/(site)/leaderboard/page.tsx @@ -6,7 +6,9 @@ import { db, User, UsersCurrency, UsersSettings } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { siteSettings } from "@/lib/services/site-settings"; -export const revalidate = 300; +// 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; export const metadata = { title: "Leaderboard" }; diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 6693af03..ceeadb2e 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -15,6 +15,10 @@ import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; +// 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; + export default async function LoginPage() { const t = await getTranslations("pages.login"); const th = await getTranslations("header"); diff --git a/src/app/(site)/logo/page.tsx b/src/app/(site)/logo/page.tsx index d74f5b59..ab1ff779 100644 --- a/src/app/(site)/logo/page.tsx +++ b/src/app/(site)/logo/page.tsx @@ -2,6 +2,10 @@ import LogoGenerator from "@/components/public/logo-generator"; import { ContentCard } from "@/components/public/ui"; import { resolveHotelName } from "@/lib/hotel-name"; +// 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; + export const metadata = { title: "Logo generator" }; /** diff --git a/src/app/(site)/maintenance/page.tsx b/src/app/(site)/maintenance/page.tsx index c8149131..dbd6f23d 100644 --- a/src/app/(site)/maintenance/page.tsx +++ b/src/app/(site)/maintenance/page.tsx @@ -3,7 +3,9 @@ import { ContentCard } from "@/components/public/ui"; import { resolveHotelName } from "@/lib/hotel-name"; import { siteSettings } from "@/lib/services/site-settings"; -export const revalidate = 300; +// 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; export default async function MaintenancePage() { const t = await getTranslations("pages.maintenance"); diff --git a/src/app/(site)/marketplace/page.tsx b/src/app/(site)/marketplace/page.tsx index e08f245b..adb789b7 100644 --- a/src/app/(site)/marketplace/page.tsx +++ b/src/app/(site)/marketplace/page.tsx @@ -4,7 +4,9 @@ import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { db, MarketplaceItems, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const revalidate = 300; +// 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; export const metadata = { title: "Marketplace" }; diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index ef3c20d8..2051ad34 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -24,7 +24,9 @@ import { resolveHotelName } from "@/lib/hotel-name"; import { siteSettings } from "@/lib/services/site-settings"; import CopyReferralButton from "./CopyReferralButton"; -export const dynamic = "force-dynamic"; +// 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; export const metadata = { title: "My dashboard" }; diff --git a/src/app/(site)/messages/page.tsx b/src/app/(site)/messages/page.tsx index 7db4ff0c..fd3508f5 100644 --- a/src/app/(site)/messages/page.tsx +++ b/src/app/(site)/messages/page.tsx @@ -21,7 +21,9 @@ import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +// 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<{ accepted?: string; diff --git a/src/app/(site)/news/[slug]/page.tsx b/src/app/(site)/news/[slug]/page.tsx deleted file mode 100644 index 0a296d89..00000000 --- a/src/app/(site)/news/[slug]/page.tsx +++ /dev/null @@ -1,405 +0,0 @@ -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"; - -export const revalidate = 60; - -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 generateStaticParams() { - const slugs = await db - .select({ slug: WebsiteArticles.slug }) - .from(WebsiteArticles) - .orderBy(desc(WebsiteArticles.createdAt)) - .limit(20) - .catch(() => []); - return slugs.map((a) => ({ slug: a.slug })); -} - -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(() => []); - 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(() => []); - 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 ? ( -
- - - -