From 635e9fa46c2ddf1059063454c09ffd0d245d92e9 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 08:39:55 +0800 Subject: [PATCH] feat: add admin comments page with search, filters, pagination, remove/restore --- .../admin/comments/comments-client.tsx | 491 ++++++++++++++++++ app/(admin)/admin/comments/page.tsx | 10 + components/admin/admin-sidebar.tsx | 3 +- 3 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 app/(admin)/admin/comments/comments-client.tsx create mode 100644 app/(admin)/admin/comments/page.tsx diff --git a/app/(admin)/admin/comments/comments-client.tsx b/app/(admin)/admin/comments/comments-client.tsx new file mode 100644 index 0000000..0a71af4 --- /dev/null +++ b/app/(admin)/admin/comments/comments-client.tsx @@ -0,0 +1,491 @@ +"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? +

+
+ + +
+ + )} +
+
+ )} +
+ ) +} diff --git a/app/(admin)/admin/comments/page.tsx b/app/(admin)/admin/comments/page.tsx new file mode 100644 index 0000000..b22ed8f --- /dev/null +++ b/app/(admin)/admin/comments/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { CommentsClient } from "./comments-client" + +export const metadata: Metadata = { + title: "Comments", +} + +export default function CommentsPage() { + return +} diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index dd837ae..5a140ee 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -2,12 +2,13 @@ import Link from "next/link" import { usePathname } from "next/navigation" -import { UsersIcon, CpuIcon, Gamepad2Icon } from "lucide-react" +import { UsersIcon, CpuIcon, Gamepad2Icon, MessageSquareIcon } from "lucide-react" const adminNavItems = [ { href: "/admin/users", label: "Users", icon: UsersIcon }, { href: "/admin/hardware", label: "Hardware", icon: CpuIcon }, { href: "/admin/games", label: "Games", icon: Gamepad2Icon }, + { href: "/admin/comments", label: "Comments", icon: MessageSquareIcon }, ] export function AdminSidebar() {