From 0ede78fd351b67815b3f8cc8371a3e060d180023 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Fri, 29 May 2026 16:25:19 +0000 Subject: [PATCH] articles: discussion threads + Related Forum Posts sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/comments/[id]/vote/route.ts | 72 ++++ src/app/api/comments/route.ts | 100 ++++++ .../articles/[slug]/ArticleDetailClient.tsx | 48 ++- src/app/articles/[slug]/page.tsx | 15 +- .../news/[slug]/NewsArticleDetailClient.tsx | 44 +++ src/app/news/[slug]/page.tsx | 14 +- src/components/ArticleComments.tsx | 315 ++++++++++++++++++ src/lib/articles/legacy-source.ts | 93 ++++++ 8 files changed, 691 insertions(+), 10 deletions(-) create mode 100644 src/app/api/comments/[id]/vote/route.ts create mode 100644 src/app/api/comments/route.ts create mode 100644 src/components/ArticleComments.tsx diff --git a/src/app/api/comments/[id]/vote/route.ts b/src/app/api/comments/[id]/vote/route.ts new file mode 100644 index 0000000..9ee4090 --- /dev/null +++ b/src/app/api/comments/[id]/vote/route.ts @@ -0,0 +1,72 @@ +/** + * POST /api/comments/[id]/vote { vote: 1 | -1 | 0 } + * + * Up/down/clear vote on an article comment. Uses the existing + * polymorphic forum_votes table (target_type='comment'). + * + * 0 → remove your existing vote (toggle). + */ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + const sb = await createClient(); + const { data: { user } } = await sb.auth.getUser(); + if (!user) return NextResponse.json({ error: "Sign in to vote" }, { status: 401 }); + + let body: { vote?: number }; + try { body = await req.json(); } + catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } + + const v = body.vote === 1 ? 1 : body.vote === -1 ? -1 : 0; + + // Always look up the current vote first so we can compute the diff + // and apply it atomically to the comment's denormalized counters. + const { data: existing } = await sb + .from("forum_votes") + .select("id, vote_value") + .eq("user_id", user.id) + .eq("target_type", "comment") + .eq("target_id", id) + .maybeSingle(); + + let upDelta = 0, downDelta = 0; + if (existing) { + // Reverse the existing vote's counter contribution first + if (existing.vote_value === 1) upDelta -= 1; + else if (existing.vote_value === -1) downDelta -= 1; + + if (v === 0) { + await sb.from("forum_votes").delete().eq("id", existing.id); + } else { + await sb.from("forum_votes") + .update({ vote_value: v, updated_at: new Date().toISOString() }) + .eq("id", existing.id); + if (v === 1) upDelta += 1; else downDelta += 1; + } + } else if (v !== 0) { + await sb.from("forum_votes").insert({ + user_id: user.id, target_type: "comment", target_id: id, vote_value: v, + }); + if (v === 1) upDelta += 1; else downDelta += 1; + } + + // Apply counter delta to the comment row. Use rpc/raw fragment to add + // integers atomically (supabase-js doesn't expose increment; we read & + // write — racy under heavy concurrency but acceptable for forum scale). + const { data: cur } = await sb + .from("article_comments") + .select("upvotes, downvotes") + .eq("id", id) + .maybeSingle(); + if (cur) { + const upvotes = Math.max(0, (cur.upvotes || 0) + upDelta); + const downvotes = Math.max(0, (cur.downvotes || 0) + downDelta); + await sb.from("article_comments").update({ + upvotes, downvotes, vote_score: upvotes - downvotes, + }).eq("id", id); + } + + return NextResponse.json({ ok: true, vote: v }); +} diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 0000000..29ede89 --- /dev/null +++ b/src/app/api/comments/route.ts @@ -0,0 +1,100 @@ +/** + * Article comments API. + * + * GET /api/comments?article_slug= → list, threaded by parent + * POST /api/comments → create (auth required) + * + * Reads: public — anyone can fetch. + * Writes: signed-in users only. We assert via supabase auth and use the + * user's forum_user_profile for the denormalized display fields so the + * client can render avatar/name without an extra join per comment. + */ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +interface Row { + id: string; + article_slug: string; + parent_comment_id: string | null; + user_id: string | null; + content: string; + author_username: string | null; + author_display_name: string | null; + author_avatar_url: string | null; + is_ai_seeded: boolean; + upvotes: number; + downvotes: number; + vote_score: number; + status: string; + created_at: string; + updated_at: string; +} + +export async function GET(req: NextRequest) { + const slug = new URL(req.url).searchParams.get("article_slug"); + if (!slug) return NextResponse.json({ error: "article_slug required" }, { status: 400 }); + + const sb = await createClient(); + const { data, error } = await sb + .from("article_comments") + .select("*") + .eq("article_slug", slug) + .eq("status", "published") + .order("vote_score", { ascending: false }) + .order("created_at", { ascending: true }) + .limit(500); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ comments: data || [] }); +} + +export async function POST(req: NextRequest) { + const sb = await createClient(); + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json( + { error: "Sign in to comment", redirectTo: "/forum/login" }, + { status: 401 }, + ); + } + let body: { article_slug?: string; content?: string; parent_comment_id?: string | null }; + try { body = await req.json(); } + catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } + + const slug = (body.article_slug || "").trim(); + const content = (body.content || "").trim(); + if (!slug || !content) { + return NextResponse.json({ error: "article_slug and content required" }, { status: 400 }); + } + if (content.length > 5000) { + return NextResponse.json({ error: "Comment too long (5000 char max)" }, { status: 400 }); + } + + // Pull the user's forum profile so we have display name + avatar for + // denormalized rendering (saves a per-comment join later). + const { data: prof } = await sb + .from("forum_user_profiles") + .select("username, display_name, avatar_url") + .eq("user_id", user.id) + .maybeSingle(); + + const ins = { + article_slug: slug, + user_id: user.id, + content, + parent_comment_id: body.parent_comment_id || null, + is_ai_seeded: false, + author_username: prof?.username || null, + author_display_name: prof?.display_name || user.email?.split("@")[0] || "Member", + author_avatar_url: prof?.avatar_url || null, + }; + const { data, error } = await sb + .from("article_comments") + .insert(ins) + .select() + .single(); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ comment: data }); +} diff --git a/src/app/articles/[slug]/ArticleDetailClient.tsx b/src/app/articles/[slug]/ArticleDetailClient.tsx index 143e2f9..2446d6a 100644 --- a/src/app/articles/[slug]/ArticleDetailClient.tsx +++ b/src/app/articles/[slug]/ArticleDetailClient.tsx @@ -8,11 +8,14 @@ import SidebarAdStack from "@/components/SidebarAdStack"; import StarRating from "@/components/StarRating"; import { createClient } from "@/lib/supabase/client"; import type { Article } from "@/lib/articles/types"; +import type { RelatedForumThread } from "@/lib/articles/legacy-source"; +import ArticleComments from "@/components/ArticleComments"; // DO NOT OVERRIDE — ArticleDetailClient interface and session tracking interface ArticleDetailClientProps { article: Article; relatedArticles: Article[]; + relatedForumThreads?: RelatedForumThread[]; } function getSessionId(): string { @@ -25,7 +28,7 @@ function getSessionId(): string { return sid; } -export default function ArticleDetailClient({ article, relatedArticles }: ArticleDetailClientProps) { +export default function ArticleDetailClient({ article, relatedArticles, relatedForumThreads = [] }: ArticleDetailClientProps) { const [isSaved, setIsSaved] = useState(false); const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle"); const [saveToast, setSaveToast] = useState(null); @@ -386,6 +389,10 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl Back to {sectionLabel} + + {/* Article comments — anonymous read, signed-in members can + post + vote + reply. AI personas participate too (tagged). */} + {/* Sidebar */} @@ -424,6 +431,45 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl + {/* Related Forum Posts — surfaces broadcast-pro discussion + threads relevant to the same topic/keyword as the article. + Title-keyword match, recency-ordered. Falls back to most- + recently-active threads so the box is never empty. */} + {relatedForumThreads.length > 0 && ( +
+

+ Related Forum Posts +

+
    + {relatedForumThreads.map((t) => ( +
  • + +

    + {t.title} +

    +

    + {t.category_name && ( + + {t.category_name} + + )} + · + {t.reply_count} {t.reply_count === 1 ? "reply" : "replies"} + {t.vote_score > 0 && <>·▲ {t.vote_score}} +

    + +
  • + ))} +
+ + Browse all forum threads → + +
+ )} + {/* Sidebar ad stack — 300x600 + 300x250 rotating */} diff --git a/src/app/articles/[slug]/page.tsx b/src/app/articles/[slug]/page.tsx index 63cbd81..552b6d7 100644 --- a/src/app/articles/[slug]/page.tsx +++ b/src/app/articles/[slug]/page.tsx @@ -6,6 +6,7 @@ import { getLegacyArticleBySlug, getLegacyRecentSlugs, getLegacyRelatedArticles, + getRelatedForumThreads, type RelatedForumThread, } from "@/lib/articles/legacy-source"; export const revalidate = 3600; @@ -33,11 +34,15 @@ function absoluteImage(src: string | undefined | null): string { async function loadArticle(slug: string): Promise<{ article: Article | null; related: Article[]; + relatedForumThreads: RelatedForumThread[]; }> { const imported = await getLegacyArticleBySlug(slug); - if (!imported) return { article: null, related: [] }; - const related = await getLegacyRelatedArticles(slug, imported.category, 3); - return { article: imported, related }; + 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 { @@ -77,7 +82,7 @@ export async function generateStaticParams() { export default async function ArticlePage({ params }: PageProps) { const { slug } = await params; - const { article, related } = await loadArticle(slug); + 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. @@ -107,7 +112,7 @@ export default async function ArticlePage({ params }: PageProps) { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} /> - + ); } diff --git a/src/app/news/[slug]/NewsArticleDetailClient.tsx b/src/app/news/[slug]/NewsArticleDetailClient.tsx index ff0d510..1683420 100644 --- a/src/app/news/[slug]/NewsArticleDetailClient.tsx +++ b/src/app/news/[slug]/NewsArticleDetailClient.tsx @@ -10,10 +10,13 @@ import ArticleBody from "@/components/ArticleBody"; import StarRating from "@/components/StarRating"; import { createClient } from "@/lib/supabase/client"; import type { Article } from "@/lib/articles/types"; +import type { RelatedForumThread } from "@/lib/articles/legacy-source"; +import ArticleComments from "@/components/ArticleComments"; interface NewsArticleDetailClientProps { article: Article; relatedArticles: Article[]; + relatedForumThreads?: RelatedForumThread[]; companiesInStory?: React.ReactNode; } @@ -30,6 +33,7 @@ function getSessionId(): string { export default function NewsArticleDetailClient({ article, relatedArticles, + relatedForumThreads = [], companiesInStory, }: NewsArticleDetailClientProps) { const [isSaved, setIsSaved] = useState(false); @@ -514,6 +518,10 @@ export default function NewsArticleDetailClient({ Back to News + + {/* Article comments — sign-in gated for posting + voting, + public for reading. AI personas participate (tagged). */} + {/* Sidebar */} @@ -557,6 +565,42 @@ export default function NewsArticleDetailClient({ + {/* Related Forum Posts — same widget as /articles/[slug] */} + {relatedForumThreads.length > 0 && ( +
+

+ Related Forum Posts +

+
    + {relatedForumThreads.map((t) => ( +
  • + +

    + {t.title} +

    +

    + {t.category_name && ( + + {t.category_name} + + )} + · + {t.reply_count} {t.reply_count === 1 ? "reply" : "replies"} + {t.vote_score > 0 && <>·▲ {t.vote_score}} +

    + +
  • + ))} +
+ + Browse all forum threads → + +
+ )} + {/* Newsletter Signup */}

Stay Updated

diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index ac0c54d..3e34647 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -5,6 +5,7 @@ import { getLegacyArticleBySlug, getLegacyRecentSlugs, getLegacyRelatedArticles, + getRelatedForumThreads, type RelatedForumThread, } from "@/lib/articles/legacy-source"; import { linkifyAndExtractMentions } from "@/lib/company-mentions"; import CompanyMentionsHover from "@/components/CompanyMentionsHover"; @@ -40,11 +41,15 @@ function absoluteImage(src: string | undefined | null): string { async function loadArticle(slug: string): Promise<{ article: Article | null; related: Article[]; + relatedForumThreads: RelatedForumThread[]; }> { const imported = await getLegacyArticleBySlug(slug); - if (!imported) return { article: null, related: [] }; - const related = await getLegacyRelatedArticles(slug, imported.category, 6); - return { article: imported, related }; + if (!imported) return { article: null, related: [], relatedForumThreads: [] }; + const [related, relatedForumThreads] = await Promise.all([ + getLegacyRelatedArticles(slug, imported.category, 6), + getRelatedForumThreads(imported.title, 6), + ]); + return { article: imported, related, relatedForumThreads }; } export async function generateMetadata({ params }: PageProps): Promise { @@ -86,7 +91,7 @@ export async function generateStaticParams() { export default async function NewsArticlePage({ params }: PageProps) { const { slug } = await params; - const { article, related } = await loadArticle(slug); + const { article, related, relatedForumThreads } = await loadArticle(slug); if (!article) { // Unknown slug — fall back to the news index instead of 404. redirect("/news"); @@ -123,6 +128,7 @@ export default async function NewsArticlePage({ params }: PageProps) { 0 ? ( diff --git a/src/components/ArticleComments.tsx b/src/components/ArticleComments.tsx new file mode 100644 index 0000000..6c7c5d3 --- /dev/null +++ b/src/components/ArticleComments.tsx @@ -0,0 +1,315 @@ +"use client"; + +/** + * Article comments section. Renders under every news + articles detail + * page. Anonymous visitors see all comments + a "Sign in to comment" + * CTA. Signed-in members get a composer + reply + vote affordances. + * + * Real users and AI users (is_ai_seeded=true) participate in the same + * thread — the only visible difference is a small "AI" chip next to the + * AI display name, so readers know what they're interacting with. + */ +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { createClient } from "@/lib/supabase/client"; + +type Comment = { + id: string; + article_slug: string; + parent_comment_id: string | null; + user_id: string | null; + content: string; + author_username: string | null; + author_display_name: string | null; + author_avatar_url: string | null; + is_ai_seeded: boolean; + upvotes: number; + downvotes: number; + vote_score: number; + status: string; + created_at: string; +}; + +interface Props { + articleSlug: string; +} + +function timeAgo(iso: string) { + const t = Date.now() - new Date(iso).getTime(); + const m = Math.floor(t / 60000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +export default function ArticleComments({ articleSlug }: Props) { + const supabase = createClient(); + const [user, setUser] = useState<{ id: string; email?: string } | null>(null); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [body, setBody] = useState(""); + const [replyTo, setReplyTo] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [myVotes, setMyVotes] = useState>({}); + + // Hydrate user + comments + useEffect(() => { + supabase.auth.getUser().then(({ data }) => { + setUser(data.user ? { id: data.user.id, email: data.user.email } : null); + }); + fetchComments(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [articleSlug]); + + async function fetchComments() { + setLoading(true); + try { + const r = await fetch(`/api/comments?article_slug=${encodeURIComponent(articleSlug)}`); + const j = await r.json(); + if (Array.isArray(j.comments)) setComments(j.comments); + } finally { + setLoading(false); + } + } + + async function submit() { + if (!body.trim()) return; + if (!user) return; + setSubmitting(true); + setError(null); + try { + const r = await fetch("/api/comments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + article_slug: articleSlug, + content: body.trim(), + parent_comment_id: replyTo, + }), + }); + const j = await r.json(); + if (!r.ok) { + setError(j.error || "Failed to post comment"); + } else { + setBody(""); + setReplyTo(null); + await fetchComments(); + } + } finally { + setSubmitting(false); + } + } + + async function vote(commentId: string, value: 1 | -1) { + if (!user) return; + const current = myVotes[commentId] || 0; + const next = current === value ? 0 : value; + setMyVotes((m) => ({ ...m, [commentId]: next })); + // Optimistic counter update + setComments((cs) => cs.map((c) => { + if (c.id !== commentId) return c; + const upDelta = (next === 1 ? 1 : 0) - (current === 1 ? 1 : 0); + const dnDelta = (next === -1 ? 1 : 0) - (current === -1 ? 1 : 0); + return { + ...c, + upvotes: Math.max(0, c.upvotes + upDelta), + downvotes: Math.max(0, c.downvotes + dnDelta), + vote_score: c.vote_score + upDelta - dnDelta, + }; + })); + try { + await fetch(`/api/comments/${commentId}/vote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vote: next }), + }); + } catch { /* swallow — UI already updated optimistically */ } + } + + // Build threaded tree + const tree = useMemo(() => { + const roots: Comment[] = []; + const childrenMap = new Map(); + for (const c of comments) { + if (c.parent_comment_id) { + const arr = childrenMap.get(c.parent_comment_id) || []; + arr.push(c); + childrenMap.set(c.parent_comment_id, arr); + } else { + roots.push(c); + } + } + return { roots, childrenMap }; + }, [comments]); + + const total = comments.length; + + return ( +
+
+

+ Discussion {total > 0 && · {total}} +

+
+ + {/* Composer or sign-in CTA */} + {user ? ( +
+ {replyTo && ( +
+ Replying to comment… + +
+ )} +