feat: migrate admin content and moderation modules

This commit is contained in:
2026-03-28 18:24:55 +08:00
parent 178434d63e
commit 84f82c2a7e
13 changed files with 2385 additions and 24 deletions

View File

@@ -0,0 +1,331 @@
import { CheckCheck, MessageSquareText, RefreshCcw, Trash2, XCircle } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { formatDateTime } from '@/lib/admin-format'
import type { CommentRecord } from '@/lib/types'
function moderationBadgeVariant(approved: boolean | null) {
if (approved) {
return 'success' as const
}
return 'warning' as const
}
export function CommentsPage() {
const [comments, setComments] = useState<CommentRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [actingId, setActingId] = useState<number | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [approvalFilter, setApprovalFilter] = useState('pending')
const [scopeFilter, setScopeFilter] = useState('all')
const loadComments = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listComments()
startTransition(() => {
setComments(next)
})
if (showToast) {
toast.success('Comments refreshed.')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load comments.')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadComments(false)
}, [loadComments])
const filteredComments = useMemo(() => {
return comments.filter((comment) => {
const matchesSearch =
!searchTerm ||
[
comment.author ?? '',
comment.post_slug ?? '',
comment.content ?? '',
comment.paragraph_excerpt ?? '',
comment.paragraph_key ?? '',
]
.join('\n')
.toLowerCase()
.includes(searchTerm.toLowerCase())
const matchesApproval =
approvalFilter === 'all' ||
(approvalFilter === 'approved' && Boolean(comment.approved)) ||
(approvalFilter === 'pending' && !comment.approved)
const matchesScope = scopeFilter === 'all' || comment.scope === scopeFilter
return matchesSearch && matchesApproval && matchesScope
})
}, [approvalFilter, comments, scopeFilter, searchTerm])
const pendingCount = useMemo(
() => comments.filter((comment) => !comment.approved).length,
[comments],
)
const paragraphCount = useMemo(
() => comments.filter((comment) => comment.scope === 'paragraph').length,
[comments],
)
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary">Comments</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Moderation queue</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Review article comments and paragraph-specific responses from one place, with fast
approval controls for the public discussion layer.
</p>
</div>
</div>
<Button variant="secondary" onClick={() => void loadComments(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Pending</p>
<div className="mt-3 text-3xl font-semibold">{pendingCount}</div>
<p className="mt-2 text-sm text-muted-foreground">Needs moderation attention.</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Paragraph replies
</p>
<div className="mt-3 text-3xl font-semibold">{paragraphCount}</div>
<p className="mt-2 text-sm text-muted-foreground">Scoped to paragraph anchors.</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Total</p>
<div className="mt-3 text-3xl font-semibold">{comments.length}</div>
<p className="mt-2 text-sm text-muted-foreground">Everything currently stored.</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Comment list</CardTitle>
<CardDescription>
Filter the queue, then approve, hide, or remove entries without leaving the page.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr_0.6fr]">
<Input
placeholder="Search by author, post slug, content, or paragraph key"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select
value={approvalFilter}
onChange={(event) => setApprovalFilter(event.target.value)}
>
<option value="all">All approval states</option>
<option value="pending">Pending only</option>
<option value="approved">Approved only</option>
</Select>
<Select value={scopeFilter} onChange={(event) => setScopeFilter(event.target.value)}>
<option value="all">All scopes</option>
<option value="article">Article</option>
<option value="paragraph">Paragraph</option>
</Select>
</div>
{loading ? (
<Skeleton className="h-[680px] rounded-3xl" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Comment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Context</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredComments.map((comment) => (
<TableRow key={comment.id}>
<TableCell>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{comment.author ?? 'Anonymous'}</span>
<Badge variant="outline">{comment.scope}</Badge>
<span className="text-xs text-muted-foreground">
{formatDateTime(comment.created_at)}
</span>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{comment.content ?? 'No content provided.'}
</p>
{comment.scope === 'paragraph' ? (
<div className="rounded-2xl border border-border/70 bg-background/60 px-3 py-2 text-xs text-muted-foreground">
<p className="font-mono">{comment.paragraph_key ?? 'missing-key'}</p>
<p className="mt-1">
{comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'}
</p>
</div>
) : null}
</div>
</TableCell>
<TableCell>
<Badge variant={moderationBadgeVariant(comment.approved)}>
{comment.approved ? 'Approved' : 'Pending'}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm text-muted-foreground">
<p className="font-mono text-xs">{comment.post_slug ?? 'unknown-post'}</p>
{comment.reply_to_comment_id ? (
<p>Replying to #{comment.reply_to_comment_id}</p>
) : (
<p>Top-level comment</p>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={actingId === comment.id || Boolean(comment.approved)}
onClick={async () => {
try {
setActingId(comment.id)
await adminApi.updateComment(comment.id, { approved: true })
toast.success('Comment approved.')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to approve comment.',
)
} finally {
setActingId(null)
}
}}
>
<CheckCheck className="h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="ghost"
disabled={actingId === comment.id || !comment.approved}
onClick={async () => {
try {
setActingId(comment.id)
await adminApi.updateComment(comment.id, { approved: false })
toast.success('Comment moved back to pending.')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to update comment.',
)
} finally {
setActingId(null)
}
}}
>
<XCircle className="h-4 w-4" />
Hide
</Button>
<Button
size="sm"
variant="danger"
disabled={actingId === comment.id}
onClick={async () => {
if (!window.confirm('Delete this comment permanently?')) {
return
}
try {
setActingId(comment.id)
await adminApi.deleteComment(comment.id)
toast.success('Comment deleted.')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to delete comment.',
)
} finally {
setActingId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
{!filteredComments.length ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<MessageSquareText className="h-8 w-8" />
<p>No comments match the current moderation filters.</p>
</div>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}