initial commit: rocket.new export of broadcastbeat
This commit is contained in:
580
src/app/admin/forum-moderation/page.tsx
Normal file
580
src/app/admin/forum-moderation/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user