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>
This commit is contained in:
72
src/app/api/comments/[id]/vote/route.ts
Normal file
72
src/app/api/comments/[id]/vote/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
100
src/app/api/comments/route.ts
Normal file
100
src/app/api/comments/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Article comments API.
|
||||
*
|
||||
* GET /api/comments?article_slug=<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 });
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
@@ -386,6 +389,10 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
Back to {sectionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Article comments — anonymous read, signed-in members can
|
||||
post + vote + reply. AI personas participate too (tagged). */}
|
||||
<ArticleComments articleSlug={article.slug} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
@@ -424,6 +431,45 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="bg-[#111] border border-[#222] p-5">
|
||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||
Related Forum Posts
|
||||
</h3>
|
||||
<ul className="space-y-3.5">
|
||||
{relatedForumThreads.map((t) => (
|
||||
<li key={t.id}>
|
||||
<Link href={`/forum/thread/${t.id}`} className="group block">
|
||||
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
|
||||
{t.title}
|
||||
</p>
|
||||
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
|
||||
{t.category_name && (
|
||||
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
|
||||
{t.category_name}
|
||||
</span>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
|
||||
{t.vote_score > 0 && <><span>·</span><span>▲ {t.vote_score}</span></>}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/forum"
|
||||
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Browse all forum threads →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar ad stack — 300x600 + 300x250 rotating */}
|
||||
<SidebarAdStack />
|
||||
</aside>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
@@ -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) }}
|
||||
/>
|
||||
<ArticleDetailPage article={article} relatedArticles={related} />
|
||||
<ArticleDetailPage article={article} relatedArticles={related} relatedForumThreads={relatedForumThreads} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Article comments — sign-in gated for posting + voting,
|
||||
public for reading. AI personas participate (tagged). */}
|
||||
<ArticleComments articleSlug={article.slug} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
@@ -557,6 +565,42 @@ export default function NewsArticleDetailClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Forum Posts — same widget as /articles/[slug] */}
|
||||
{relatedForumThreads.length > 0 && (
|
||||
<div className="bg-[#111] border border-[#222] p-5">
|
||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||
Related Forum Posts
|
||||
</h3>
|
||||
<ul className="space-y-3.5">
|
||||
{relatedForumThreads.map((t) => (
|
||||
<li key={t.id}>
|
||||
<Link href={`/forum/thread/${t.id}`} className="group block">
|
||||
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
|
||||
{t.title}
|
||||
</p>
|
||||
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
|
||||
{t.category_name && (
|
||||
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
|
||||
{t.category_name}
|
||||
</span>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
|
||||
{t.vote_score > 0 && <><span>·</span><span>▲ {t.vote_score}</span></>}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/forum"
|
||||
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Browse all forum threads →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
|
||||
<h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
@@ -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) {
|
||||
<NewsArticleDetailClient
|
||||
article={articleWithLinks}
|
||||
relatedArticles={related}
|
||||
relatedForumThreads={relatedForumThreads}
|
||||
companiesInStory={
|
||||
mentionedSlugs.length > 0 ? (
|
||||
<CompaniesInThisStory slugs={mentionedSlugs} />
|
||||
|
||||
315
src/components/ArticleComments.tsx
Normal file
315
src/components/ArticleComments.tsx
Normal file
@@ -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<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [body, setBody] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [myVotes, setMyVotes] = useState<Record<string, number>>({});
|
||||
|
||||
// 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<string, Comment[]>();
|
||||
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 (
|
||||
<section className="mt-10 border-t border-[#252525] pt-8">
|
||||
<header className="flex items-center justify-between mb-5">
|
||||
<h2 className="font-heading text-white text-xl font-bold">
|
||||
Discussion {total > 0 && <span className="text-[#666] font-normal">· {total}</span>}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{/* Composer or sign-in CTA */}
|
||||
{user ? (
|
||||
<div className="bg-[#0d0d0d] border border-[#252525] rounded-md p-4 mb-6">
|
||||
{replyTo && (
|
||||
<div className="flex items-center justify-between mb-2 text-xs text-[#888]">
|
||||
<span>Replying to comment…</span>
|
||||
<button onClick={() => setReplyTo(null)} className="text-[#3b82f6] hover:underline">Cancel reply</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={replyTo ? "Write your reply…" : "Share your take on this story…"}
|
||||
rows={3}
|
||||
className="w-full bg-[#111] border border-[#252525] rounded text-white text-sm font-body p-2 resize-none focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
{error && <p className="text-[11px] text-red-400 mt-1">{error}</p>}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-[#666]">Markdown + image links supported · 5000 char max</span>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={submitting || !body.trim()}
|
||||
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body text-xs font-semibold px-4 py-1.5 rounded transition-colors"
|
||||
>
|
||||
{submitting ? "Posting…" : replyTo ? "Post reply" : "Post comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gradient-to-r from-[#0d1520] to-[#0d0d0d] border border-[#1e3a5f] rounded-md p-5 mb-6 text-center">
|
||||
<p className="text-white font-body text-sm mb-3">
|
||||
Sign in to join the discussion. Vote, reply, and follow industry conversations.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link
|
||||
href={`/forum/login?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
|
||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-semibold px-5 py-2 rounded"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href={`/forum/register?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
|
||||
className="border border-[#3b82f6] text-[#3b82f6] hover:bg-[#3b82f6]/10 font-body text-xs font-semibold px-5 py-2 rounded"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment list */}
|
||||
{loading ? (
|
||||
<p className="text-[#666] text-sm">Loading comments…</p>
|
||||
) : tree.roots.length === 0 ? (
|
||||
<p className="text-[#666] text-sm italic">No comments yet — be the first.</p>
|
||||
) : (
|
||||
<ul className="space-y-5">
|
||||
{tree.roots.map((c) => (
|
||||
<CommentItem
|
||||
key={c.id}
|
||||
comment={c}
|
||||
children={tree.childrenMap.get(c.id) || []}
|
||||
canInteract={!!user}
|
||||
onReply={(id) => { setReplyTo(id); document.querySelector("textarea")?.focus(); }}
|
||||
onVote={vote}
|
||||
myVote={myVotes[c.id] || 0}
|
||||
myChildVotes={myVotes}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentItem({
|
||||
comment, children, canInteract, onReply, onVote, myVote, myChildVotes,
|
||||
}: {
|
||||
comment: Comment;
|
||||
children: Comment[];
|
||||
canInteract: boolean;
|
||||
onReply: (id: string) => void;
|
||||
onVote: (id: string, v: 1 | -1) => void;
|
||||
myVote: number;
|
||||
myChildVotes: Record<string, number>;
|
||||
}) {
|
||||
return (
|
||||
<li className="bg-[#0d0d0d] border border-[#252525] rounded-md p-4">
|
||||
<header className="flex items-center gap-2 mb-2">
|
||||
{comment.author_avatar_url ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={comment.author_avatar_url} alt="" className="w-7 h-7 rounded-full bg-[#1a1a1a] object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-[#1a2535] text-[#3b82f6] text-xs font-bold flex items-center justify-center flex-shrink-0">
|
||||
{(comment.author_display_name || "?").trim().charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{comment.author_display_name || "Member"}
|
||||
{comment.is_ai_seeded && (
|
||||
<span className="ml-1.5 text-[9px] uppercase tracking-widest bg-[#1e3a5f] text-[#60a5fa] px-1.5 py-0.5 rounded">AI</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[#666]">· {timeAgo(comment.created_at)}</span>
|
||||
</header>
|
||||
<div className="text-[#cbd5e1] text-sm whitespace-pre-wrap leading-relaxed">{comment.content}</div>
|
||||
<footer className="mt-2.5 flex items-center gap-3 text-xs">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button
|
||||
disabled={!canInteract}
|
||||
onClick={() => onVote(comment.id, 1)}
|
||||
title={canInteract ? "Upvote" : "Sign in to vote"}
|
||||
className={`px-1.5 py-0.5 rounded text-base leading-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
myVote === 1 ? "text-emerald-400" : "text-[#666] hover:text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<span className="font-mono text-[#888] text-[11px] min-w-[18px] text-center">{comment.vote_score}</span>
|
||||
<button
|
||||
disabled={!canInteract}
|
||||
onClick={() => onVote(comment.id, -1)}
|
||||
title={canInteract ? "Downvote" : "Sign in to vote"}
|
||||
className={`px-1.5 py-0.5 rounded text-base leading-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
myVote === -1 ? "text-red-400" : "text-[#666] hover:text-red-400"
|
||||
}`}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
{canInteract && (
|
||||
<button onClick={() => onReply(comment.id)} className="text-[#3b82f6] hover:underline text-[11px]">
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{children.length > 0 && (
|
||||
<ul className="mt-4 space-y-3 pl-5 border-l border-[#252525]">
|
||||
{children.map((kid) => (
|
||||
<CommentItem
|
||||
key={kid.id}
|
||||
comment={kid}
|
||||
children={[]}
|
||||
canInteract={canInteract}
|
||||
onReply={onReply}
|
||||
onVote={onVote}
|
||||
myVote={myChildVotes[kid.id] || 0}
|
||||
myChildVotes={myChildVotes}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -408,6 +408,99 @@ export async function searchLegacyArticles(
|
||||
return merged.slice(0, limit).map((m) => m.article);
|
||||
}
|
||||
|
||||
/** Pick keywords from an article title to match against forum threads.
|
||||
Drops short/common words; keeps brand/product nouns. */
|
||||
function titleKeywords(s: string): string[] {
|
||||
const STOP = new Set([
|
||||
"the","a","an","and","or","but","of","for","to","in","on","at","with","from",
|
||||
"by","as","is","are","was","were","be","been","being","this","that","these",
|
||||
"those","it","its","new","now","more","most","how","why","when","where","what",
|
||||
"who","which","also","plus","via","over","under","into","than","then","each",
|
||||
"all","any","every","some","just","one","two","three","four","five","six",
|
||||
]);
|
||||
return Array.from(new Set(
|
||||
s.toLowerCase()
|
||||
.replace(/[^a-z0-9 -]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 4 && !STOP.has(w))
|
||||
)).slice(0, 6);
|
||||
}
|
||||
|
||||
export interface RelatedForumThread {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
reply_count: number;
|
||||
view_count: number;
|
||||
vote_score: number;
|
||||
last_reply_at: string | null;
|
||||
category_slug: string | null;
|
||||
category_name: string | null;
|
||||
}
|
||||
|
||||
/** Find forum threads relevant to an article's title. Strategy: extract
|
||||
longish title tokens, match any against forum_threads.title (ILIKE OR),
|
||||
rank by reply_count + recency, cap at `limit`. Falls back to recently-
|
||||
active threads if no keyword matches. */
|
||||
export async function getRelatedForumThreads(
|
||||
articleTitle: string,
|
||||
limit = 6,
|
||||
): Promise<RelatedForumThread[]> {
|
||||
const kws = titleKeywords(articleTitle);
|
||||
let rows: any[] = [];
|
||||
if (kws.length > 0) {
|
||||
const filter = kws.map((k) => `title.ilike.%${k.replace(/[%_]/g, "")}%`).join(",");
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("forum_threads")
|
||||
.select("id, title, body, reply_count, view_count, vote_score, last_reply_at, category_id")
|
||||
.or(filter)
|
||||
.order("reply_count", { ascending: false })
|
||||
.order("last_reply_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit * 2);
|
||||
rows = data || [];
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (rows.length < limit) {
|
||||
// Top up with recently-active threads so we never render an empty box.
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("forum_threads")
|
||||
.select("id, title, body, reply_count, view_count, vote_score, last_reply_at, category_id")
|
||||
.order("last_reply_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit * 2);
|
||||
const seen = new Set(rows.map((r) => r.id));
|
||||
for (const r of data || []) {
|
||||
if (rows.length >= limit * 2) break;
|
||||
if (!seen.has(r.id)) rows.push(r);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// Resolve category slug/name in one batch
|
||||
const catIds = Array.from(new Set(rows.map((r) => r.category_id).filter(Boolean)));
|
||||
let catById = new Map<string, { slug: string; name: string }>();
|
||||
if (catIds.length) {
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("forum_categories")
|
||||
.select("id, slug, name")
|
||||
.in("id", catIds);
|
||||
for (const c of data || []) catById.set(c.id, { slug: c.slug, name: c.name });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return rows.slice(0, limit).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
body: r.body,
|
||||
reply_count: r.reply_count ?? 0,
|
||||
view_count: r.view_count ?? 0,
|
||||
vote_score: r.vote_score ?? 0,
|
||||
last_reply_at: r.last_reply_at,
|
||||
category_slug: catById.get(r.category_id)?.slug || null,
|
||||
category_name: catById.get(r.category_id)?.name || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
|
||||
let legacy: string[] = [];
|
||||
let rewrites: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user