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 /> +
+ +