From 75ed8e233bd8ac6ed45674e1510b0879407939be Mon Sep 17 00:00:00 2001 From: fevirtus Date: Fri, 6 Mar 2026 17:30:56 +0700 Subject: [PATCH] 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. --- app/api/mod/chuong/[id]/route.ts | 46 +++ app/api/mod/chuong/optimize/route.ts | 51 +++ app/api/mod/chuong/route.ts | 105 +++++- app/api/mod/epub/route.ts | 8 +- app/api/mod/the-loai/route.ts | 71 ++++ app/api/mod/truyen/[id]/route.ts | 39 ++ app/api/mod/truyen/route.ts | 79 ++++- app/api/truyen/[id]/comments/route.ts | 47 +++ app/api/truyen/[id]/rate/route.ts | 44 +++ app/api/user/bookmarks/route.ts | 108 ++++++ app/mod/chuong/chapter-client.tsx | 469 ++++++++++++++++++++++--- app/mod/truyen/novel-client.tsx | 345 +++++++++++++++++- app/page.tsx | 8 +- app/the-loai/[slug]/page.tsx | 72 ++-- app/the-loai/page.tsx | 16 +- app/tim-kiem/page.tsx | 167 ++++----- app/truyen/[slug]/[chapterId]/page.tsx | 27 +- app/truyen/[slug]/page.tsx | 65 +++- app/tu-sach/page.tsx | 12 +- components/chapter-list.tsx | 69 +++- components/comment-section.tsx | 38 +- components/novel-card.tsx | 2 +- components/star-rating.tsx | 64 +++- components/tts-player.tsx | 19 +- lib/bookmark-context.tsx | 98 +++--- lib/data.ts | 379 -------------------- lib/types.ts | 1 + lib/utils.ts | 21 ++ prisma/schema.prisma | 8 +- scripts/wipe_db.js | 60 ++++ tsconfig.tsbuildinfo | 2 +- 31 files changed, 1853 insertions(+), 687 deletions(-) create mode 100644 app/api/mod/chuong/[id]/route.ts create mode 100644 app/api/mod/chuong/optimize/route.ts create mode 100644 app/api/mod/the-loai/route.ts create mode 100644 app/api/mod/truyen/[id]/route.ts create mode 100644 app/api/truyen/[id]/comments/route.ts create mode 100644 app/api/truyen/[id]/rate/route.ts create mode 100644 app/api/user/bookmarks/route.ts delete mode 100644 lib/data.ts create mode 100644 scripts/wipe_db.js diff --git a/app/api/mod/chuong/[id]/route.ts b/app/api/mod/chuong/[id]/route.ts new file mode 100644 index 0000000..1bdbcad --- /dev/null +++ b/app/api/mod/chuong/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import connectToMongoDB from "@/lib/mongoose" +import { Chapter } from "@/lib/models/chapter" +import { prisma } from "@/lib/prisma" + +export async function GET( + req: Request, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + await connectToMongoDB() + // console.log("Fetching chapter with ID:", id) + + const chapter = await Chapter.findById(id) + + if (!chapter) { + // console.log("Chapter not found in DB") + return NextResponse.json({ error: "Chapter not found" }, { status: 404 }) + } + + // Verify the moderator owns the related novel + const novel = await prisma.novel.findFirst({ + where: { + id: chapter.novelId, + uploaderId: session.user.id + } + }) + + if (!novel) { + return NextResponse.json({ error: "Unauthorized access to this chapter" }, { status: 403 }) + } + + return NextResponse.json(chapter) + } catch (error) { + console.error("GET Chapter error:", error) + return NextResponse.json({ error: "Failed to fetch chapter details" }, { status: 500 }) + } +} diff --git a/app/api/mod/chuong/optimize/route.ts b/app/api/mod/chuong/optimize/route.ts new file mode 100644 index 0000000..86e01c9 --- /dev/null +++ b/app/api/mod/chuong/optimize/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import connectToMongoDB from "@/lib/mongoose" +import { Chapter } from "@/lib/models/chapter" + +export async function PUT(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const body = await req.json() + const { novelId, updates } = body + + if (!novelId || !updates || !Array.isArray(updates)) { + return NextResponse.json({ error: "Tham số không hợp lệ" }, { status: 400 }) + } + + await connectToMongoDB() + + // Prepare bulk operations for mongoose + const bulkOps = updates.map((update: any) => ({ + updateOne: { + filter: { _id: update.id, novelId: novelId }, + update: { + $set: { + number: update.number, + title: update.title + } + } + } + })) + + if (bulkOps.length === 0) { + return NextResponse.json({ message: "Không có thay đổi nào" }, { status: 200 }) + } + + const result = await Chapter.bulkWrite(bulkOps) + + return NextResponse.json({ + message: "Cập nhật thành công", + modifiedCount: result.modifiedCount + }, { status: 200 }) + + } catch (error: any) { + console.error("Bulk optimize error:", error) + return NextResponse.json({ error: "Lỗi cập nhật hàng loạt", details: error.message }, { status: 500 }) + } +} diff --git a/app/api/mod/chuong/route.ts b/app/api/mod/chuong/route.ts index f849fa7..e95afb9 100644 --- a/app/api/mod/chuong/route.ts +++ b/app/api/mod/chuong/route.ts @@ -8,6 +8,8 @@ import { prisma } from "@/lib/prisma" export async function GET(req: Request) { const { searchParams } = new URL(req.url) const novelId = searchParams.get("novelId") + const page = parseInt(searchParams.get("page") || "1") + const limit = parseInt(searchParams.get("limit") || "20") if (!novelId) { return NextResponse.json({ error: "novelId is required" }, { status: 400 }) @@ -15,8 +17,23 @@ export async function GET(req: Request) { try { await connectToMongoDB() - const chapters = await Chapter.find({ novelId }).sort({ number: 1 }).select("-content") - return NextResponse.json(chapters) + const skip = (page - 1) * limit + + const [chapters, totalChapters] = await Promise.all([ + Chapter.find({ novelId }) + .sort({ number: 1 }) + .skip(skip) + .limit(limit) + .select("-content"), + Chapter.countDocuments({ novelId }) + ]) + + return NextResponse.json({ + chapters, + totalChapters, + totalPages: Math.ceil(totalChapters / limit), + currentPage: page + }) } catch (error) { console.error("GET Chapter Error:", error) return NextResponse.json({ error: "Failed to fetch chapters" }, { status: 500 }) @@ -70,3 +87,87 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Failed to create chapter" }, { status: 500 }) } } + +export async function PUT(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const data = await req.json() + const { id, novelId, number, title, content } = data + + // Xác minh truyện thuộc về Mod này + const novel = await prisma.novel.findFirst({ + where: { id: novelId, uploaderId: session.user.id }, + }) + + if (!novel) { + return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 }) + } + + await connectToMongoDB() + + const updatedChapter = await Chapter.findOneAndUpdate( + { _id: id, novelId }, + { number, title, content }, + { new: true } + ) + + if (!updatedChapter) { + return NextResponse.json({ error: "Không tìm thấy chương" }, { status: 404 }) + } + + return NextResponse.json(updatedChapter) + } catch (error) { + console.error("PUT Chapter Error:", error) + return NextResponse.json({ error: "Failed to update chapter" }, { status: 500 }) + } +} + +export async function DELETE(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const url = new URL(req.url) + const id = url.searchParams.get("id") + const novelId = url.searchParams.get("novelId") + + if (!id || !novelId) { + return NextResponse.json({ error: "Thiếu ID chương hoặc ID truyện" }, { status: 400 }) + } + + // Xác minh truyện thuộc về Mod này + const novel = await prisma.novel.findFirst({ + where: { id: novelId, uploaderId: session.user.id }, + }) + + if (!novel) { + return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 }) + } + + await connectToMongoDB() + + const deletedChapter = await Chapter.findOneAndDelete({ _id: id, novelId }) + + if (!deletedChapter) { + return NextResponse.json({ error: "Không tìm thấy chương" }, { status: 404 }) + } + + // Cập nhật lại số lượng chương trong Postgres + const totalChapters = await Chapter.countDocuments({ novelId }) + await prisma.novel.update({ + where: { id: novelId }, + data: { totalChapters }, + }) + + return NextResponse.json({ message: "Đã xóa chương thành công" }) + } catch (error) { + console.error("DELETE Chapter Error:", error) + return NextResponse.json({ error: "Failed to delete chapter" }, { status: 500 }) + } +} diff --git a/app/api/mod/epub/route.ts b/app/api/mod/epub/route.ts index 6a9fde6..972f506 100644 --- a/app/api/mod/epub/route.ts +++ b/app/api/mod/epub/route.ts @@ -8,6 +8,7 @@ import path from "path" import os from "os" import { promises as fs } from "fs" import { convert } from "html-to-text" +import { slugify } from "@/lib/utils" export async function POST(req: Request) { const session = await getServerSession(authOptions) @@ -71,12 +72,7 @@ export async function POST(req: Request) { let novelDesc = metadata.description || "Chưa có giới thiệu" // Generate base slug - const baseSlug = novelTitle - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)+/g, "") + const baseSlug = slugify(novelTitle) let slug = baseSlug let slugCounter = 1 diff --git a/app/api/mod/the-loai/route.ts b/app/api/mod/the-loai/route.ts new file mode 100644 index 0000000..9c04dee --- /dev/null +++ b/app/api/mod/the-loai/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/prisma" +import { slugify } from "@/lib/utils" + +// Get all genres +export async function GET() { + try { + const genres = await prisma.genre.findMany({ + orderBy: { name: "asc" } + }) + return NextResponse.json(genres) + } catch (error) { + return NextResponse.json({ error: "Failed to fetch genres" }, { status: 500 }) + } +} + +// Admins/Mods can add new genres +export async function POST(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const data = await req.json() + const { name, description } = data + + if (!name) { + return NextResponse.json({ error: "Genre name is required" }, { status: 400 }) + } + + const slug = slugify(name) + + const newGenre = await prisma.genre.create({ + data: { name, slug, description } + }) + + return NextResponse.json(newGenre, { status: 201 }) + } catch (error: any) { + if (error.code === 'P2002') { + return NextResponse.json({ error: "Thể loại này đã tồn tại" }, { status: 400 }) + } + return NextResponse.json({ error: "Failed to create genre" }, { status: 500 }) + } +} + +export async function DELETE(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const url = new URL(req.url) + const id = url.searchParams.get("id") + + if (!id) { + return NextResponse.json({ error: "Thiếu ID thể loại" }, { status: 400 }) + } + + await prisma.genre.delete({ + where: { id } + }) + + return NextResponse.json({ message: "Đã xóa thể loại thành công" }) + } catch (error) { + return NextResponse.json({ error: "Lỗi khi xóa thể loại" }, { status: 500 }) + } +} diff --git a/app/api/mod/truyen/[id]/route.ts b/app/api/mod/truyen/[id]/route.ts new file mode 100644 index 0000000..6584fcb --- /dev/null +++ b/app/api/mod/truyen/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +export async function GET( + req: Request, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const novel = await prisma.novel.findUnique({ + where: { + id, + uploaderId: session.user.id, + }, + include: { + genres: { + include: { + genre: true + } + } + } + }) + + if (!novel) { + return NextResponse.json({ error: "Novel not found" }, { status: 404 }) + } + + return NextResponse.json(novel) + } catch (error) { + return NextResponse.json({ error: "Failed to fetch novel details" }, { status: 500 }) + } +} diff --git a/app/api/mod/truyen/route.ts b/app/api/mod/truyen/route.ts index 0b5d930..9080b34 100644 --- a/app/api/mod/truyen/route.ts +++ b/app/api/mod/truyen/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { getServerSession } from "next-auth/next" import { authOptions } from "@/lib/auth" import { prisma } from "@/lib/prisma" +import { slugify } from "@/lib/utils" export async function GET() { const session = await getServerSession(authOptions) @@ -28,21 +29,22 @@ export async function POST(req: Request) { try { const data = await req.json() + const { title, authorName, description, genreIds = [] } = data // Tạo slug từ title - const slug = data.title - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)+/g, "") + const slug = slugify(title) const newNovel = await prisma.novel.create({ data: { - title: data.title, + title, slug: slug, - authorName: data.authorName, - description: data.description, + authorName, + description, uploaderId: session.user.id, + genres: { + create: genreIds.map((id: string) => ({ + genre: { connect: { id } } + })) + } }, }) return NextResponse.json(newNovel, { status: 201 }) @@ -50,3 +52,62 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Failed to create novel" }, { status: 500 }) } } + +export async function PUT(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const data = await req.json() + const { id, title, authorName, description, status, genreIds } = data + + // Update basic info and recreate genre relations + const updatedNovel = await prisma.novel.update({ + where: { id: id, uploaderId: session.user.id }, // Make sure they own it + data: { + title, + authorName, + description, + status, + // Replace all existing genres if genreIds is provided + ...(genreIds !== undefined && { + genres: { + deleteMany: {}, + create: genreIds.map((gId: string) => ({ + genre: { connect: { id: gId } } + })) + } + }) + }, + }) + return NextResponse.json(updatedNovel) + } catch (error) { + return NextResponse.json({ error: "Failed to update novel" }, { status: 500 }) + } +} + +export async function DELETE(req: Request) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const url = new URL(req.url) + const id = url.searchParams.get("id") + + if (!id) return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 }) + + // Xóa truyện. (Chapters trong MongoDB nên được xóa bằng một cron job hoặc API khác để tránh block UI quá lâu, + // ở đây chúng ta chỉ xóa record của Postgres để ẩn truyện). + await prisma.novel.delete({ + where: { id: id, uploaderId: session.user.id }, + }) + + return NextResponse.json({ message: "Đã xóa truyện thành công" }) + } catch (error) { + return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 }) + } +} diff --git a/app/api/truyen/[id]/comments/route.ts b/app/api/truyen/[id]/comments/route.ts new file mode 100644 index 0000000..47c458c --- /dev/null +++ b/app/api/truyen/[id]/comments/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: novelId } = await params + const body = await req.json() + const { content, chapterId } = body + + if (!content || typeof content !== "string") { + return NextResponse.json({ error: "Content is required" }, { status: 400 }) + } + + const newComment = await prisma.comment.create({ + data: { + content: content.trim(), + userId: session.user.id, + novelId, + chapterId: chapterId || null + }, + include: { + user: true + } + }) + + return NextResponse.json({ + id: newComment.id, + userId: newComment.user.id, + username: newComment.user.name || "User", + avatarColor: newComment.user.image || "bg-primary", + novelId: newComment.novelId, + chapterId: newComment.chapterId, + content: newComment.content, + createdAt: newComment.createdAt.toISOString().split("T")[0] + }) + } catch (error) { + console.error("POST Comment Error", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/app/api/truyen/[id]/rate/route.ts b/app/api/truyen/[id]/rate/route.ts new file mode 100644 index 0000000..e883b80 --- /dev/null +++ b/app/api/truyen/[id]/rate/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const body = await req.json() + const { score } = body + + if (typeof score !== 'number' || score < 1 || score > 5) { + return NextResponse.json({ error: "Invalid score" }, { status: 400 }) + } + + // Fetch current rating + const novel = await prisma.novel.findUnique({ + where: { id }, + select: { rating: true, ratingCount: true } + }) + + if (!novel) { + return NextResponse.json({ error: "Novel not found" }, { status: 404 }) + } + + const { rating, ratingCount } = novel + const newRatingCount = ratingCount + 1 + const newRating = ((rating * ratingCount) + score) / newRatingCount + + const updatedNovel = await prisma.novel.update({ + where: { id }, + data: { + rating: newRating, + ratingCount: newRatingCount + } + }) + + return NextResponse.json({ + rating: updatedNovel.rating, + ratingCount: updatedNovel.ratingCount + }) + } catch (error) { + console.error("Rating Error", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/app/api/user/bookmarks/route.ts b/app/api/user/bookmarks/route.ts new file mode 100644 index 0000000..4d931ee --- /dev/null +++ b/app/api/user/bookmarks/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +// Lấy danh sách bookmark +export async function GET(req: Request) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const bookmarks = await prisma.bookmark.findMany({ + where: { userId: session.user.id }, + include: { novel: true }, + orderBy: { createdAt: "desc" } + }) + + return NextResponse.json(bookmarks) + } catch (error) { + console.error("GET Bookmarks Error", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} + +// Thêm, cập nhật hoặc xóa bookmark +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await req.json() + const { action, novelId, lastChapterId, lastChapterNumber } = body + + if (!novelId || !action) { + return NextResponse.json({ error: "Bad Request" }, { status: 400 }) + } + + if (action === "toggle") { + const existing = await prisma.bookmark.findUnique({ + where: { + userId_novelId: { + userId: session.user.id, + novelId, + } + } + }) + + if (existing) { + // Xoá + await prisma.$transaction([ + prisma.bookmark.delete({ where: { id: existing.id } }), + prisma.novel.update({ where: { id: novelId }, data: { bookmarkCount: { decrement: 1 } } }) + ]) + return NextResponse.json({ status: "removed" }) + } else { + // Thêm mới + const newBookmark = await prisma.$transaction(async (tx) => { + const b = await tx.bookmark.create({ + data: { + userId: session.user.id, + novelId, + lastChapterId, + lastChapterNumber + } + }) + await tx.novel.update({ where: { id: novelId }, data: { bookmarkCount: { increment: 1 } } }) + return b + }) + return NextResponse.json({ status: "added", bookmark: newBookmark }) + } + } else if (action === "updateProgress") { + // Cập nhật tiến độ lưu trang + if (!lastChapterId || !lastChapterNumber) { + return NextResponse.json({ error: "Missing chapter info" }, { status: 400 }) + } + + const bookmark = await prisma.bookmark.upsert({ + where: { + userId_novelId: { + userId: session.user.id, + novelId, + } + }, + update: { + lastChapterId, + lastChapterNumber + }, + create: { + userId: session.user.id, + novelId, + lastChapterId, + lastChapterNumber + } + }) + return NextResponse.json({ status: "updated", bookmark }) + } + + return NextResponse.json({ error: "Invalid action" }, { status: 400 }) + + } catch (error) { + console.error("POST Bookmarks Error", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/app/mod/chuong/chapter-client.tsx b/app/mod/chuong/chapter-client.tsx index 1cda041..165210d 100644 --- a/app/mod/chuong/chapter-client.tsx +++ b/app/mod/chuong/chapter-client.tsx @@ -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([]) + + // Edit states + const [openEdit, setOpenEdit] = useState(false) + const [editingChapterId, setEditingChapterId] = useState(null) + const [loadingEditData, setLoadingEditData] = useState(false) + + // Delete states + const [openDelete, setOpenDelete] = useState(false) + const [deletingChapterId, setDeletingChapterId] = useState(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 (
@@ -113,52 +284,197 @@ function ChapterManager() { Quản lý chương - - - - - - - Đăng Chương Mới - - Thêm nội dung một chương truyện để gửi đến độc giả. - - -
-
-
- - setNumber(e.target.value)} required /> +
+ + + + + + + + + Đăng Chương Mới + + Thêm nội dung một chương truyện để gửi đến độc giả. + + + +
+
+ + setNumber(e.target.value)} required /> +
+
+ + setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus /> +
-
- - setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus /> +
+ +