Files
avbeat-com/src/app/forum/thread/[id]/page.tsx
Ryan Salazar d67a2bd48d AV Beat rebrand: theme swap (orange #E67E22 / near-black), logo, schema default → avb
- public/assets/images/logo.png + public/brand/logo*.png replaced with the 1320x310 AV Beat logo
- public/assets/logos/avbeat.png added as the canonical source
- tailwind.config.js + src/styles/tailwind.css + src/styles/redesign-tokens.css palette swapped (primary #0F0E0E, secondary #1D1A1A, accent #E67E22 orange, foreground #F0EBE6 warm white)
- Layout / robots / sitemap / home-page / about / contact / press-kit / team / global-error metadata switched to AV Beat (Where AV Meets IT)
- Bulk replaced 'Broadcast Beat'/'BroadcastBeat.com'/'broadcastbeat.com' across user-facing news/forum/marketplace/advertise/search/newsletter pages
- src/lib/supabase/{admin,server,client} now default to schema 'avb' (was 'bb') — overridable via NEXT_PUBLIC_SUPABASE_SCHEMA
- package.json renamed to 'avbeat'
- Single d60701 reference in about/team swapped to E67E22

Design-only replica — no article content migrated.
2026-06-01 15:30:16 +00:00

556 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import AdImage from '@/components/AdImage';
import { pickAds, rotateAll } from '@/lib/ads';
import { createClient } from '@/lib/supabase/client';
import SidebarAdStack from "@/components/SidebarAdStack";
import LinkifyPlainText from "@/components/LinkifyPlainText";
import RichBody from "@/components/forum/RichBody";
import ImageAttachButton from "@/components/forum/ImageAttachButton";
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
// Inline ad slot inserted between forum replies every N posts.
function InThreadAd({ adIndex }: { adIndex: number }) {
const pool = rotateAll("300x250");
if (pool.length === 0) return null;
const ad = pool[adIndex % pool.length];
return (
<div className="bg-[#0d1117] border border-dashed border-[#2a3a50] rounded-lg p-4 my-3 flex flex-col items-center gap-2" aria-label="Advertisement">
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6b7280]">Sponsored</span>
<AdImage ad={ad} page="forum-thread" />
</div>
);
}
interface ForumThread {
id: string;
title: string;
body: string;
author_name: string;
status: string;
reply_count: number;
view_count: number;
upvotes: number;
downvotes: number;
vote_score: number;
created_at: string;
forum_categories: { id: string; name: string; slug: string };
}
interface ForumReply {
id: string;
body: string;
author_name: string;
is_ai_response: boolean;
upvotes: number;
downvotes: number;
vote_score: number;
created_at: string;
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function Avatar({ name, isAI }: { name: string; isAI: boolean }) {
if (isAI) {
return (
<div className="w-9 h-9 rounded-full bg-[#1e3a5f] border border-[#E67E22] flex items-center justify-center flex-shrink-0">
<span className="text-[#E67E22] text-xs font-bold">AI</span>
</div>
);
}
const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const colors = ['#1e3a5f', '#1a3a2a', '#3a1a1a', '#2a1a3a', '#1a2a3a'];
const colorIdx = name.charCodeAt(0) % colors.length;
return (
<div
className="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 text-white text-xs font-bold"
style={{ backgroundColor: colors[colorIdx] }}
>
{initials || '?'}
</div>
);
}
interface VoteButtonsProps {
targetType: 'thread' | 'reply';
targetId: string;
upvotes: number;
downvotes: number;
voteScore: number;
isAI?: boolean;
isAuthenticated: boolean;
}
function VoteButtons({ targetType, targetId, upvotes, downvotes, voteScore, isAI, isAuthenticated }: VoteButtonsProps) {
const [currentVote, setCurrentVote] = useState<1 | -1 | null>(null);
const [counts, setCounts] = useState({ upvotes, downvotes, vote_score: voteScore });
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
useEffect(() => {
if (!isAuthenticated || fetched) return;
fetch(`/api/forum/votes?target_type=${targetType}&target_id=${targetId}`)
.then(r => r.json())
.then(d => {
setCurrentVote(d.vote ?? null);
setFetched(true);
})
.catch(() => setFetched(true));
}, [isAuthenticated, targetType, targetId, fetched]);
const handleVote = async (value: 1 | -1) => {
if (!isAuthenticated || loading || isAI) return;
setLoading(true);
try {
const res = await fetch('/api/forum/votes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_type: targetType, target_id: targetId, vote_value: value }),
});
const data = await res.json();
if (!data.error) {
setCurrentVote(data.vote);
setCounts({ upvotes: data.upvotes, downvotes: data.downvotes, vote_score: data.vote_score });
}
} catch {
// silent
} finally {
setLoading(false);
}
};
const scoreColor = counts.vote_score > 0 ? 'text-[#E67E22]' : counts.vote_score < 0 ? 'text-red-400' : 'text-[#666]';
return (
<div className="flex items-center gap-1 mt-2">
{/* Upvote */}
<button
onClick={() => handleVote(1)}
disabled={loading || !isAuthenticated || !!isAI}
title={!isAuthenticated ? 'Sign in to vote' : isAI ? 'Cannot vote on AI responses' : 'Upvote'}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-body font-semibold transition-colors border ${
currentVote === 1
? 'bg-[#E67E22]/20 border-[#E67E22] text-[#E67E22]'
: 'bg-transparent border-[#2a2a2a] text-[#666] hover:border-[#E67E22] hover:text-[#E67E22]'
} disabled:opacity-40 disabled:cursor-not-allowed`}
aria-label="Upvote"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={currentVote === 1 ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
<span>{counts.upvotes}</span>
</button>
{/* Score */}
<span className={`text-xs font-body font-bold px-1 min-w-[1.5rem] text-center ${scoreColor}`}>
{counts.vote_score > 0 ? `+${counts.vote_score}` : counts.vote_score}
</span>
{/* Downvote */}
<button
onClick={() => handleVote(-1)}
disabled={loading || !isAuthenticated || !!isAI}
title={!isAuthenticated ? 'Sign in to vote' : isAI ? 'Cannot vote on AI responses' : 'Downvote'}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-body font-semibold transition-colors border ${
currentVote === -1
? 'bg-red-900/30 border-red-700 text-red-400' :'bg-transparent border-[#2a2a2a] text-[#666] hover:border-red-700 hover:text-red-400'
} disabled:opacity-40 disabled:cursor-not-allowed`}
aria-label="Downvote"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={currentVote === -1 ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
<span>{counts.downvotes}</span>
</button>
{!isAuthenticated && (
<Link href="/login" className="text-xs font-body text-[#555] hover:text-[#E67E22] ml-1 transition-colors">
Sign in to vote
</Link>
)}
</div>
);
}
export default function ForumThreadPage() {
const params = useParams();
const threadId = params?.id as string;
const supabase = createClient();
const [thread, setThread] = useState<ForumThread | null>(null);
const [replies, setReplies] = useState<ForumReply[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Reply form
const [replyBody, setReplyBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
// AI assistant
const [aiQuestion, setAiQuestion] = useState('');
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const repliesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setIsAuthenticated(!!user);
});
}, []);
useEffect(() => {
if (!threadId) return;
fetch(`/api/forum/threads/${threadId}`)
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error);
setThread(d.thread);
setReplies(d.replies || []);
setLoading(false);
})
.catch(e => {
setError(e.message || 'Failed to load thread.');
setLoading(false);
});
}, [threadId]);
const handleReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyBody.trim() || !threadId) return;
setSubmitting(true);
setReplyError(null);
try {
const res = await fetch('/api/forum/replies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
thread_id: threadId,
body: replyBody,
}),
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
window.location.href = `/forum/login?next=${encodeURIComponent(window.location.pathname)}`;
return;
}
throw new Error(data.error || `Reply failed (${res.status})`);
}
if (data.reply) {
setReplies(prev => [...prev, data.reply]);
setReplyBody('');
setTimeout(() => repliesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
}
} catch (err: any) {
setReplyError(err.message || 'Failed to post reply. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleAiAssist = async (e: React.FormEvent) => {
e.preventDefault();
if (!aiQuestion.trim() || !thread) return;
setAiLoading(true);
setAiError(null);
try {
const res = await fetch('/api/forum/ai-assist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
thread_id: threadId,
question: aiQuestion,
thread_title: thread.title,
thread_body: thread.body,
replies: replies.slice(-5),
}),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
if (data.reply) {
setReplies(prev => [...prev, data.reply]);
setAiQuestion('');
setTimeout(() => repliesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
}
} catch (err: any) {
setAiError(err.message || 'AI assistant unavailable. Please try again.');
} finally {
setAiLoading(false);
}
};
if (loading) {
return (
<>
<Header />
<main className="min-h-screen bg-[#111111]">
<aside className="float-right ml-6 mb-6 hidden lg:block max-w-[300px]"><SidebarAdStack /></aside>
<div className="max-w-container mx-auto px-4 py-10 space-y-4">
<div className="h-8 bg-[#1a1a1a] rounded animate-pulse w-2/3" />
<div className="h-32 bg-[#1a1a1a] rounded animate-pulse" />
<div className="h-20 bg-[#1a1a1a] rounded animate-pulse" />
</div>
</main>
<Footer />
</>
);
}
if (error || !thread) {
return (
<>
<Header />
<main className="min-h-screen bg-[#111111]">
<div className="max-w-container mx-auto px-4 py-10">
<div className="bg-red-900/20 border border-red-800 rounded-lg p-4 text-red-400 font-body">
{error || 'Thread not found.'}
</div>
<Link href="/forum" className="mt-4 inline-block text-[#E67E22] hover:underline font-body text-sm">
Back to Forum
</Link>
</div>
</main>
<Footer />
</>
);
}
const category = thread.forum_categories;
return (
<>
<Header />
<main className="min-h-screen bg-[#111111]">
{/* Breadcrumb */}
<div className="bg-[#1a2535] border-b border-[#2a3a50]">
<div className="max-w-container mx-auto px-4 py-4">
<nav className="text-sm font-body text-[#666]">
<Link href="/forum" className="hover:text-[#E67E22] transition-colors">Forum</Link>
<span className="mx-2"></span>
{category && (
<>
<Link href={`/forum/${category.slug}`} className="hover:text-[#E67E22] transition-colors">{category.name}</Link>
<span className="mx-2"></span>
</>
)}
<span className="text-[#aab4c4] truncate">{thread.title}</span>
</nav>
</div>
</div>
<div className="max-w-container mx-auto px-4 py-6">
<div className="max-w-3xl mx-auto">
{/* Thread Title */}
<h1 className="text-2xl font-heading font-bold text-white mb-6 leading-snug">{thread.title}</h1>
{/* Original Post */}
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-5 mb-4">
<div className="flex items-start gap-3">
<Avatar name={thread.author_name} isAI={false} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#E67E22] hover:underline">{thread.author_name}</Link>
<span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span>
<span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{thread.view_count} views</span>
</div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<RichBody body={thread.body} />
</p>
<VoteButtons
targetType="thread"
targetId={thread.id}
upvotes={thread.upvotes ?? 0}
downvotes={thread.downvotes ?? 0}
voteScore={thread.vote_score ?? 0}
isAuthenticated={isAuthenticated}
/>
</div>
</div>
</div>
{/* Replies */}
{replies.length > 0 && (
<div className="space-y-3 mb-6">
{replies.map((reply, idx) => (
<React.Fragment key={reply.id}>
<div
className={`border rounded-lg p-5 ${
reply.is_ai_response
? 'bg-[#0d1f35] border-[#1e3a5f]'
: 'bg-[#1a1a1a] border-[#252525]'
}`}
>
<div className="flex items-start gap-3">
<Avatar name={reply.author_name} isAI={reply.is_ai_response} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Link href={`/forum/user/${encodeURIComponent(reply.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#E67E22] hover:underline">{reply.author_name}</Link>
{reply.is_ai_response && (
<span className="text-xs bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/30 px-2 py-0.5 rounded font-body font-semibold">
AI Assistant
</span>
)}
<span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">#{idx + 1}</span>
<span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
</div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<RichBody body={reply.body} />
</p>
<VoteButtons
targetType="reply"
targetId={reply.id}
upvotes={reply.upvotes ?? 0}
downvotes={reply.downvotes ?? 0}
voteScore={reply.vote_score ?? 0}
isAI={reply.is_ai_response}
isAuthenticated={isAuthenticated}
/>
</div>
</div>
</div>
{/* In-thread 300x250 ad after every 4 replies (idx 3, 7, 11, …),
unless it's the last reply (avoid trailing ad). */}
{(idx + 1) % 4 === 0 && idx < replies.length - 1 && (
<InThreadAd adIndex={Math.floor(idx / 4)} />
)}
</React.Fragment>
))}
<div ref={repliesEndRef} />
</div>
)}
{/* AI Assistant Panel */}
<div className="bg-[#0d1f35] border border-[#1e3a5f] rounded-lg p-5 mb-6">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 rounded-full bg-[#1e3a5f] border border-[#E67E22] flex items-center justify-center">
<span className="text-[#E67E22] text-xs font-bold">AI</span>
</div>
<div>
<span className="text-white font-body font-semibold text-sm">AV Beat AI Assistant</span>
<span className="ml-2 text-xs bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/30 px-2 py-0.5 rounded font-body">AI Not a human</span>
</div>
</div>
<p className="text-[#7a9ab8] font-body text-xs mb-3">
Ask the AI assistant a question related to this thread. Responses are generated by AI and should be verified for critical decisions.
</p>
<form onSubmit={handleAiAssist} className="flex gap-2">
<input
type="text"
placeholder="Ask a question about this topic..."
value={aiQuestion}
onChange={e => setAiQuestion(e.target.value)}
disabled={aiLoading}
className="flex-1 bg-[#111] border border-[#2a3a50] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#3a5a7a] focus:outline-none focus:border-[#E67E22] disabled:opacity-50"
/>
<button
type="submit"
disabled={aiLoading || !aiQuestion.trim()}
className="bg-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors flex-shrink-0"
>
{aiLoading ? (
<span className="flex items-center gap-1">
<svg className="animate-spin w-3 h-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Thinking...
</span>
) : 'Ask AI'}
</button>
</form>
{aiError && (
<p className="text-red-400 font-body text-xs mt-2">{aiError}</p>
)}
</div>
{/* Reply Form — auth-gated */}
{thread.status !== 'closed' && isAuthenticated && (
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-5">
<h3 className="text-white font-heading font-semibold mb-4">Post a Reply</h3>
<form onSubmit={handleReply} className="space-y-3">
<textarea
placeholder="Share your thoughts, experience, or answer..."
value={replyBody}
onChange={e => setReplyBody(e.target.value)}
required
rows={5}
className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#E67E22] resize-none"
/>
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setReplyBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{replyError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{replyError}
</div>
)}
<button
type="submit"
disabled={submitting || !replyBody.trim()}
className="bg-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
>
{submitting ? 'Posting...' : 'Post Reply'}
</button>
</form>
</div>
)}
{thread.status !== 'closed' && !isAuthenticated && (
<div className="bg-[#0d1f35] border border-[#1e3a5f] rounded-lg p-5 text-center">
<h3 className="text-white font-heading font-semibold mb-1">Join the discussion</h3>
<p className="text-[#7a9ab8] font-body text-sm mb-4">
Sign in or create a free account to post replies.
</p>
<div className="flex items-center justify-center gap-3">
<Link
href={`/forum/login?next=${encodeURIComponent(`/forum/thread/${threadId}`)}`}
className="bg-[#E67E22] hover:bg-[#C8651B] text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
>
Sign In
</Link>
<Link
href={`/forum/register?next=${encodeURIComponent(`/forum/thread/${threadId}`)}`}
className="border border-[#E67E22] text-[#E67E22] hover:bg-[#E67E22]/10 font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
>
Register
</Link>
</div>
</div>
)}
{thread.status === 'closed' && (
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg p-4 text-center text-[#666] font-body text-sm">
This thread is closed to new replies.
</div>
)}
</div>
</div>
</main>
<Footer />
<CompanyMentionsHover />
</>
);
}