Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -39,6 +39,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
|
||||
// Core states
|
||||
const [number, setNumber] = useState("")
|
||||
const [volumeNumber, setVolumeNumber] = useState("")
|
||||
const [volumeTitle, setVolumeTitle] = useState("")
|
||||
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalNovelId, setOriginalNovelId] = useState("")
|
||||
@@ -77,6 +80,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
const data = await res.json()
|
||||
|
||||
setNumber(data.number.toString())
|
||||
setVolumeNumber(data.volumeNumber ? String(data.volumeNumber) : "")
|
||||
setVolumeTitle(data.volumeTitle || "")
|
||||
setVolumeChapterNumber(data.volumeChapterNumber ? String(data.volumeChapterNumber) : "")
|
||||
setTitle(data.title)
|
||||
setContent(data.content)
|
||||
setOriginalNovelId(data.novelId)
|
||||
@@ -189,6 +195,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
id: chapterId,
|
||||
novelId: originalNovelId,
|
||||
number: parseInt(number),
|
||||
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
|
||||
volumeTitle: volumeTitle.trim() || null,
|
||||
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
|
||||
title,
|
||||
content
|
||||
})
|
||||
@@ -477,12 +486,26 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
|
||||
{/* Editor Workspace */}
|
||||
<div className="flex flex-col flex-1 pb-4 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 shrink-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4 mb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Quyển số</label>
|
||||
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} className="font-mono" placeholder="VD: 1" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương trong quyển</label>
|
||||
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} className="font-mono" placeholder="VD: 3" />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên quyển</label>
|
||||
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 mb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,9 @@ import * as mammoth from "mammoth"
|
||||
interface Chapter {
|
||||
_id: string
|
||||
number: number
|
||||
volumeNumber?: number | null
|
||||
volumeTitle?: string | null
|
||||
volumeChapterNumber?: number | null
|
||||
title: string
|
||||
views: number
|
||||
createdAt: string
|
||||
@@ -60,9 +63,11 @@ function ChapterManager() {
|
||||
const [openOptimize, setOpenOptimize] = useState(false)
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
|
||||
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
||||
const [optRenumber, setOptRenumber] = useState(true)
|
||||
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
||||
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
@@ -76,6 +81,9 @@ function ChapterManager() {
|
||||
|
||||
// Form states
|
||||
const [number, setNumber] = useState("")
|
||||
const [volumeNumber, setVolumeNumber] = useState("")
|
||||
const [volumeTitle, setVolumeTitle] = useState("")
|
||||
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
@@ -115,6 +123,29 @@ function ChapterManager() {
|
||||
}
|
||||
}, [novelId, currentPage])
|
||||
|
||||
const fetchAllChaptersForOptimize = async (): Promise<Chapter[]> => {
|
||||
if (!novelId) return []
|
||||
|
||||
const limit = 200
|
||||
let page = 1
|
||||
let total = 1
|
||||
const all: Chapter[] = []
|
||||
|
||||
while (page <= total) {
|
||||
const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${page}&limit=${limit}`)
|
||||
if (!res.ok) {
|
||||
throw new Error("Không thể tải toàn bộ chương để tối ưu")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
all.push(...(data.chapters || []))
|
||||
total = data.totalPages || 1
|
||||
page++
|
||||
}
|
||||
|
||||
return all.sort((a, b) => a.number - b.number)
|
||||
}
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!number || !title || !content || !novelId) {
|
||||
@@ -127,7 +158,15 @@ function ChapterManager() {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ novelId, number: parseInt(number), title, content }),
|
||||
body: JSON.stringify({
|
||||
novelId,
|
||||
number: parseInt(number),
|
||||
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
|
||||
volumeTitle: volumeTitle.trim() || null,
|
||||
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
|
||||
const resData = await res.json()
|
||||
@@ -137,6 +176,9 @@ function ChapterManager() {
|
||||
setOpenAdd(false)
|
||||
setTitle("")
|
||||
setContent("")
|
||||
setVolumeNumber("")
|
||||
setVolumeTitle("")
|
||||
setVolumeChapterNumber("")
|
||||
setNumber((parseInt(number) + 1).toString())
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
@@ -205,38 +247,73 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewOptimize = () => {
|
||||
let newChapters = [...chapters]
|
||||
const handlePreviewOptimize = async () => {
|
||||
if (!novelId) return
|
||||
|
||||
if (optRenumber) {
|
||||
newChapters.sort((a, b) => a.number - b.number)
|
||||
newChapters = newChapters.map((ch, idx) => ({
|
||||
...ch,
|
||||
number: idx + 1
|
||||
}))
|
||||
if (!optRemovePrefix && !optRenumber) {
|
||||
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
|
||||
return
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
}
|
||||
setLoadingOptimizeSource(true)
|
||||
|
||||
setOptimizedChapters(newChapters)
|
||||
setPreviewMode(true)
|
||||
try {
|
||||
const allChapters = await fetchAllChaptersForOptimize()
|
||||
if (allChapters.length === 0) {
|
||||
toast.info("Truyện này chưa có chương để tối ưu")
|
||||
return
|
||||
}
|
||||
|
||||
setOptimizeSourceChapters(allChapters)
|
||||
let newChapters = [...allChapters]
|
||||
|
||||
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) => {
|
||||
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)
|
||||
toast.success(`Đã tạo xem trước cho toàn bộ ${newChapters.length} chương`)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể tạo bản xem trước")
|
||||
} finally {
|
||||
setLoadingOptimizeSource(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 sourceById = new Map(optimizeSourceChapters.map((ch) => [ch._id, ch]))
|
||||
const updates = optimizedChapters
|
||||
.filter((ch) => {
|
||||
const old = sourceById.get(ch._id)
|
||||
return !!old && (old.number !== ch.number || old.title !== ch.title)
|
||||
})
|
||||
.map((ch) => ({
|
||||
id: ch._id,
|
||||
title: ch.title,
|
||||
number: ch.number
|
||||
}))
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info("Không có thay đổi nào cần lưu")
|
||||
setOptimizing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch("/api/mod/chuong/optimize", {
|
||||
method: "PUT",
|
||||
@@ -247,9 +324,11 @@ function ChapterManager() {
|
||||
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!`)
|
||||
toast.success(`Đã tối ưu ${data.modifiedCount} chương trên toàn bộ truyện!`)
|
||||
setOpenOptimize(false)
|
||||
setPreviewMode(false)
|
||||
setOptimizedChapters([])
|
||||
setOptimizeSourceChapters([])
|
||||
fetchChapters(currentPage)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
@@ -298,6 +377,8 @@ function ChapterManager() {
|
||||
)
|
||||
}
|
||||
|
||||
const optimizeSourceMap = new Map(optimizeSourceChapters.map((source) => [source._id, source]))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
|
||||
@@ -310,6 +391,8 @@ function ChapterManager() {
|
||||
<Button variant="secondary" className="gap-2" onClick={() => {
|
||||
setOpenOptimize(true)
|
||||
setPreviewMode(false)
|
||||
setOptimizedChapters([])
|
||||
setOptimizeSourceChapters([])
|
||||
}} disabled={chapters.length === 0}>
|
||||
<Wand2 className="h-4 w-4" /> Tối ưu hóa
|
||||
</Button>
|
||||
@@ -341,12 +424,26 @@ function ChapterManager() {
|
||||
</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="grid grid-cols-6 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-1">
|
||||
<label className="text-sm font-medium">Quyển số</label>
|
||||
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương trong quyển</label>
|
||||
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} placeholder="VD: 5" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên quyển (Tuỳ chọn)</label>
|
||||
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-4">
|
||||
<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>
|
||||
@@ -389,12 +486,23 @@ function ChapterManager() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openOptimize} onOpenChange={setOpenOptimize}>
|
||||
<Dialog
|
||||
open={openOptimize}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpenOptimize(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setPreviewMode(false)
|
||||
setOptimizedChapters([])
|
||||
setOptimizeSourceChapters([])
|
||||
setLoadingOptimizeSource(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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.
|
||||
Công cụ sẽ áp dụng trên toàn bộ chương của truyện hiện tại, không chỉ page bạn đang xem.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -425,8 +533,8 @@ function ChapterManager() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{optimizedChapters.map((newCh, i) => {
|
||||
const oldCh = chapters[i]
|
||||
{optimizedChapters.map((newCh) => {
|
||||
const oldCh = optimizeSourceMap.get(newCh._id) || newCh
|
||||
return (
|
||||
<tr key={newCh._id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-3 border-r text-muted-foreground">
|
||||
@@ -448,10 +556,13 @@ function ChapterManager() {
|
||||
<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 onClick={handlePreviewOptimize} disabled={loadingOptimizeSource}>
|
||||
{loadingOptimizeSource && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Kiểm tra toàn bộ truyện
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing}>Quay lại Option</Button>
|
||||
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing || loadingOptimizeSource}>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
|
||||
@@ -471,6 +582,7 @@ function ChapterManager() {
|
||||
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th scope="col" className="px-5 py-4 font-semibold w-24">Chương</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Quyển</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Tên chương</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold text-right">Lượt đọc</th>
|
||||
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao tác</th>
|
||||
@@ -478,13 +590,18 @@ function ChapterManager() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
|
||||
) : chapters.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground">Chưa có chương nào được đăng.</td></tr>
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa có chương nào được đăng.</td></tr>
|
||||
) : (
|
||||
chapters.map((ch) => (
|
||||
<tr key={ch._id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0">
|
||||
<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.volumeNumber || ch.volumeTitle
|
||||
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
|
||||
: "-"}
|
||||
</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">
|
||||
|
||||
+1
-4
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Users, List, Home } from "lucide-react"
|
||||
import { BookOpen, Home } from "lucide-react"
|
||||
|
||||
export default async function ModLayout({
|
||||
children,
|
||||
@@ -28,9 +28,6 @@ export default async function ModLayout({
|
||||
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
|
||||
<BookOpen className="h-4 w-4" /> Quản lý truyện
|
||||
</Link>
|
||||
<Link href="/mod/chuong" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
|
||||
<List className="h-4 w-4" /> Quản lý chương
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
+45
-5
@@ -1,9 +1,45 @@
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export default async function ModDashboardPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const novelWhere = session?.user.role === "ADMIN"
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ uploaderId: session?.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
}
|
||||
|
||||
const [novelCount, novelViewsAgg, commentCount, seriesCount] = await Promise.all([
|
||||
prisma.novel.count({ where: novelWhere }),
|
||||
prisma.novel.aggregate({
|
||||
where: novelWhere,
|
||||
_sum: { views: true },
|
||||
}),
|
||||
prisma.comment.count({
|
||||
where: {
|
||||
novel: novelWhere,
|
||||
},
|
||||
}),
|
||||
prisma.series.count({
|
||||
where: session?.user.role === "ADMIN"
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ novels: { some: { uploaderId: session?.user.id } } },
|
||||
{ novels: { some: { uploaderId: null } } },
|
||||
{ novels: { none: {} } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const totalViews = novelViewsAgg._sum.views || 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1>
|
||||
@@ -11,18 +47,22 @@ export default async function ModDashboardPage() {
|
||||
Chào mừng bạn đến với trang quản trị dành cho Moderator.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Truyện của bạn</h3>
|
||||
<p className="text-3xl font-bold mt-2">0</p>
|
||||
<h3 className="font-semibold text-lg">Tổng truyện</h3>
|
||||
<p className="text-3xl font-bold mt-2">{novelCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Tổng lượt xem</h3>
|
||||
<p className="text-3xl font-bold mt-2">0</p>
|
||||
<p className="text-3xl font-bold mt-2">{totalViews}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Bình luận mới</h3>
|
||||
<p className="text-3xl font-bold mt-2">0</p>
|
||||
<p className="text-3xl font-bold mt-2">{commentCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Tổng series</h3>
|
||||
<p className="text-3xl font-bold mt-2">{seriesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { SeriesClient } from "./series-client"
|
||||
|
||||
export default async function ModSeriesPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return <SeriesClient />
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Layers, Loader2, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface SeriesItem {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description?: string | null
|
||||
_count?: {
|
||||
novels: number
|
||||
}
|
||||
}
|
||||
|
||||
export function SeriesClient() {
|
||||
const [series, setSeries] = useState<SeriesItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
|
||||
const filteredSeries = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase()
|
||||
if (!q) return series
|
||||
return series.filter((item) => {
|
||||
return (
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.slug.toLowerCase().includes(q) ||
|
||||
(item.description || "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [keyword, series])
|
||||
|
||||
const fetchSeries = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/mod/series")
|
||||
if (!res.ok) throw new Error("Không thể tải danh sách series")
|
||||
const data = await res.json()
|
||||
setSeries(data)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể tải danh sách series")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSeries()
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null)
|
||||
setName("")
|
||||
setDescription("")
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error("Vui lòng nhập tên series")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
id: editingId,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
}
|
||||
|
||||
const res = await fetch("/api/mod/series", {
|
||||
method: editingId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Không thể lưu series")
|
||||
}
|
||||
|
||||
toast.success(editingId ? "Đã cập nhật series" : "Đã tạo series")
|
||||
resetForm()
|
||||
fetchSeries()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể lưu series")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: SeriesItem) => {
|
||||
setEditingId(item.id)
|
||||
setName(item.name)
|
||||
setDescription(item.description || "")
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Bạn chắc chắn muốn xóa series này?")) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/mod/series?id=${id}`, { method: "DELETE" })
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Không thể xóa series")
|
||||
}
|
||||
|
||||
toast.success("Đã xóa series")
|
||||
if (editingId === id) resetForm()
|
||||
fetchSeries()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể xóa series")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card rounded-xl border shadow-sm p-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Layers className="h-6 w-6 text-primary" /> Quản lý series
|
||||
</h1>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="Tìm series..."
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div className="xl:col-span-1 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<h2 className="text-base font-semibold mb-3">{editingId ? "Chỉnh sửa series" : "Tạo series mới"}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tên series</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ví dụ: Overlord" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Mô tả</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Mô tả ngắn về series"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={submitting} className="gap-2">
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{editingId ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{editingId ? "Lưu" : "Tạo"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button type="button" variant="outline" onClick={resetForm} className="gap-2">
|
||||
<X className="h-4 w-4" /> Hủy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<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">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Tên</th>
|
||||
<th className="px-4 py-3 font-semibold">Slug</th>
|
||||
<th className="px-4 py-3 font-semibold">Số truyện</th>
|
||||
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredSeries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
Chưa có series nào
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredSeries.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{item.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{item.slug}</td>
|
||||
<td className="px-4 py-3">{item._count?.novels ?? 0}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground max-w-sm truncate">{item.description || "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="icon" variant="outline" className="h-8 w-8" onClick={() => handleEdit(item)}>
|
||||
<Pencil 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={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+966
-45
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user