Files
reader/app/mod/de-cu/recommendation-client.tsx

366 lines
14 KiB
TypeScript

"use client"
import Link from "next/link"
import { FormEvent, useEffect, useMemo, useState } from "react"
import { Loader2, Search, Star, Trash2, UserRoundCheck } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type NovelLite = {
id: string
title: string
slug: string
authorName: string
coverUrl: string | null
status: string
totalChapters: number
}
type RecommendationItem = {
id: string
createdAt: string | null
recommendCount: number
novel: NovelLite
editor: {
id: string
name: string
}
}
type SummaryItem = {
novel: NovelLite
recommendCount: number
}
type CandidateNovel = NovelLite & {
alreadyRecommended: boolean
recommendCount: number
}
type RecommendationResponse = {
items: RecommendationItem[]
summary: SummaryItem[]
candidates: CandidateNovel[]
myNovelIds: string[]
currentUser: {
id: string
role: string
recommendationCount: number
maxRecommendationCount: number
}
}
function formatRelativeTime(value: string | null): string {
if (!value) return "Vừa đề cử"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "Vừa đề cử"
const diff = Date.now() - date.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return "Vừa xong"
if (diff < hour) return `${Math.floor(diff / minute)} phút trước`
if (diff < day) return `${Math.floor(diff / hour)} giờ trước`
if (diff < day * 30) return `${Math.floor(diff / day)} ngày trước`
return date.toLocaleDateString("vi-VN")
}
export function RecommendationClient() {
const [loading, setLoading] = useState(true)
const [searching, setSearching] = useState(false)
const [submittingNovelId, setSubmittingNovelId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [keyword, setKeyword] = useState("")
const [activeQuery, setActiveQuery] = useState("")
const [items, setItems] = useState<RecommendationItem[]>([])
const [summary, setSummary] = useState<SummaryItem[]>([])
const [candidates, setCandidates] = useState<CandidateNovel[]>([])
const [currentUser, setCurrentUser] = useState<{
id: string
role: string
recommendationCount: number
maxRecommendationCount: number
} | null>(null)
const fetchData = async (query: string, initial = false) => {
if (initial) {
setLoading(true)
} else {
setSearching(true)
}
try {
const url = query.trim() ? `/api/mod/de-cu?q=${encodeURIComponent(query.trim())}` : "/api/mod/de-cu"
const res = await fetch(url)
const data = (await res.json()) as RecommendationResponse & { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể tải dữ liệu đề cử")
}
setItems(data.items || [])
setSummary(data.summary || [])
setCandidates(data.candidates || [])
setCurrentUser(data.currentUser || null)
setActiveQuery(query.trim())
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể tải dữ liệu đề cử"
toast.error(message)
} finally {
setLoading(false)
setSearching(false)
}
}
useEffect(() => {
fetchData("", true)
}, [])
const handleSearch = async (e: FormEvent) => {
e.preventDefault()
await fetchData(keyword)
}
const handleRecommend = async (novelId: string) => {
setSubmittingNovelId(novelId)
try {
const res = await fetch("/api/mod/de-cu", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể thêm đề cử")
}
toast.success("Đã thêm đề cử")
await fetchData(activeQuery)
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể thêm đề cử"
toast.error(message)
} finally {
setSubmittingNovelId(null)
}
}
const handleDelete = async (id: string) => {
if (!confirm("Bạn có chắc muốn xóa đề cử này?")) return
setDeletingId(id)
try {
const res = await fetch(`/api/mod/de-cu?id=${encodeURIComponent(id)}`, { method: "DELETE" })
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể xóa đề cử")
}
toast.success("Đã xóa đề cử")
await fetchData(activeQuery)
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể xóa đề cử"
toast.error(message)
} finally {
setDeletingId(null)
}
}
const canDelete = (item: RecommendationItem) => {
if (!currentUser) return false
return currentUser.role === "ADMIN" || currentUser.id === item.editor.id
}
const rankedSummary = useMemo(() => summary.slice(0, 12), [summary])
const hasReachedLimit =
Boolean(currentUser) &&
(currentUser?.recommendationCount || 0) >= (currentUser?.maxRecommendationCount || 0)
return (
<div className="space-y-6">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<UserRoundCheck className="h-6 w-6 text-primary" /> Quản đ cử biên tập
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Mỗi biên tập viên thể đ cử truyện thủ công. Trang chủ sẽ lấy dữ liệu từ danh sách này.
</p>
{currentUser && (
<p className="mt-2 text-xs text-primary">
Bạn đang dùng {currentUser.recommendationCount}/{currentUser.maxRecommendationCount} đ cử.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="space-y-4 xl:col-span-1">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h2 className="mb-3 text-base font-semibold">Tìm truyện đ đ cử</h2>
<form onSubmit={handleSearch} className="flex gap-2">
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Nhập tên truyện, tác giả hoặc slug"
/>
<Button type="submit" variant="outline" className="gap-2" disabled={searching}>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Tìm
</Button>
</form>
<div className="mt-3 space-y-2">
{searching ? (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Đang tìm...
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">Nhập từ khóa rồi bấm tìm đ hiện danh sách truyện.</p>
) : (
candidates.map((novel) => (
<div key={novel.id} className="rounded-lg border border-border bg-background/60 p-2.5">
<div className="flex items-start gap-2">
<img
src={novel.coverUrl || "/default-cover.svg"}
alt={novel.title}
className="h-14 w-10 rounded border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<Link href={`/truyen/${novel.slug}`} className="line-clamp-1 text-sm font-semibold hover:text-primary">
{novel.title}
</Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<p className="text-xs text-muted-foreground">{novel.recommendCount} đ cử</p>
</div>
</div>
<Button
type="button"
size="sm"
className="mt-2 w-full gap-2"
disabled={
novel.alreadyRecommended ||
submittingNovelId === novel.id ||
(hasReachedLimit && !novel.alreadyRecommended)
}
onClick={() => handleRecommend(novel.id)}
>
{submittingNovelId === novel.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Star className="h-4 w-4" />}
{novel.alreadyRecommended
? "Bạn đã đề cử"
: hasReachedLimit
? "Đã đạt giới hạn 5 truyện"
: "Đề cử truyện này"}
</Button>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h2 className="mb-3 text-base font-semibold">Top theo số lượng đ cử</h2>
<div className="space-y-2">
{rankedSummary.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa dữ liệu đ cử.</p>
) : (
rankedSummary.map((item, index) => (
<Link
key={item.novel.id}
href={`/truyen/${item.novel.slug}`}
className="flex items-center gap-2 rounded-lg border border-border bg-background/60 p-2.5 hover:border-primary/40"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{index + 1}
</span>
<img
src={item.novel.coverUrl || "/default-cover.svg"}
alt={item.novel.title}
className="h-12 w-9 rounded border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<p className="line-clamp-1 text-sm font-medium">{item.novel.title}</p>
<p className="text-xs text-muted-foreground">{item.recommendCount} đ cử</p>
</div>
</Link>
))
)}
</div>
</div>
</div>
<div className="rounded-xl border bg-card shadow-sm xl:col-span-2">
<div className="border-b border-border p-4">
<h2 className="text-base font-semibold">Danh sách đ cử hiện tại</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-4 py-3 font-semibold">Truyện</th>
<th className="px-4 py-3 font-semibold">Biên tập viên</th>
<th className="px-4 py-3 font-semibold">Tổng đ cử</th>
<th className="px-4 py-3 font-semibold">Thời gian</th>
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Chưa đ cử nào
</td>
</tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link href={`/truyen/${item.novel.slug}`} className="font-medium hover:text-primary">
{item.novel.title}
</Link>
<p className="text-xs text-muted-foreground">{item.novel.authorName}</p>
</td>
<td className="px-4 py-3">{item.editor.name}</td>
<td className="px-4 py-3">{item.recommendCount}</td>
<td className="px-4 py-3 text-muted-foreground">{formatRelativeTime(item.createdAt)}</td>
<td className="px-4 py-3 text-right">
{canDelete(item) ? (
<Button
variant="outline"
size="sm"
className="gap-1.5 text-red-600 border-red-200 hover:bg-red-50"
onClick={() => handleDelete(item.id)}
disabled={deletingId === item.id}
>
{deletingId === item.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
Xóa
</Button>
) : (
<span className="text-xs text-muted-foreground">Không quyền xóa</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}