"use client" import { useCallback, useEffect, useRef, useState } from "react" import Image from "next/image" import Link from "next/link" import { Loader2, SearchIcon, TrashIcon, RotateCcwIcon, ExternalLinkIcon, ChevronLeftIcon, ChevronRightIcon, MessageSquareIcon, } from "lucide-react" interface Comment { id: string gameId: string content: Record upvotes: number isRemoved: boolean createdAt: string updatedAt: string userId: string userName: string | null userImage: string | null gameTitle: string parentId: string | null } interface CommentsApiResponse { data: Comment[] total: number limit: number offset: number } type StatusFilter = "all" | "active" | "removed" const LIMIT = 20 function formatDate(value: string | Date | null | undefined) { if (!value) return "—" const d = typeof value === "string" ? new Date(value) : value return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) } function getInitial(name: string | null | undefined) { return name?.charAt(0)?.toUpperCase() || "?" } function extractPlainText(content: Record, maxLength = 80): string { let result = "" function walk(node: unknown) { if (typeof node !== "object" || node === null) return if (Array.isArray(node)) { for (const item of node) { walk(item) if (result.length >= maxLength) return } return } const obj = node as Record if (typeof obj.text === "string") { result += obj.text if (result.length >= maxLength) return } if (Array.isArray(obj.content)) { for (const item of obj.content) { walk(item) if (result.length >= maxLength) return } } } walk(content) return result.length > maxLength ? result.slice(0, maxLength) + "…" : result } function statusBadgeClasses(isRemoved: boolean) { return isRemoved ? "bg-red-500/10 text-red-400" : "bg-green-500/10 text-green-400" } function statusDotClass(isRemoved: boolean) { return isRemoved ? "bg-red-400" : "bg-green-400" } export function CommentsClient() { const [comments, setComments] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [search, setSearch] = useState("") const [offset, setOffset] = useState(0) const [statusFilter, setStatusFilter] = useState("all") const [actionLoading, setActionLoading] = useState>({}) const [confirmAction, setConfirmAction] = useState< | { type: "remove" | "restore"; comment: Comment } | null >(null) const isSearchChangeRef = useRef(false) const handleSearchChange = (value: string) => { setSearch(value) setOffset(0) isSearchChangeRef.current = true } const handleStatusChange = (value: StatusFilter) => { setStatusFilter(value) setOffset(0) isSearchChangeRef.current = true } const handlePrev = () => { setOffset((prev) => Math.max(0, prev - LIMIT)) isSearchChangeRef.current = false } const handleNext = () => { setOffset((prev) => prev + LIMIT) isSearchChangeRef.current = false } const loadData = useCallback(async () => { setLoading(true) try { const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset), }) if (statusFilter === "active") { params.set("removed", "false") } else if (statusFilter === "removed") { params.set("removed", "true") } if (search.trim()) { params.set("search", search.trim()) } const res = await fetch(`/api/admin/comments?${params.toString()}`) if (res.ok) { const json = (await res.json()) as CommentsApiResponse setComments(json.data) setTotal(json.total) } } catch { // ignore } finally { setLoading(false) } }, [search, offset, statusFilter]) useEffect(() => { const delay = isSearchChangeRef.current ? 300 : 0 isSearchChangeRef.current = false let cancelled = false const timer = setTimeout(async () => { if (!cancelled) { await loadData() } }, delay) return () => { cancelled = true clearTimeout(timer) } }, [loadData]) const handleRemove = async (comment: Comment) => { setActionLoading((prev) => ({ ...prev, [comment.id]: true })) try { const res = await fetch(`/api/admin/comments/${comment.id}/remove`, { method: "PATCH", }) if (res.ok) { await loadData() setConfirmAction(null) } } finally { setActionLoading((prev) => ({ ...prev, [comment.id]: false })) } } const handleRestore = async (comment: Comment) => { setActionLoading((prev) => ({ ...prev, [comment.id]: true })) try { const res = await fetch(`/api/admin/comments/${comment.id}/restore`, { method: "PATCH", }) if (res.ok) { await loadData() setConfirmAction(null) } } finally { setActionLoading((prev) => ({ ...prev, [comment.id]: false })) } } const hasMore = offset + comments.length < total const tabs: { label: string; value: StatusFilter }[] = [ { label: "All", value: "all" }, { label: "Active", value: "active" }, { label: "Removed", value: "removed" }, ] return (
{/* Header */}

Comments

{/* Search */}
handleSearchChange(e.target.value)} placeholder="Search comments..." className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors" />
{/* Filter tabs */}
{tabs.map((tab) => ( ))}
{/* Table */}
{loading ? ( ) : comments.length === 0 ? ( ) : ( comments.map((comment) => ( )) )}
Author Content Game Upvotes Status Date Actions
No comments found.
{comment.userImage ? ( {comment.userName ) : (
{getInitial(comment.userName)}
)}

{comment.userName || "Unknown"}

{extractPlainText(comment.content, 80)}

{comment.gameTitle}

{comment.upvotes} {comment.isRemoved ? "Removed" : "Active"} {formatDate(comment.createdAt)}
View {!comment.isRemoved ? ( ) : ( )}
{/* Pagination */} {comments.length > 0 && (

Showing {offset + 1}–{Math.min(offset + comments.length, total)} of{" "} {total}

)} {/* Confirmation Dialogs */} {confirmAction && (
{confirmAction.type === "remove" && ( <>

Confirm Remove

Are you sure you want to remove this comment?

)} {confirmAction.type === "restore" && ( <>

Confirm Restore

Are you sure you want to restore this comment?

)}
)}
) }