Files
avbeat-com/src/app/admin/forum-moderation/page.tsx
Ryan Salazar dc166dfb10 forum: fix API + UI column-name mismatch against bb.forum_threads
The schema has upvotes / downvotes / vote_score; API + UI were
querying/reading upvote_count / downvote_count (non-existent
columns). Threads existed in the DB but didn't render.

- /api/forum/threads route.ts:
  - 'top-voted' sort: order by vote_score (was upvote_count)
  - 'newest' default sort: forum_threads has no 'status' column,
    so order pinned-first via is_pinned, then last_reply_at desc
- UI ForumThread interface + components: rename
  upvote_count → upvotes, add downvotes + vote_score so the
  vote display in the thread cards and the admin moderation
  list pulls live data from the existing columns.

After this lands and the deploy completes, the 50 seeded
threads + the 9 ai-seeded new threads + 253 backfilled replies
already in the DB will all render at /forum.
2026-05-20 08:34:05 +00:00

581 lines
24 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, 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;
upvotes: 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>
</>
);
}