Files
avbeat-com/src/app/articles/[slug]/page.tsx
Ryan Salazar 0ede78fd35 articles: discussion threads + Related Forum Posts sidebar
ArticleComments component renders under every news + articles detail
page. Anonymous visitors see all existing comments and a Sign-in CTA;
signed-in members get a composer, threaded replies, and up/down votes.
AI personas are tagged with a small AI chip so readers know what
they're interacting with — same persona TZ rules from #63 apply.

Schema extensions on bb.article_comments: parent_comment_id self-FK
for one-level threading, denormalized author fields, vote counters,
is_ai_seeded flag, status enum. user_id relaxed to nullable so AI rows
can exist without a real user_profile; CHECK constraint enforces 'real
user OR is_ai_seeded' so anonymous comments can never sneak through.
forum_votes.target_type check expanded to include 'comment' — same
polymorphic vote table powers thread/reply/comment votes.

New Related Forum Posts sidebar on both /articles/[slug] and
/news/[slug]. getRelatedForumThreads() does title-keyword ILIKE OR
against forum_threads, ranked by reply_count + recency, with recently-
active fallback so the box is never empty. 6 threads per article.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:25:19 +00:00

119 lines
3.8 KiB
TypeScript

import type { Metadata } from "next";
import { redirect } from "next/navigation";
import ArticleDetailPage from "./ArticleDetailClient";
import type { Article } from "@/lib/articles/types";
import {
getLegacyArticleBySlug,
getLegacyRecentSlugs,
getLegacyRelatedArticles,
getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source";
export const revalidate = 3600;
export const dynamicParams = true;
interface PageProps {
params: Promise<{ slug: string }>;
}
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://broadcastbeat.com";
function toIso(d: string | null | undefined): string {
if (!d) return new Date().toISOString();
const parsed = new Date(d);
return isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
function absoluteImage(src: string | undefined | null): string {
if (!src) return `${SITE_URL}/assets/images/article-placeholder.jpg`;
if (/^https?:\/\//i.test(src)) return src;
return `${SITE_URL}${src.startsWith("/") ? "" : "/"}${src}`;
}
async function loadArticle(slug: string): Promise<{
article: Article | null;
related: Article[];
relatedForumThreads: RelatedForumThread[];
}> {
const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [], relatedForumThreads: [] };
const [related, relatedForumThreads] = await Promise.all([
getLegacyRelatedArticles(slug, imported.category, 3),
getRelatedForumThreads(imported.title, 6),
]);
return { article: imported, related, relatedForumThreads };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const { article } = await loadArticle(slug);
if (!article) {
return { title: "Article Not Found", description: "The article you are looking for does not exist." };
}
return {
title: article.title,
description: article.excerpt,
alternates: { canonical: `/articles/${article.slug}` },
openGraph: {
type: "article",
title: article.title,
description: article.excerpt,
url: `/articles/${article.slug}`,
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
authors: [article.author],
publishedTime: article.date,
tags: article.tags,
},
twitter: {
card: "summary_large_image",
title: article.title,
description: article.excerpt,
images: [article.image],
creator: "@Broadcast Beat",
},
};
}
export async function generateStaticParams() {
const recent = await getLegacyRecentSlugs(50);
return recent.map((slug) => ({ slug }));
}
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const { article, related, relatedForumThreads } = await loadArticle(slug);
if (!article) {
// Slug isn't in any of our article tables — send the reader to the news
// index so a click from the homepage/ticker doesn't dead-end on 404.
redirect("/news");
}
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
headline: article.title,
description: article.excerpt,
image: [absoluteImage(article.image)],
author: { "@type": "Person", name: article.author },
publisher: {
"@type": "Organization",
name: "Broadcast Beat",
logo: { "@type": "ImageObject", url: `${SITE_URL}/assets/images/logo.png` },
},
datePublished: toIso(article.date),
dateModified: new Date().toISOString(),
mainEntityOfPage: { "@type": "WebPage", "@id": `${SITE_URL}/articles/${article.slug}` },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<ArticleDetailPage article={article} relatedArticles={related} relatedForumThreads={relatedForumThreads} />
</>
);
}