initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,580 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Header from '@/components/Header';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ForumThread {
id: string;
title: string;
author_name: string;
body: string;
reply_count: number;
view_count: number;
upvote_count: number;
is_pinned: boolean;
is_featured: boolean;
is_locked: boolean;
is_flagged: boolean;
flag_reason: string | null;
created_at: string;
forum_categories: { name: string; slug: string } | null;
}
interface ForumReply {
id: string;
body: string;
author_name: string;
is_hidden: boolean;
is_flagged: boolean;
flag_reason: string | null;
created_at: string;
forum_threads: { id: string; title: string } | null;
}
type Tab = 'threads' | 'replies';
type ThreadFilter = 'all' | 'flagged' | 'ai_flagged' | 'pinned' | 'featured' | 'locked';
type ReplyFilter = 'all' | 'flagged' | 'ai_flagged' | 'hidden';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
// ─── Flag Modal ───────────────────────────────────────────────────────────────
interface FlagModalProps {
onConfirm: (reason: string) => void;
onCancel: () => void;
}
function FlagModal({ onConfirm, onCancel }: FlagModalProps) {
const [reason, setReason] = useState('');
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div className="bg-[#1a1a1a] border border-[#333] rounded-xl p-6 w-full max-w-md">
<h3 className="text-white font-heading font-bold text-lg mb-2">Flag Content</h3>
<p className="text-[#888] font-body text-sm mb-4">Provide a reason for flagging this content (optional).</p>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
rows={3}
placeholder="e.g. Spam, harassment, off-topic..."
className="w-full bg-[#0d0d0d] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#ef4444] resize-none mb-4"
/>
<div className="flex gap-3 justify-end">
<button onClick={onCancel} className="px-4 py-2 text-sm font-body text-[#888] hover:text-white bg-[#252525] hover:bg-[#333] rounded-lg transition-colors">
Cancel
</button>
<button onClick={() => onConfirm(reason)} className="px-4 py-2 text-sm font-body font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors">
Flag Content
</button>
</div>
</div>
</div>
);
}
// ─── Action Badge ─────────────────────────────────────────────────────────────
function StatusBadge({ label, color }: { label: string; color: string }) {
return (
<span className={`inline-flex items-center gap-1 text-xs font-body font-semibold px-2 py-0.5 rounded-full ${color}`}>
{label}
</span>
);
}
// ─── Thread Row ───────────────────────────────────────────────────────────────
interface ThreadRowProps {
thread: ForumThread;
onAction: (id: string, action: string, flagReason?: string) => Promise<void>;
loadingId: string | null;
}
function ThreadRow({ thread, onAction, loadingId }: ThreadRowProps) {
const [showFlagModal, setShowFlagModal] = useState(false);
const isLoading = loadingId === thread.id;
const isAiFlagged = thread.is_flagged && thread.flag_reason?.startsWith('[AI]');
return (
<>
{showFlagModal && (
<FlagModal
onConfirm={async (reason) => {
setShowFlagModal(false);
await onAction(thread.id, 'flag', reason);
}}
onCancel={() => setShowFlagModal(false)}
/>
)}
<div className={`bg-[#111] border rounded-lg p-4 transition-colors ${isAiFlagged ? 'border-purple-500/50' : thread.is_flagged ? 'border-red-500/40' : 'border-[#252525]'}`}>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{/* Title + badges */}
<div className="flex flex-wrap items-center gap-2 mb-1">
<Link
href={`/forum/thread/${thread.id}`}
className="text-white font-body font-semibold text-sm hover:text-[#3b82f6] transition-colors truncate max-w-xs"
target="_blank"
>
{thread.title}
</Link>
{thread.is_pinned && <StatusBadge label="📌 Pinned" color="text-amber-400 bg-amber-400/10" />}
{thread.is_featured && <StatusBadge label="⭐ Featured" color="text-yellow-400 bg-yellow-400/10" />}
{thread.is_locked && <StatusBadge label="🔒 Locked" color="text-orange-400 bg-orange-400/10" />}
{isAiFlagged && <StatusBadge label="🤖 AI Flagged" color="text-purple-400 bg-purple-400/10" />}
{thread.is_flagged && !isAiFlagged && <StatusBadge label="🚩 Flagged" color="text-red-400 bg-red-400/10" />}
</div>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 text-xs text-[#666] font-body">
<span>by <span className="text-[#aaa]">{thread.author_name}</span></span>
<span>{thread.forum_categories?.name || '—'}</span>
<span>{thread.reply_count} replies</span>
<span>{thread.view_count} views</span>
<span>{timeAgo(thread.created_at)}</span>
</div>
{thread.flag_reason && (
<p className={`text-xs font-body mt-1 ${isAiFlagged ? 'text-purple-400' : 'text-red-400'}`}>
{isAiFlagged ? '🤖 ' : ''}Flag reason: {thread.flag_reason.replace('[AI] ', '')}
</p>
)}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-1.5 flex-shrink-0">
<button
disabled={isLoading}
onClick={() => onAction(thread.id, thread.is_pinned ? 'unpin' : 'pin')}
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
thread.is_pinned
? 'bg-amber-400/20 text-amber-400 hover:bg-amber-400/30' :'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-amber-400'
}`}
>
{thread.is_pinned ? 'Unpin' : 'Pin'}
</button>
<button
disabled={isLoading}
onClick={() => onAction(thread.id, thread.is_featured ? 'unfeature' : 'feature')}
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
thread.is_featured
? 'bg-yellow-400/20 text-yellow-400 hover:bg-yellow-400/30' :'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-yellow-400'
}`}
>
{thread.is_featured ? 'Unfeature' : 'Feature'}
</button>
<button
disabled={isLoading}
onClick={() => onAction(thread.id, thread.is_locked ? 'unlock' : 'lock')}
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
thread.is_locked
? 'bg-orange-400/20 text-orange-400 hover:bg-orange-400/30' :'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-orange-400'
}`}
>
{thread.is_locked ? 'Unlock' : 'Lock'}
</button>
{thread.is_flagged ? (
<button
disabled={isLoading}
onClick={() => onAction(thread.id, 'unflag')}
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
isAiFlagged
? 'bg-purple-400/20 text-purple-400 hover:bg-purple-400/30' :'bg-red-400/20 text-red-400 hover:bg-red-400/30'
}`}
>
Unflag
</button>
) : (
<button
disabled={isLoading}
onClick={() => setShowFlagModal(true)}
className="px-2.5 py-1 text-xs font-body font-semibold rounded-md bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-red-400 transition-colors disabled:opacity-50"
>
Flag
</button>
)}
</div>
</div>
</div>
</>
);
}
// ─── Reply Row ────────────────────────────────────────────────────────────────
interface ReplyRowProps {
reply: ForumReply;
onAction: (id: string, action: string, flagReason?: string) => Promise<void>;
loadingId: string | null;
}
function ReplyRow({ reply, onAction, loadingId }: ReplyRowProps) {
const [showFlagModal, setShowFlagModal] = useState(false);
const isLoading = loadingId === reply.id;
const isAiFlagged = reply.is_flagged && reply.flag_reason?.startsWith('[AI]');
return (
<>
{showFlagModal && (
<FlagModal
onConfirm={async (reason) => {
setShowFlagModal(false);
await onAction(reply.id, 'flag', reason);
}}
onCancel={() => setShowFlagModal(false)}
/>
)}
<div className={`bg-[#111] border rounded-lg p-4 transition-colors ${isAiFlagged ? 'border-purple-500/50' : reply.is_flagged ? 'border-red-500/40' : reply.is_hidden ? 'border-[#333] opacity-60' : 'border-[#252525]'}`}>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{/* Thread link + badges */}
<div className="flex flex-wrap items-center gap-2 mb-1">
{reply.forum_threads && (
<Link
href={`/forum/thread/${reply.forum_threads.id}`}
className="text-[#3b82f6] font-body text-xs hover:underline truncate max-w-xs"
target="_blank"
>
{reply.forum_threads.title}
</Link>
)}
{reply.is_hidden && <StatusBadge label="👁 Hidden" color="text-[#888] bg-[#252525]" />}
{isAiFlagged && <StatusBadge label="🤖 AI Flagged" color="text-purple-400 bg-purple-400/10" />}
{reply.is_flagged && !isAiFlagged && <StatusBadge label="🚩 Flagged" color="text-red-400 bg-red-400/10" />}
</div>
{/* Body preview */}
<p className="text-[#ccc] font-body text-sm line-clamp-2 mb-1">{reply.body}</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-[#666] font-body">
<span>by <span className="text-[#aaa]">{reply.author_name}</span></span>
<span>{timeAgo(reply.created_at)}</span>
</div>
{reply.flag_reason && (
<p className={`text-xs font-body mt-1 ${isAiFlagged ? 'text-purple-400' : 'text-red-400'}`}>
{isAiFlagged ? '🤖 ' : ''}Flag reason: {reply.flag_reason.replace('[AI] ', '')}
</p>
)}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-1.5 flex-shrink-0">
<button
disabled={isLoading}
onClick={() => onAction(reply.id, reply.is_hidden ? 'unhide' : 'hide')}
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
reply.is_hidden
? 'bg-[#252525] text-[#aaa] hover:bg-[#333]'
: 'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-white'
}`}
>
{reply.is_hidden ? 'Unhide' : 'Hide'}
</button>
{reply.is_flagged ? (
<button
disabled={isLoading}
onClick={() => onAction(reply.id, 'unflag')}
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
isAiFlagged
? 'bg-purple-400/20 text-purple-400 hover:bg-purple-400/30' :'bg-red-400/20 text-red-400 hover:bg-red-400/30'
}`}
>
Unflag
</button>
) : (
<button
disabled={isLoading}
onClick={() => setShowFlagModal(true)}
className="px-2.5 py-1 text-xs font-body font-semibold rounded-md bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-red-400 transition-colors disabled:opacity-50"
>
Flag
</button>
)}
</div>
</div>
</div>
</>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ForumModerationPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('threads');
const [threadFilter, setThreadFilter] = useState<ThreadFilter>('all');
const [replyFilter, setReplyFilter] = useState<ReplyFilter>('all');
const [threads, setThreads] = useState<ForumThread[]>([]);
const [replies, setReplies] = useState<ForumReply[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loadingData, setLoadingData] = useState(true);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const fetchData = useCallback(async () => {
setLoadingData(true);
try {
const filter = activeTab === 'threads' ? threadFilter : replyFilter;
const res = await fetch(`/api/admin/forum-moderation?tab=${activeTab}&filter=${filter}&page=${page}`);
const data = await res.json();
if (activeTab === 'threads') {
setThreads(data.threads || []);
} else {
setReplies(data.replies || []);
}
setTotal(data.total || 0);
} catch {
showToast('Failed to load data', 'error');
} finally {
setLoadingData(false);
}
}, [activeTab, threadFilter, replyFilter, page]);
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
useEffect(() => {
if (user) fetchData();
}, [user, fetchData]);
const handleThreadAction = async (id: string, action: string, flagReason?: string) => {
setLoadingId(id);
try {
const res = await fetch('/api/admin/forum-moderation', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'thread', id, action, flag_reason: flagReason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setThreads(prev => prev.map(t => t.id === id ? { ...t, ...data.thread } : t));
showToast(`Thread ${action}ned successfully`);
} catch (err: any) {
showToast(err.message || 'Action failed', 'error');
} finally {
setLoadingId(null);
}
};
const handleReplyAction = async (id: string, action: string, flagReason?: string) => {
setLoadingId(id);
try {
const res = await fetch('/api/admin/forum-moderation', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'reply', id, action, flag_reason: flagReason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setReplies(prev => prev.map(r => r.id === id ? { ...r, ...data.reply } : r));
showToast(`Reply ${action === 'hide' ? 'hidden' : action + 'ed'} successfully`);
} catch (err: any) {
showToast(err.message || 'Action failed', 'error');
} finally {
setLoadingId(null);
}
};
const totalPages = Math.ceil(total / 20);
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return null;
const THREAD_FILTERS: { value: ThreadFilter; label: string }[] = [
{ value: 'all', label: 'All Threads' },
{ value: 'flagged', label: '🚩 Flagged' },
{ value: 'ai_flagged', label: '🤖 AI Flagged' },
{ value: 'pinned', label: '📌 Pinned' },
{ value: 'featured', label: '⭐ Featured' },
{ value: 'locked', label: '🔒 Locked' },
];
const REPLY_FILTERS: { value: ReplyFilter; label: string }[] = [
{ value: 'all', label: 'All Replies' },
{ value: 'flagged', label: '🚩 Flagged' },
{ value: 'ai_flagged', label: '🤖 AI Flagged' },
{ value: 'hidden', label: '👁 Hidden' },
];
return (
<>
<Header />
<main className="min-h-screen bg-[#0a0a0a]">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-body font-semibold shadow-lg transition-all ${
toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'
}`}>
{toast.message}
</div>
)}
{/* Page Header */}
<div className="bg-[#1a2535] border-b border-[#2a3a50]">
<div className="max-w-7xl mx-auto px-4 py-6">
<nav className="text-sm font-body text-[#666] mb-2">
<Link href="/admin" className="hover:text-[#3b82f6] transition-colors">Admin</Link>
<span className="mx-2"></span>
<span className="text-[#aab4c4]">Forum Moderation</span>
</nav>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-heading font-bold text-white">Forum Moderation</h1>
<p className="text-[#aab4c4] font-body text-sm mt-1">
Pin, feature, lock threads · Flag inappropriate content · Hide replies
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-purple-400 font-body bg-purple-400/10 border border-purple-400/20 px-3 py-1.5 rounded-lg">
🤖 Claude AI Auto-Moderation Active
</span>
<span className="text-xs text-[#555] font-body bg-[#1a1a1a] border border-[#252525] px-3 py-1.5 rounded-lg">
🛡 Moderator View
</span>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Tabs */}
<div className="flex gap-1 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit mb-6">
{(['threads', 'replies'] as Tab[]).map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); setPage(1); }}
className={`px-5 py-2 text-sm font-body font-semibold rounded-md transition-colors capitalize ${
activeTab === tab
? 'bg-[#3b82f6] text-white'
: 'text-[#888] hover:text-white'
}`}
>
{tab === 'threads' ? '💬 Threads' : '↩ Replies'}
</button>
))}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2 mb-5">
{activeTab === 'threads'
? THREAD_FILTERS.map(f => (
<button
key={f.value}
onClick={() => { setThreadFilter(f.value); setPage(1); }}
className={`px-3 py-1.5 text-xs font-body font-semibold rounded-lg transition-colors ${
threadFilter === f.value
? 'bg-[#3b82f6] text-white'
: 'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-white border border-[#252525]'
}`}
>
{f.label}
</button>
))
: REPLY_FILTERS.map(f => (
<button
key={f.value}
onClick={() => { setReplyFilter(f.value); setPage(1); }}
className={`px-3 py-1.5 text-xs font-body font-semibold rounded-lg transition-colors ${
replyFilter === f.value
? 'bg-[#3b82f6] text-white'
: 'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-white border border-[#252525]'
}`}
>
{f.label}
</button>
))
}
<span className="ml-auto text-xs text-[#555] font-body self-center">{total} total</span>
</div>
{/* Content */}
{loadingData ? (
<div className="flex items-center justify-center py-20">
<div className="w-7 h-7 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : activeTab === 'threads' ? (
<>
{threads.length === 0 ? (
<div className="text-center py-16 text-[#555] font-body text-sm">No threads found for this filter.</div>
) : (
<div className="space-y-3">
{threads.map(thread => (
<ThreadRow
key={thread.id}
thread={thread}
onAction={handleThreadAction}
loadingId={loadingId}
/>
))}
</div>
)}
</>
) : (
<>
{replies.length === 0 ? (
<div className="text-center py-16 text-[#555] font-body text-sm">No replies found for this filter.</div>
) : (
<div className="space-y-3">
{replies.map(reply => (
<ReplyRow
key={reply.id}
reply={reply}
onAction={handleReplyAction}
loadingId={loadingId}
/>
))}
</div>
)}
</>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-8">
<button
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm font-body text-[#888] bg-[#1a1a1a] border border-[#252525] rounded-lg hover:bg-[#252525] disabled:opacity-40 transition-colors"
>
Prev
</button>
<span className="text-sm font-body text-[#666]">Page {page} of {totalPages}</span>
<button
disabled={page === totalPages}
onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm font-body text-[#888] bg-[#1a1a1a] border border-[#252525] rounded-lg hover:bg-[#252525] disabled:opacity-40 transition-colors"
>
Next
</button>
</div>
)}
</div>
</main>
</>
);
}