Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-11 17:02:31 +07:00
parent 1139125460
commit 5686753ab7
42 changed files with 4659 additions and 309 deletions
+24 -1
View File
@@ -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>
+150 -33
View File
@@ -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(`Đã ti ưu ${data.modifiedCount} chương!`)
toast.success(`Đã ti ư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 đá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 tn 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 chương nào đưc đăng.</td></tr>
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa 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
View File
@@ -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 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 chương
</Link>
</nav>
</aside>
+45 -5
View File
@@ -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>
+13
View File
@@ -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 />
}
+230
View File
@@ -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 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"> 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"> 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 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>
)
}
File diff suppressed because it is too large Load Diff