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.
581 lines
24 KiB
TypeScript
581 lines
24 KiB
TypeScript
'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>
|
||
</>
|
||
);
|
||
}
|