Add moderation APIs and admin UI
Add moderator/admin backend APIs and client features for managing novels and chapters. New endpoints include mod chapter routes (paginated list, single GET, PUT, DELETE, and bulk optimize), mod novel routes (create, GET by id, update, delete), genre CRUD, user bookmarks, novel comments, and rating endpoints. Update EPUB import to use a shared slugify util. Enhance moderator UI: chapter manager gains pagination, bulk optimization preview/apply, edit/delete dialogs; novel client adds genre management and edit/delete flows. Also update Prisma schema, add a DB wipe script, remove unused lib/data.ts, and adjust related types/utils and bookmark context.
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+103
-2
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { FileText, Loader2, Plus, ArrowLeft } from "lucide-react"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -26,6 +26,19 @@ interface Chapter {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const generatePagination = (currentPage: number, totalPages: number) => {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, '...', totalPages]
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
|
||||
}
|
||||
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages]
|
||||
}
|
||||
|
||||
function ChapterManager() {
|
||||
const searchParams = useSearchParams()
|
||||
const novelId = searchParams.get("novelId")
|
||||
@@ -35,20 +48,47 @@ function ChapterManager() {
|
||||
const [openAdd, setOpenAdd] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalChapters, setTotalChapters] = useState(0)
|
||||
|
||||
// Optimization states
|
||||
const [openOptimize, setOpenOptimize] = useState(false)
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
||||
const [optRenumber, setOptRenumber] = useState(true)
|
||||
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
const [editingChapterId, setEditingChapterId] = useState<string | null>(null)
|
||||
const [loadingEditData, setLoadingEditData] = useState(false)
|
||||
|
||||
// Delete states
|
||||
const [openDelete, setOpenDelete] = useState(false)
|
||||
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
|
||||
|
||||
// Form states
|
||||
const [number, setNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
const fetchChapters = async () => {
|
||||
const fetchChapters = async (pageToFetch = 1) => {
|
||||
if (!novelId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/mod/chuong?novelId=${novelId}`)
|
||||
const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${pageToFetch}&limit=50`)
|
||||
if (!res.ok) throw new Error("Lỗi fetch")
|
||||
const data = await res.json()
|
||||
setChapters(data)
|
||||
if (data.length > 0) {
|
||||
setNumber((data[data.length - 1].number + 1).toString())
|
||||
setChapters(data.chapters)
|
||||
setTotalPages(data.totalPages)
|
||||
setCurrentPage(data.currentPage)
|
||||
setTotalChapters(data.totalChapters)
|
||||
|
||||
if (data.chapters.length > 0) {
|
||||
setNumber((data.chapters[data.chapters.length - 1].number + 1).toString())
|
||||
} else {
|
||||
setNumber("1")
|
||||
}
|
||||
@@ -60,8 +100,10 @@ function ChapterManager() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchChapters()
|
||||
}, [novelId])
|
||||
if (novelId) {
|
||||
fetchChapters(currentPage)
|
||||
}
|
||||
}, [novelId, currentPage])
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -94,6 +136,135 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewOptimize = () => {
|
||||
let newChapters = [...chapters]
|
||||
|
||||
if (optRenumber) {
|
||||
newChapters.sort((a, b) => a.number - b.number)
|
||||
newChapters = newChapters.map((ch, idx) => ({
|
||||
...ch,
|
||||
number: idx + 1
|
||||
}))
|
||||
}
|
||||
|
||||
if (optRemovePrefix) {
|
||||
newChapters = newChapters.map((ch, i) => {
|
||||
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
||||
if (!newTitle) newTitle = `Chương ${ch.number}`
|
||||
return { ...ch, title: newTitle }
|
||||
})
|
||||
}
|
||||
|
||||
setOptimizedChapters(newChapters)
|
||||
setPreviewMode(true)
|
||||
}
|
||||
|
||||
const handleApplyOptimize = async () => {
|
||||
if (optimizedChapters.length === 0) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const updates = optimizedChapters.map(ch => ({
|
||||
id: ch._id,
|
||||
title: ch.title,
|
||||
number: ch.number
|
||||
}))
|
||||
|
||||
const res = await fetch("/api/mod/chuong/optimize", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ novelId, updates }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa")
|
||||
|
||||
toast.success(`Đã tổi ưu ${data.modifiedCount} chương!`)
|
||||
setOpenOptimize(false)
|
||||
setPreviewMode(false)
|
||||
fetchChapters(currentPage)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (chapter: Chapter) => {
|
||||
setEditingChapterId(chapter._id)
|
||||
setNumber(chapter.number.toString())
|
||||
setTitle(chapter.title)
|
||||
setContent("") // Khởi tạo rỗng trong lúc chờ fetch nội dung
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
try {
|
||||
// Lấy chi tiết chương từ list db để có nội dung qua API GET /api/mod/chuong/[id] vừa tạo
|
||||
const res = await fetch(`/api/mod/chuong/${chapter._id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data.content)
|
||||
} else {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
} finally {
|
||||
setLoadingEditData(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!number || !title || !content || !novelId || !editingChapterId) {
|
||||
toast.error("Vui lòng điền đầy đủ")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: editingChapterId, novelId, number: parseInt(number), title, content }),
|
||||
})
|
||||
|
||||
const resData = await res.json()
|
||||
if (!res.ok) throw new Error(resData.error || "Cập nhật thất bại")
|
||||
|
||||
toast.success("Đã cập nhật chương thành công!")
|
||||
setOpenEdit(false)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingChapterId || !novelId) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/mod/chuong?id=${deletingChapterId}&novelId=${novelId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Xóa thất bại")
|
||||
}
|
||||
toast.success("Đã xóa chương thành công")
|
||||
setOpenDelete(false)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (!novelId) {
|
||||
return (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
@@ -113,52 +284,197 @@ function ChapterManager() {
|
||||
<FileText className="h-6 w-6 text-primary" /> Quản lý chương
|
||||
</h1>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Đăng chương mới
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Đăng Chương Mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thêm nội dung một chương truyện để gửi đến độc giả.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="gap-2" onClick={() => {
|
||||
setOpenOptimize(true)
|
||||
setPreviewMode(false)
|
||||
}} disabled={chapters.length === 0}>
|
||||
<Wand2 className="h-4 w-4" /> Tối ưu hóa
|
||||
</Button>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Đăng chương mới
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Đăng Chương Mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thêm nội dung một chương truyện để gửi đến độc giả.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
placeholder="Paste văn bản của chương vào đây..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
placeholder="Paste văn bản của chương vào đây..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Đăng ngay
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chỉnh Sửa Chương</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thay đổi nội dung hoặc thông tin chương truyện.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingEditData ? (
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEditSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive">Xác nhận xóa chương</DialogTitle>
|
||||
<DialogDescription>
|
||||
Hành động này không thể hoàn tác. Chương này sẽ bị xóa vĩnh viễn khỏi cơ sở dữ liệu.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setOpenDelete(false)}>Hủy bỏ</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Đăng ngay
|
||||
Tiếp tục xóa
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openOptimize} onOpenChange={setOpenOptimize}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tối Ưu Hóa Chương Hàng Loạt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Công cụ dọn dẹp tên chương và đánh lại số thứ tự tự động tiện lợi sau khi đăng ép từ tệp EPUB.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!previewMode ? (
|
||||
<div className="flex flex-col gap-4 py-4 flex-1">
|
||||
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" checked={optRemovePrefix} onChange={(e) => setOptRemovePrefix(e.target.checked)} />
|
||||
<div>
|
||||
<p className="font-medium text-base">Xóa tiền tố "Chương X:" dư thừa</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Ví dụ: <span className="line-through">Chương 1: Bắt đầu</span> sẽ thành <strong>Bắt đầu</strong></p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" checked={optRenumber} onChange={(e) => setOptRenumber(e.target.checked)} />
|
||||
<div>
|
||||
<p className="font-medium text-base">Đánh lại số thứ tự tự động</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Sắp xếp và gán lại số chương liên tục từ 1 đến N để sửa lỗi nhảy cóc</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto border rounded-lg my-4 custom-scrollbar">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted sticky top-0 shadow-sm">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-1/2 border-r">Nội dung gốc (Hiện tại)</th>
|
||||
<th className="px-4 py-3 w-1/2">Xem trước kết quả (Mới)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{optimizedChapters.map((newCh, i) => {
|
||||
const oldCh = chapters[i]
|
||||
return (
|
||||
<tr key={newCh._id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-3 border-r text-muted-foreground">
|
||||
<span className="font-mono text-xs mr-2 inline-block w-8 text-right">#{oldCh.number}</span>
|
||||
<span className={oldCh.title !== newCh.title ? "line-through opacity-70" : ""}>{oldCh.title}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-foreground font-medium">
|
||||
<span className="font-mono text-xs mr-2 text-primary inline-block w-8 text-right">#{newCh.number}</span>
|
||||
<span className={oldCh.title !== newCh.title ? "text-primary" : ""}>{newCh.title}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-auto pt-2">
|
||||
<Button variant="outline" onClick={() => setOpenOptimize(false)}>Hủy bỏ</Button>
|
||||
{!previewMode ? (
|
||||
<Button onClick={handlePreviewOptimize}>Kiểm tra trước</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing}>Quay lại Option</Button>
|
||||
<Button onClick={handleApplyOptimize} disabled={optimizing}>
|
||||
{optimizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi vào DB
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card overflow-hidden shadow-sm">
|
||||
<div className="rounded-xl border bg-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
|
||||
@@ -180,8 +496,18 @@ function ChapterManager() {
|
||||
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
|
||||
<td className="px-5 py-4 text-right">{ch.views}</td>
|
||||
<td className="px-5 py-4 text-right space-x-3">
|
||||
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa nội dung</button>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50" onClick={() => handleOpenEdit(ch)}>
|
||||
<Edit className="w-4 h-4 mr-1" /> Sửa
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
|
||||
setDeletingChapterId(ch._id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4 mr-1" /> Xóa
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@@ -189,6 +515,51 @@ function ChapterManager() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t p-4 flex flex-col sm:flex-row items-center justify-between gap-4 bg-muted/20">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Trang <span className="font-medium text-foreground">{currentPage}</span> / {totalPages} (Tổng {totalChapters} chương)
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1 || loading}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
|
||||
{generatePagination(currentPage, totalPages).map((p, i) => (
|
||||
<div key={i} className="hidden sm:block">
|
||||
{p === '...' ? (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === p ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
disabled={loading}
|
||||
onClick={() => setCurrentPage(p as number)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { BookOpen, Loader2, Plus, Upload } from "lucide-react"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -26,6 +26,11 @@ interface Novel {
|
||||
totalChapters: number
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function NovelClient() {
|
||||
const [novels, setNovels] = useState<Novel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -37,6 +42,22 @@ export function NovelClient() {
|
||||
const [title, setTitle] = useState("")
|
||||
const [authorName, setAuthorName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [status, setStatus] = useState("Đang ra")
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
const [editingNovel, setEditingNovel] = useState<Novel | null>(null)
|
||||
const [loadingEditData, setLoadingEditData] = useState(false)
|
||||
|
||||
// Genre states
|
||||
const [genres, setGenres] = useState<Genre[]>([])
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
|
||||
const [newGenreName, setNewGenreName] = useState("")
|
||||
const [addingGenre, setAddingGenre] = useState(false)
|
||||
|
||||
// Delete states
|
||||
const [openDelete, setOpenDelete] = useState(false)
|
||||
const [deletingNovelId, setDeletingNovelId] = useState<string | null>(null)
|
||||
|
||||
const fetchNovels = async () => {
|
||||
try {
|
||||
@@ -51,10 +72,72 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGenres = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/mod/the-loai")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setGenres(data)
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch genres")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchNovels()
|
||||
fetchGenres()
|
||||
}, [])
|
||||
|
||||
const toggleGenre = (id: string) => {
|
||||
setSelectedGenres(prev =>
|
||||
prev.includes(id) ? prev.filter(gId => gId !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddGenre = async () => {
|
||||
if (!newGenreName.trim()) return
|
||||
setAddingGenre(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/the-loai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newGenreName, description: "" })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Thêm lỗi")
|
||||
|
||||
toast.success("Thêm thể loại thành công")
|
||||
setNewGenreName("")
|
||||
fetchGenres()
|
||||
setSelectedGenres(prev => [...prev, data.id])
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setAddingGenre(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGenre = async (id: string, name: string) => {
|
||||
if (!confirm(`Bạn có chắc muốn xóa thể loại "${name}" khỏi hệ thống?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mod/the-loai?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Xóa lỗi")
|
||||
}
|
||||
toast.success("Đã xóa thể loại thành công")
|
||||
fetchGenres()
|
||||
// Clean up from selected lists
|
||||
setSelectedGenres(prev => prev.filter(gId => gId !== id))
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title || !authorName || !description) {
|
||||
@@ -67,7 +150,7 @@ export function NovelClient() {
|
||||
const res = await fetch("/api/mod/truyen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, authorName, description }),
|
||||
body: JSON.stringify({ title, authorName, description, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
|
||||
})
|
||||
if (!res.ok) throw new Error("Thêm mới thất bại")
|
||||
toast.success("Đã thêm truyện thành công!")
|
||||
@@ -75,6 +158,8 @@ export function NovelClient() {
|
||||
setTitle("")
|
||||
setAuthorName("")
|
||||
setDescription("")
|
||||
setStatus("Đang ra")
|
||||
setSelectedGenres([])
|
||||
fetchNovels()
|
||||
} catch {
|
||||
toast.error("Lỗi khi thêm truyện mới")
|
||||
@@ -118,6 +203,93 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (novel: Novel) => {
|
||||
setEditingNovel(novel)
|
||||
setTitle(novel.title)
|
||||
setAuthorName(novel.authorName)
|
||||
setStatus(novel.status)
|
||||
setDescription("")
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mod/truyen/${novel.id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDescription(data.description || "")
|
||||
if (data.genres && Array.isArray(data.genres)) {
|
||||
setSelectedGenres(data.genres.map((g: any) => g.genreId))
|
||||
} else {
|
||||
setSelectedGenres([])
|
||||
}
|
||||
} else {
|
||||
toast.error("Không tải được chi tiết truyện")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Không tải được chi tiết truyện")
|
||||
} finally {
|
||||
setLoadingEditData(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingNovel || !title || !authorName) {
|
||||
toast.error("Vui lòng nhập tên truyện và tác giả")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/truyen", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: editingNovel.id,
|
||||
title,
|
||||
authorName,
|
||||
description,
|
||||
genreIds: selectedGenres,
|
||||
status: status
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi cập nhật")
|
||||
|
||||
toast.success("Cập nhật truyện thành công!")
|
||||
setOpenEdit(false)
|
||||
fetchNovels()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSubmit = async () => {
|
||||
if (!deletingNovelId) return
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mod/truyen?id=${deletingNovelId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Xóa thất bại")
|
||||
}
|
||||
toast.success("Đã xóa truyện thành công")
|
||||
setOpenDelete(false)
|
||||
fetchNovels()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
|
||||
@@ -144,13 +316,18 @@ export function NovelClient() {
|
||||
Tải lên EPUB
|
||||
</Button>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<Dialog open={openAdd} onOpenChange={(val) => {
|
||||
setOpenAdd(val);
|
||||
if (val) {
|
||||
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName("");
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Thêm truyện
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Thêm Truyện Mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -166,6 +343,38 @@ export function NovelClient() {
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Ví dụ: Vong Ngữ" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thêm thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Tên thể loại mới..."
|
||||
value={newGenreName}
|
||||
onChange={(e) => setNewGenreName(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddGenre(); } }}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !newGenreName.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : "Tạo"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto p-2 border rounded-md custom-scrollbar bg-card">
|
||||
{genres.map(genre => (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border transition-colors select-none ${selectedGenres.includes(genre.id) ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
<span className="cursor-pointer" onClick={() => toggleGenre(genre.id)}>{genre.name}</span>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5 ml-1 inline-flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteGenre(genre.id, genre.name); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{genres.length === 0 && <span className="text-xs text-muted-foreground p-1">Chưa có thể loại nào</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Giới thiệu ngắn (Mô tả)</label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Tóm tắt về câu chuyện..." rows={4} />
|
||||
@@ -179,6 +388,112 @@ export function NovelClient() {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
|
||||
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chỉnh Sửa Truyện</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cập nhật thông tin cho tác phẩm của bạn.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingEditData ? (
|
||||
<div className="flex-1 flex justify-center items-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tên truyện</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cập nhật thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Tên thể loại mới..."
|
||||
value={newGenreName}
|
||||
onChange={(e) => setNewGenreName(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddGenre(); } }}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !newGenreName.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : "Tạo"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto p-2 border rounded-md custom-scrollbar bg-card">
|
||||
{genres.map(genre => (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border transition-colors select-none ${selectedGenres.includes(genre.id) ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
<span className="cursor-pointer" onClick={() => toggleGenre(genre.id)}>{genre.name}</span>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5 ml-1 inline-flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteGenre(genre.id, genre.name); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Trạng thái</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="Đang ra">Đang ra</option>
|
||||
<option value="Hoàn thành">Hoàn thành</option>
|
||||
<option value="Tạm dừng">Tạm dừng</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Giới thiệu ngắn (Mô tả mới)</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Để trống nếu không muốn thay đổi..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu Thay Đổi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive">Xác nhận xóa truyện</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bạn có chắc chắn muốn xóa bộ truyện này? Hành động này sẽ ẩn đầu truyện khỏi hệ thống.
|
||||
<br /><br />
|
||||
Lưu ý: Các chương liên quan (trong MongoDB) sẽ cần được dọn dẹp riêng.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setOpenDelete(false)}>Hủy bỏ</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Tiếp tục xóa
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,11 +529,23 @@ export function NovelClient() {
|
||||
{novel.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right space-x-3">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`} className="font-medium text-blue-500 hover:text-blue-600 hover:underline">
|
||||
Đăng chương
|
||||
</Link>
|
||||
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa</button>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
Cập nhật chương
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50" onClick={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { genres } from "@/lib/data"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
@@ -33,6 +33,10 @@ export default async function HomePage() {
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
})
|
||||
|
||||
// get the most popular as featured (can be empty if DB is new)
|
||||
const featured = popularNovels[0]
|
||||
|
||||
@@ -147,7 +151,7 @@ export default async function HomePage() {
|
||||
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-accent/50"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
{iconMap[genre.icon] || <BookOpen className="h-5 w-5" />}
|
||||
{genre.icon && iconMap[genre.icon] ? iconMap[genre.icon] : <BookOpen className="h-5 w-5" />}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h3>
|
||||
|
||||
@@ -1,77 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { use, useState, useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { getGenreBySlug, getNovelsByGenre } from "@/lib/data"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export default function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = use(params)
|
||||
const genre = getGenreBySlug(slug)
|
||||
|
||||
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { slug }
|
||||
})
|
||||
|
||||
if (!genre) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <GenreContent genreName={genre.name} genreSlug={genre.slug} genreDescription={genre.description} />
|
||||
}
|
||||
|
||||
function GenreContent({ genreName, genreSlug, genreDescription }: { genreName: string; genreSlug: string; genreDescription: string }) {
|
||||
const [sortBy, setSortBy] = useState("latest")
|
||||
const allNovels = getNovelsByGenre(genreSlug)
|
||||
|
||||
const sortedNovels = useMemo(() => {
|
||||
const sorted = [...allNovels]
|
||||
switch (sortBy) {
|
||||
case "popular":
|
||||
sorted.sort((a, b) => b.views - a.views)
|
||||
break
|
||||
case "rating":
|
||||
sorted.sort((a, b) => b.rating - a.rating)
|
||||
break
|
||||
case "latest":
|
||||
default:
|
||||
sorted.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime())
|
||||
const allNovels = await prisma.novel.findMany({
|
||||
where: {
|
||||
genres: {
|
||||
some: {
|
||||
genreId: genre.id
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc"
|
||||
}
|
||||
return sorted
|
||||
}, [allNovels, sortBy])
|
||||
})
|
||||
|
||||
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<div className="mb-6">
|
||||
<Link href="/the-loai" className="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-4 w-4" /> Thể Loại
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground">{genreName}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{genreDescription}</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">{genre.name}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{genre.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{sortedNovels.length} truyện</p>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">Mới nhất</SelectItem>
|
||||
<SelectItem value="popular">Xem nhiều</SelectItem>
|
||||
<SelectItem value="rating">Đánh giá cao</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">{allNovels.length} truyện</p>
|
||||
<div className="w-40" /> {/* Spacer for symmetry if we add sort later */}
|
||||
</div>
|
||||
|
||||
{sortedNovels.length === 0 ? (
|
||||
{allNovels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<p className="text-lg font-medium">Chưa có truyện nào</p>
|
||||
<p className="text-sm">Thể loại này chưa có truyện, hãy quay lại sau.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sortedNovels.map((novel) => (
|
||||
{allNovels.map((novel) => (
|
||||
<NovelCard key={novel.id} novel={novel} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
+12
-4
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
||||
import { genres, getNovelsByGenre } from "@/lib/data"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
Sparkles: <Sparkles className="h-6 w-6" />,
|
||||
@@ -15,13 +15,21 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Shield: <Shield className="h-6 w-6" />,
|
||||
}
|
||||
|
||||
export default function GenresPage() {
|
||||
export default async function GenresPage() {
|
||||
const genres = await prisma.genre.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { novels: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<h1 className="mb-6 text-2xl font-bold text-foreground">Thể Loại Truyện</h1>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{genres.map((genre) => {
|
||||
const novelCount = getNovelsByGenre(genre.slug).length
|
||||
const novelCount = genre._count.novels
|
||||
return (
|
||||
<Link
|
||||
key={genre.id}
|
||||
@@ -29,7 +37,7 @@ export default function GenresPage() {
|
||||
className="group flex items-start gap-4 rounded-xl border border-border bg-card p-5 transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
{iconMap[genre.icon] || <BookOpen className="h-6 w-6" />}
|
||||
{genre.icon && iconMap[genre.icon] ? iconMap[genre.icon] : <BookOpen className="h-6 w-6" />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h2>
|
||||
|
||||
+86
-81
@@ -1,106 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
// Server component instead of client component
|
||||
import { Search } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { novels, genres, searchNovels } from "@/lib/data"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export default function SearchPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const initialQuery = searchParams.get("q") || ""
|
||||
const initialSort = searchParams.get("sort") || "latest"
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | undefined }>
|
||||
}) {
|
||||
const resolvedParams = await searchParams
|
||||
const q = resolvedParams.q || ""
|
||||
const sortBy = resolvedParams.sort || "latest"
|
||||
const genreFilter = resolvedParams.genreFilter || "all"
|
||||
const statusFilter = resolvedParams.statusFilter || "all"
|
||||
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [sortBy, setSortBy] = useState(initialSort)
|
||||
const [genreFilter, setGenreFilter] = useState("all")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
// Build where clause
|
||||
let where: any = {}
|
||||
|
||||
const filteredNovels = useMemo(() => {
|
||||
let results = query.trim() ? searchNovels(query) : [...novels]
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ authorName: { contains: q, mode: "insensitive" } },
|
||||
]
|
||||
}
|
||||
|
||||
if (genreFilter !== "all") {
|
||||
results = results.filter((n) => n.genres.includes(genreFilter))
|
||||
if (genreFilter !== "all") {
|
||||
where.genres = {
|
||||
some: {
|
||||
genre: {
|
||||
slug: genreFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (statusFilter !== "all") {
|
||||
results = results.filter((n) => n.status === statusFilter)
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
where.status = statusFilter
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case "popular":
|
||||
results.sort((a, b) => b.views - a.views)
|
||||
break
|
||||
case "rating":
|
||||
results.sort((a, b) => b.rating - a.rating)
|
||||
break
|
||||
case "name":
|
||||
results.sort((a, b) => a.title.localeCompare(b.title))
|
||||
break
|
||||
case "latest":
|
||||
default:
|
||||
results.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime())
|
||||
}
|
||||
// Build order clause
|
||||
let orderBy: any = {}
|
||||
switch (sortBy) {
|
||||
case "popular":
|
||||
orderBy = { views: "desc" }
|
||||
break
|
||||
case "rating":
|
||||
orderBy = { rating: "desc" }
|
||||
break
|
||||
case "name":
|
||||
orderBy = { title: "asc" }
|
||||
break
|
||||
case "latest":
|
||||
default:
|
||||
orderBy = { updatedAt: "desc" }
|
||||
}
|
||||
|
||||
return results
|
||||
}, [query, sortBy, genreFilter, statusFilter])
|
||||
const filteredNovels = await prisma.novel.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
})
|
||||
|
||||
const genres = await prisma.genre.findMany()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<h1 className="mb-6 text-2xl font-bold text-foreground">Tìm Kiếm Truyện</h1>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Tìm theo tên truyện, tác giả..."
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Search and Filters - This requires a client component wrapper ideally, but for now we can rely on standard form submissions to update searchParams */}
|
||||
<form method="GET" action="/tim-kiem">
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="q"
|
||||
type="search"
|
||||
placeholder="Tìm theo tên truyện, tác giả..."
|
||||
className="pl-9"
|
||||
defaultValue={q}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Select value={genreFilter} onValueChange={setGenreFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Thể loại" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tất cả thể loại</SelectItem>
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<select name="genreFilter" defaultValue={genreFilter} className="flex h-9 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<option value="all">Tất cả thể loại</option>
|
||||
{genres.map((g) => (
|
||||
<SelectItem key={g.slug} value={g.slug}>{g.name}</SelectItem>
|
||||
<option key={g.slug} value={g.slug}>{g.name}</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Trạng thái" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tất cả</SelectItem>
|
||||
<SelectItem value="Đang ra">Đang ra</SelectItem>
|
||||
<SelectItem value="Hoàn thành">Hoàn thành</SelectItem>
|
||||
<SelectItem value="Tạm ngưng">Tạm ngưng</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select name="statusFilter" defaultValue={statusFilter} className="flex h-9 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<option value="all">Tất cả trạng thái</option>
|
||||
<option value="Đang ra">Đang ra</option>
|
||||
<option value="Hoàn thành">Hoàn thành</option>
|
||||
<option value="Tạm ngưng">Tạm ngưng</option>
|
||||
</select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Sắp xếp" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">Mới nhất</SelectItem>
|
||||
<SelectItem value="popular">Xem nhiều</SelectItem>
|
||||
<SelectItem value="rating">Đánh giá cao</SelectItem>
|
||||
<SelectItem value="name">Theo tên</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<select name="sort" defaultValue={sortBy} className="flex h-9 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<option value="latest">Mới nhất</option>
|
||||
<option value="popular">Xem nhiều</option>
|
||||
<option value="rating">Đánh giá cao</option>
|
||||
<option value="name">Theo tên</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium">Lọc</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Results */}
|
||||
<p className="mb-4 text-sm text-muted-foreground">{filteredNovels.length} kết quả</p>
|
||||
|
||||
@@ -34,7 +34,32 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const maxChapter = await ChapterModel.countDocuments({ novelId: novel.id })
|
||||
const comments: any[] = [] // Temporarily empty
|
||||
|
||||
const commentsData = await prisma.comment.findMany({
|
||||
where: { novelId: novel.id, chapterId: chapter._id.toString() },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
})
|
||||
|
||||
const comments = commentsData.map(c => ({
|
||||
id: c.id,
|
||||
userId: c.user.id,
|
||||
username: c.user.name || "User",
|
||||
avatarColor: c.user.image || "bg-primary",
|
||||
novelId: c.novelId,
|
||||
chapterId: c.chapterId,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
// Increment views quietly (fire and forget to not block render)
|
||||
Promise.all([
|
||||
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } }),
|
||||
prisma.novel.update({
|
||||
where: { id: novel.id },
|
||||
data: { views: { increment: 1 } }
|
||||
}).catch(e => console.error("Error incrementing novel views:", e))
|
||||
]).catch(e => console.error("Error updating views:", e))
|
||||
|
||||
const hasPrev = chapterNumber > 1
|
||||
const hasNext = chapterNumber < maxChapter
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react"
|
||||
import { genres, formatViews } from "@/lib/data"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
import { GenreBadge } from "@/components/genre-badge"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
import { ChapterList } from "@/components/chapter-list"
|
||||
@@ -10,8 +10,18 @@ import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
|
||||
export default async function NovelDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
export default async function NovelDetailPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ slug: string }>,
|
||||
searchParams: Promise<{ page?: string }>
|
||||
}) {
|
||||
const { slug } = await params
|
||||
const { page } = await searchParams
|
||||
|
||||
const currentPage = parseInt(page || "1")
|
||||
const limit = 20
|
||||
|
||||
const novel = await prisma.novel.findUnique({
|
||||
where: { slug },
|
||||
@@ -26,12 +36,27 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Increment view quietly
|
||||
prisma.novel.update({
|
||||
where: { id: novel.id },
|
||||
data: { views: { increment: 1 } }
|
||||
}).catch(e => console.error("Error incrementing view:", e))
|
||||
|
||||
// Fetch chapters from MongoDB
|
||||
await connectToMongoDB()
|
||||
const chapters = await Chapter.find({ novelId: novel.id })
|
||||
.sort({ number: 1 })
|
||||
.select("id novelId number title createdAt views")
|
||||
.lean()
|
||||
const skip = (currentPage - 1) * limit
|
||||
|
||||
const [chapters, totalChapters] = await Promise.all([
|
||||
Chapter.find({ novelId: novel.id })
|
||||
.sort({ number: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select("id novelId number title createdAt views")
|
||||
.lean(),
|
||||
Chapter.countDocuments({ novelId: novel.id })
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(totalChapters / limit)
|
||||
|
||||
// Convert Mongoose documents to plain objects for Server Component
|
||||
const formattedChapters = chapters.map(c => ({
|
||||
@@ -44,7 +69,23 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
|
||||
content: "" // We don't fetch content for the list
|
||||
}))
|
||||
|
||||
const comments: any[] = [] // Temporarily empty until we implement comments
|
||||
const commentsData = await prisma.comment.findMany({
|
||||
where: { novelId: novel.id, chapterId: null },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
})
|
||||
|
||||
// Format explicitly as the CommentProp type
|
||||
const comments = commentsData.map(c => ({
|
||||
id: c.id,
|
||||
userId: c.user.id,
|
||||
username: c.user.name || "User",
|
||||
avatarColor: c.user.image || "bg-primary",
|
||||
novelId: c.novelId,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
const novelGenres = novel.genres.map(ng => ng.genre) || []
|
||||
|
||||
return (
|
||||
@@ -77,7 +118,7 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StarRating rating={novel.rating} ratingCount={novel.ratingCount} interactive />
|
||||
<StarRating rating={novel.rating} ratingCount={novel.ratingCount} novelId={novel.id} interactive />
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{novelGenres.map((g) => (
|
||||
@@ -99,7 +140,13 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2>
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<ChapterList chapters={formattedChapters as any} novelSlug={novel.slug} />
|
||||
<ChapterList
|
||||
chapters={formattedChapters as any}
|
||||
novelSlug={novel.slug}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalChapters={totalChapters}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BookOpen, BookMarked, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { useBookmarks } from "@/lib/bookmark-context"
|
||||
import { getNovelById } from "@/lib/data"
|
||||
|
||||
|
||||
export default function BookshelfPage() {
|
||||
const { user } = useAuth()
|
||||
@@ -24,12 +24,10 @@ export default function BookshelfPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const bookmarkedNovels = bookmarks
|
||||
.map((b) => {
|
||||
const novel = getNovelById(b.novelId)
|
||||
return novel ? { novel, bookmark: b } : null
|
||||
})
|
||||
.filter(Boolean) as Array<{ novel: NonNullable<ReturnType<typeof getNovelById>>; bookmark: typeof bookmarks[number] }>
|
||||
const bookmarkedNovels = bookmarks.filter(b => b.novel).map(b => ({
|
||||
novel: b.novel as any,
|
||||
bookmark: b
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
|
||||
Reference in New Issue
Block a user