Files
avbeat-com/src/app/forum/thread/[id]/page.tsx

493 lines
19 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 { createClient } from '@/lib/supabase/client';
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-[#3b82f6] flex items-center justify-center flex-shrink-0">
<span className="text-[#3b82f6] 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-[#3b82f6]' : 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-[#3b82f6]/20 border-[#3b82f6] text-[#3b82f6]'
: 'bg-transparent border-[#2a2a2a] text-[#666] hover:border-[#3b82f6] hover:text-[#3b82f6]'
} 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-[#3b82f6] 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 [replyAuthor, setReplyAuthor] = useState('');
const [submitting, setSubmitting] = useState(false);
// 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);
try {
const res = await fetch('/api/forum/replies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
thread_id: threadId,
body: replyBody,
author_name: replyAuthor || 'Anonymous',
}),
});
const data = await res.json();
if (data.reply) {
setReplies(prev => [...prev, data.reply]);
setReplyBody('');
setReplyAuthor('');
setTimeout(() => repliesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
}
} catch {
// silent
} 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]">
<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-[#3b82f6] 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-[#3b82f6] transition-colors">Forum</Link>
<span className="mx-2"></span>
{category && (
<>
<Link href={`/forum/${category.slug}`} className="hover:text-[#3b82f6] 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">
<span className="text-white font-body font-semibold text-sm">{thread.author_name}</span>
<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 whitespace-pre-wrap">{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) => (
<div
key={reply.id}
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">
<span className="text-white font-body font-semibold text-sm">{reply.author_name}</span>
{reply.is_ai_response && (
<span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/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 whitespace-pre-wrap">{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>
))}
<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-[#3b82f6] flex items-center justify-center">
<span className="text-[#3b82f6] text-xs font-bold">AI</span>
</div>
<div>
<span className="text-white font-body font-semibold text-sm">Broadcast Beat AI Assistant</span>
<span className="ml-2 text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/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-[#3b82f6] disabled:opacity-50"
/>
<button
type="submit"
disabled={aiLoading || !aiQuestion.trim()}
className="bg-[#3b82f6] hover:bg-[#2563eb] 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 */}
{thread.status !== 'closed' && (
<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">
<input
type="text"
placeholder="Your name (optional)"
value={replyAuthor}
onChange={e => setReplyAuthor(e.target.value)}
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-[#3b82f6]"
/>
<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-[#3b82f6] resize-none"
/>
<button
type="submit"
disabled={submitting || !replyBody.trim()}
className="bg-[#3b82f6] hover:bg-[#2563eb] 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' && (
<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 />
</>
);
}