Add moderation APIs and admin UI
Add moderator/admin backend APIs and client features for managing novels and chapters. New endpoints include mod chapter routes (paginated list, single GET, PUT, DELETE, and bulk optimize), mod novel routes (create, GET by id, update, delete), genre CRUD, user bookmarks, novel comments, and rating endpoints. Update EPUB import to use a shared slugify util. Enhance moderator UI: chapter manager gains pagination, bulk optimization preview/apply, edit/delete dialogs; novel client adds genre management and edit/delete flows. Also update Prisma schema, add a DB wipe script, remove unused lib/data.ts, and adjust related types/utils and bookmark context.
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { FileText, Loader2, Plus, ArrowLeft } from "lucide-react"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -26,6 +26,19 @@ interface Chapter {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const generatePagination = (currentPage: number, totalPages: number) => {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, '...', totalPages]
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
|
||||
}
|
||||
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages]
|
||||
}
|
||||
|
||||
function ChapterManager() {
|
||||
const searchParams = useSearchParams()
|
||||
const novelId = searchParams.get("novelId")
|
||||
@@ -35,20 +48,47 @@ function ChapterManager() {
|
||||
const [openAdd, setOpenAdd] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalChapters, setTotalChapters] = useState(0)
|
||||
|
||||
// Optimization states
|
||||
const [openOptimize, setOpenOptimize] = useState(false)
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
||||
const [optRenumber, setOptRenumber] = useState(true)
|
||||
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
const [editingChapterId, setEditingChapterId] = useState<string | null>(null)
|
||||
const [loadingEditData, setLoadingEditData] = useState(false)
|
||||
|
||||
// Delete states
|
||||
const [openDelete, setOpenDelete] = useState(false)
|
||||
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
|
||||
|
||||
// Form states
|
||||
const [number, setNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
const fetchChapters = async () => {
|
||||
const fetchChapters = async (pageToFetch = 1) => {
|
||||
if (!novelId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/mod/chuong?novelId=${novelId}`)
|
||||
const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${pageToFetch}&limit=50`)
|
||||
if (!res.ok) throw new Error("Lỗi fetch")
|
||||
const data = await res.json()
|
||||
setChapters(data)
|
||||
if (data.length > 0) {
|
||||
setNumber((data[data.length - 1].number + 1).toString())
|
||||
setChapters(data.chapters)
|
||||
setTotalPages(data.totalPages)
|
||||
setCurrentPage(data.currentPage)
|
||||
setTotalChapters(data.totalChapters)
|
||||
|
||||
if (data.chapters.length > 0) {
|
||||
setNumber((data.chapters[data.chapters.length - 1].number + 1).toString())
|
||||
} else {
|
||||
setNumber("1")
|
||||
}
|
||||
@@ -60,8 +100,10 @@ function ChapterManager() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchChapters()
|
||||
}, [novelId])
|
||||
if (novelId) {
|
||||
fetchChapters(currentPage)
|
||||
}
|
||||
}, [novelId, currentPage])
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -94,6 +136,135 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewOptimize = () => {
|
||||
let newChapters = [...chapters]
|
||||
|
||||
if (optRenumber) {
|
||||
newChapters.sort((a, b) => a.number - b.number)
|
||||
newChapters = newChapters.map((ch, idx) => ({
|
||||
...ch,
|
||||
number: idx + 1
|
||||
}))
|
||||
}
|
||||
|
||||
if (optRemovePrefix) {
|
||||
newChapters = newChapters.map((ch, i) => {
|
||||
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
||||
if (!newTitle) newTitle = `Chương ${ch.number}`
|
||||
return { ...ch, title: newTitle }
|
||||
})
|
||||
}
|
||||
|
||||
setOptimizedChapters(newChapters)
|
||||
setPreviewMode(true)
|
||||
}
|
||||
|
||||
const handleApplyOptimize = async () => {
|
||||
if (optimizedChapters.length === 0) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const updates = optimizedChapters.map(ch => ({
|
||||
id: ch._id,
|
||||
title: ch.title,
|
||||
number: ch.number
|
||||
}))
|
||||
|
||||
const res = await fetch("/api/mod/chuong/optimize", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ novelId, updates }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa")
|
||||
|
||||
toast.success(`Đã tổi ưu ${data.modifiedCount} chương!`)
|
||||
setOpenOptimize(false)
|
||||
setPreviewMode(false)
|
||||
fetchChapters(currentPage)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (chapter: Chapter) => {
|
||||
setEditingChapterId(chapter._id)
|
||||
setNumber(chapter.number.toString())
|
||||
setTitle(chapter.title)
|
||||
setContent("") // Khởi tạo rỗng trong lúc chờ fetch nội dung
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
try {
|
||||
// Lấy chi tiết chương từ list db để có nội dung qua API GET /api/mod/chuong/[id] vừa tạo
|
||||
const res = await fetch(`/api/mod/chuong/${chapter._id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data.content)
|
||||
} else {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
} finally {
|
||||
setLoadingEditData(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!number || !title || !content || !novelId || !editingChapterId) {
|
||||
toast.error("Vui lòng điền đầy đủ")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: editingChapterId, novelId, number: parseInt(number), title, content }),
|
||||
})
|
||||
|
||||
const resData = await res.json()
|
||||
if (!res.ok) throw new Error(resData.error || "Cập nhật thất bại")
|
||||
|
||||
toast.success("Đã cập nhật chương thành công!")
|
||||
setOpenEdit(false)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingChapterId || !novelId) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/mod/chuong?id=${deletingChapterId}&novelId=${novelId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Xóa thất bại")
|
||||
}
|
||||
toast.success("Đã xóa chương thành công")
|
||||
setOpenDelete(false)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (!novelId) {
|
||||
return (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
@@ -113,52 +284,197 @@ function ChapterManager() {
|
||||
<FileText className="h-6 w-6 text-primary" /> Quản lý chương
|
||||
</h1>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Đăng chương mới
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Đăng Chương Mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thêm nội dung một chương truyện để gửi đến độc giả.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="gap-2" onClick={() => {
|
||||
setOpenOptimize(true)
|
||||
setPreviewMode(false)
|
||||
}} disabled={chapters.length === 0}>
|
||||
<Wand2 className="h-4 w-4" /> Tối ưu hóa
|
||||
</Button>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Đăng chương mới
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Đăng Chương Mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thêm nội dung một chương truyện để gửi đến độc giả.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
placeholder="Paste văn bản của chương vào đây..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
placeholder="Paste văn bản của chương vào đây..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Đăng ngay
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chỉnh Sửa Chương</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thay đổi nội dung hoặc thông tin chương truyện.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingEditData ? (
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEditSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive">Xác nhận xóa chương</DialogTitle>
|
||||
<DialogDescription>
|
||||
Hành động này không thể hoàn tác. Chương này sẽ bị xóa vĩnh viễn khỏi cơ sở dữ liệu.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setOpenDelete(false)}>Hủy bỏ</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Đăng ngay
|
||||
Tiếp tục xóa
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openOptimize} onOpenChange={setOpenOptimize}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tối Ưu Hóa Chương Hàng Loạt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Công cụ dọn dẹp tên chương và đánh lại số thứ tự tự động tiện lợi sau khi đăng ép từ tệp EPUB.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!previewMode ? (
|
||||
<div className="flex flex-col gap-4 py-4 flex-1">
|
||||
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" checked={optRemovePrefix} onChange={(e) => setOptRemovePrefix(e.target.checked)} />
|
||||
<div>
|
||||
<p className="font-medium text-base">Xóa tiền tố "Chương X:" dư thừa</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Ví dụ: <span className="line-through">Chương 1: Bắt đầu</span> sẽ thành <strong>Bắt đầu</strong></p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" checked={optRenumber} onChange={(e) => setOptRenumber(e.target.checked)} />
|
||||
<div>
|
||||
<p className="font-medium text-base">Đánh lại số thứ tự tự động</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Sắp xếp và gán lại số chương liên tục từ 1 đến N để sửa lỗi nhảy cóc</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto border rounded-lg my-4 custom-scrollbar">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted sticky top-0 shadow-sm">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-1/2 border-r">Nội dung gốc (Hiện tại)</th>
|
||||
<th className="px-4 py-3 w-1/2">Xem trước kết quả (Mới)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{optimizedChapters.map((newCh, i) => {
|
||||
const oldCh = chapters[i]
|
||||
return (
|
||||
<tr key={newCh._id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-3 border-r text-muted-foreground">
|
||||
<span className="font-mono text-xs mr-2 inline-block w-8 text-right">#{oldCh.number}</span>
|
||||
<span className={oldCh.title !== newCh.title ? "line-through opacity-70" : ""}>{oldCh.title}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-foreground font-medium">
|
||||
<span className="font-mono text-xs mr-2 text-primary inline-block w-8 text-right">#{newCh.number}</span>
|
||||
<span className={oldCh.title !== newCh.title ? "text-primary" : ""}>{newCh.title}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-auto pt-2">
|
||||
<Button variant="outline" onClick={() => setOpenOptimize(false)}>Hủy bỏ</Button>
|
||||
{!previewMode ? (
|
||||
<Button onClick={handlePreviewOptimize}>Kiểm tra trước</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing}>Quay lại Option</Button>
|
||||
<Button onClick={handleApplyOptimize} disabled={optimizing}>
|
||||
{optimizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi vào DB
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card overflow-hidden shadow-sm">
|
||||
<div className="rounded-xl border bg-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
|
||||
@@ -180,8 +496,18 @@ function ChapterManager() {
|
||||
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
|
||||
<td className="px-5 py-4 text-right">{ch.views}</td>
|
||||
<td className="px-5 py-4 text-right space-x-3">
|
||||
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa nội dung</button>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50" onClick={() => handleOpenEdit(ch)}>
|
||||
<Edit className="w-4 h-4 mr-1" /> Sửa
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
|
||||
setDeletingChapterId(ch._id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4 mr-1" /> Xóa
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@@ -189,6 +515,51 @@ function ChapterManager() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t p-4 flex flex-col sm:flex-row items-center justify-between gap-4 bg-muted/20">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Trang <span className="font-medium text-foreground">{currentPage}</span> / {totalPages} (Tổng {totalChapters} chương)
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1 || loading}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
|
||||
{generatePagination(currentPage, totalPages).map((p, i) => (
|
||||
<div key={i} className="hidden sm:block">
|
||||
{p === '...' ? (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === p ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
disabled={loading}
|
||||
onClick={() => setCurrentPage(p as number)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { BookOpen, Loader2, Plus, Upload } from "lucide-react"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -26,6 +26,11 @@ interface Novel {
|
||||
totalChapters: number
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function NovelClient() {
|
||||
const [novels, setNovels] = useState<Novel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -37,6 +42,22 @@ export function NovelClient() {
|
||||
const [title, setTitle] = useState("")
|
||||
const [authorName, setAuthorName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [status, setStatus] = useState("Đang ra")
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
const [editingNovel, setEditingNovel] = useState<Novel | null>(null)
|
||||
const [loadingEditData, setLoadingEditData] = useState(false)
|
||||
|
||||
// Genre states
|
||||
const [genres, setGenres] = useState<Genre[]>([])
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
|
||||
const [newGenreName, setNewGenreName] = useState("")
|
||||
const [addingGenre, setAddingGenre] = useState(false)
|
||||
|
||||
// Delete states
|
||||
const [openDelete, setOpenDelete] = useState(false)
|
||||
const [deletingNovelId, setDeletingNovelId] = useState<string | null>(null)
|
||||
|
||||
const fetchNovels = async () => {
|
||||
try {
|
||||
@@ -51,10 +72,72 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGenres = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/mod/the-loai")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setGenres(data)
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch genres")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchNovels()
|
||||
fetchGenres()
|
||||
}, [])
|
||||
|
||||
const toggleGenre = (id: string) => {
|
||||
setSelectedGenres(prev =>
|
||||
prev.includes(id) ? prev.filter(gId => gId !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddGenre = async () => {
|
||||
if (!newGenreName.trim()) return
|
||||
setAddingGenre(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/the-loai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newGenreName, description: "" })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Thêm lỗi")
|
||||
|
||||
toast.success("Thêm thể loại thành công")
|
||||
setNewGenreName("")
|
||||
fetchGenres()
|
||||
setSelectedGenres(prev => [...prev, data.id])
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setAddingGenre(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGenre = async (id: string, name: string) => {
|
||||
if (!confirm(`Bạn có chắc muốn xóa thể loại "${name}" khỏi hệ thống?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mod/the-loai?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Xóa lỗi")
|
||||
}
|
||||
toast.success("Đã xóa thể loại thành công")
|
||||
fetchGenres()
|
||||
// Clean up from selected lists
|
||||
setSelectedGenres(prev => prev.filter(gId => gId !== id))
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title || !authorName || !description) {
|
||||
@@ -67,7 +150,7 @@ export function NovelClient() {
|
||||
const res = await fetch("/api/mod/truyen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, authorName, description }),
|
||||
body: JSON.stringify({ title, authorName, description, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
|
||||
})
|
||||
if (!res.ok) throw new Error("Thêm mới thất bại")
|
||||
toast.success("Đã thêm truyện thành công!")
|
||||
@@ -75,6 +158,8 @@ export function NovelClient() {
|
||||
setTitle("")
|
||||
setAuthorName("")
|
||||
setDescription("")
|
||||
setStatus("Đang ra")
|
||||
setSelectedGenres([])
|
||||
fetchNovels()
|
||||
} catch {
|
||||
toast.error("Lỗi khi thêm truyện mới")
|
||||
@@ -118,6 +203,93 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (novel: Novel) => {
|
||||
setEditingNovel(novel)
|
||||
setTitle(novel.title)
|
||||
setAuthorName(novel.authorName)
|
||||
setStatus(novel.status)
|
||||
setDescription("")
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mod/truyen/${novel.id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDescription(data.description || "")
|
||||
if (data.genres && Array.isArray(data.genres)) {
|
||||
setSelectedGenres(data.genres.map((g: any) => g.genreId))
|
||||
} else {
|
||||
setSelectedGenres([])
|
||||
}
|
||||
} else {
|
||||
toast.error("Không tải được chi tiết truyện")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Không tải được chi tiết truyện")
|
||||
} finally {
|
||||
setLoadingEditData(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingNovel || !title || !authorName) {
|
||||
toast.error("Vui lòng nhập tên truyện và tác giả")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/truyen", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: editingNovel.id,
|
||||
title,
|
||||
authorName,
|
||||
description,
|
||||
genreIds: selectedGenres,
|
||||
status: status
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi cập nhật")
|
||||
|
||||
toast.success("Cập nhật truyện thành công!")
|
||||
setOpenEdit(false)
|
||||
fetchNovels()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSubmit = async () => {
|
||||
if (!deletingNovelId) return
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mod/truyen?id=${deletingNovelId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Xóa thất bại")
|
||||
}
|
||||
toast.success("Đã xóa truyện thành công")
|
||||
setOpenDelete(false)
|
||||
fetchNovels()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
|
||||
@@ -144,13 +316,18 @@ export function NovelClient() {
|
||||
Tải lên EPUB
|
||||
</Button>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<Dialog open={openAdd} onOpenChange={(val) => {
|
||||
setOpenAdd(val);
|
||||
if (val) {
|
||||
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName("");
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Thêm truyện
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Thêm Truyện Mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -166,6 +343,38 @@ export function NovelClient() {
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Ví dụ: Vong Ngữ" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thêm thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Tên thể loại mới..."
|
||||
value={newGenreName}
|
||||
onChange={(e) => setNewGenreName(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddGenre(); } }}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !newGenreName.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : "Tạo"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto p-2 border rounded-md custom-scrollbar bg-card">
|
||||
{genres.map(genre => (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border transition-colors select-none ${selectedGenres.includes(genre.id) ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
<span className="cursor-pointer" onClick={() => toggleGenre(genre.id)}>{genre.name}</span>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5 ml-1 inline-flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteGenre(genre.id, genre.name); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{genres.length === 0 && <span className="text-xs text-muted-foreground p-1">Chưa có thể loại nào</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Giới thiệu ngắn (Mô tả)</label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Tóm tắt về câu chuyện..." rows={4} />
|
||||
@@ -179,6 +388,112 @@ export function NovelClient() {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
|
||||
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chỉnh Sửa Truyện</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cập nhật thông tin cho tác phẩm của bạn.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingEditData ? (
|
||||
<div className="flex-1 flex justify-center items-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tên truyện</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cập nhật thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Tên thể loại mới..."
|
||||
value={newGenreName}
|
||||
onChange={(e) => setNewGenreName(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddGenre(); } }}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !newGenreName.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : "Tạo"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto p-2 border rounded-md custom-scrollbar bg-card">
|
||||
{genres.map(genre => (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border transition-colors select-none ${selectedGenres.includes(genre.id) ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
<span className="cursor-pointer" onClick={() => toggleGenre(genre.id)}>{genre.name}</span>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5 ml-1 inline-flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteGenre(genre.id, genre.name); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Trạng thái</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="Đang ra">Đang ra</option>
|
||||
<option value="Hoàn thành">Hoàn thành</option>
|
||||
<option value="Tạm dừng">Tạm dừng</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Giới thiệu ngắn (Mô tả mới)</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Để trống nếu không muốn thay đổi..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu Thay Đổi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive">Xác nhận xóa truyện</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bạn có chắc chắn muốn xóa bộ truyện này? Hành động này sẽ ẩn đầu truyện khỏi hệ thống.
|
||||
<br /><br />
|
||||
Lưu ý: Các chương liên quan (trong MongoDB) sẽ cần được dọn dẹp riêng.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setOpenDelete(false)}>Hủy bỏ</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Tiếp tục xóa
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,11 +529,23 @@ export function NovelClient() {
|
||||
{novel.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right space-x-3">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`} className="font-medium text-blue-500 hover:text-blue-600 hover:underline">
|
||||
Đăng chương
|
||||
</Link>
|
||||
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa</button>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
Cập nhật chương
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50" onClick={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user