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:
2026-03-06 17:30:56 +07:00
parent ce805adb08
commit 75ed8e233b
31 changed files with 1853 additions and 687 deletions
+46
View File
@@ -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 })
}
}
+51
View File
@@ -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
View File
@@ -8,6 +8,8 @@ import { prisma } from "@/lib/prisma"
export async function GET(req: Request) { export async function GET(req: Request) {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId") const novelId = searchParams.get("novelId")
const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get("limit") || "20")
if (!novelId) { if (!novelId) {
return NextResponse.json({ error: "novelId is required" }, { status: 400 }) return NextResponse.json({ error: "novelId is required" }, { status: 400 })
@@ -15,8 +17,23 @@ export async function GET(req: Request) {
try { try {
await connectToMongoDB() await connectToMongoDB()
const chapters = await Chapter.find({ novelId }).sort({ number: 1 }).select("-content") const skip = (page - 1) * limit
return NextResponse.json(chapters)
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) { } catch (error) {
console.error("GET Chapter Error:", error) console.error("GET Chapter Error:", error)
return NextResponse.json({ error: "Failed to fetch chapters" }, { status: 500 }) 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 }) 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 })
}
}
+2 -6
View File
@@ -8,6 +8,7 @@ import path from "path"
import os from "os" import os from "os"
import { promises as fs } from "fs" import { promises as fs } from "fs"
import { convert } from "html-to-text" import { convert } from "html-to-text"
import { slugify } from "@/lib/utils"
export async function POST(req: Request) { export async function POST(req: Request) {
const session = await getServerSession(authOptions) 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" let novelDesc = metadata.description || "Chưa có giới thiệu"
// Generate base slug // Generate base slug
const baseSlug = novelTitle const baseSlug = slugify(novelTitle)
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
let slug = baseSlug let slug = baseSlug
let slugCounter = 1 let slugCounter = 1
+71
View File
@@ -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 })
}
}
+39
View File
@@ -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 })
}
}
+70 -9
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next" import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { slugify } from "@/lib/utils"
export async function GET() { export async function GET() {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -28,21 +29,22 @@ export async function POST(req: Request) {
try { try {
const data = await req.json() const data = await req.json()
const { title, authorName, description, genreIds = [] } = data
// Tạo slug từ title // Tạo slug từ title
const slug = data.title const slug = slugify(title)
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
const newNovel = await prisma.novel.create({ const newNovel = await prisma.novel.create({
data: { data: {
title: data.title, title,
slug: slug, slug: slug,
authorName: data.authorName, authorName,
description: data.description, description,
uploaderId: session.user.id, uploaderId: session.user.id,
genres: {
create: genreIds.map((id: string) => ({
genre: { connect: { id } }
}))
}
}, },
}) })
return NextResponse.json(newNovel, { status: 201 }) 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 }) 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 })
}
}
+47
View File
@@ -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 })
}
}
+44
View File
@@ -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 })
}
}
+108
View File
@@ -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 })
}
}
+420 -49
View File
@@ -14,7 +14,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } 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 { toast } from "sonner"
import Link from "next/link" import Link from "next/link"
@@ -26,6 +26,19 @@ interface Chapter {
createdAt: string 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() { function ChapterManager() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const novelId = searchParams.get("novelId") const novelId = searchParams.get("novelId")
@@ -35,20 +48,47 @@ function ChapterManager() {
const [openAdd, setOpenAdd] = useState(false) const [openAdd, setOpenAdd] = useState(false)
const [submitting, setSubmitting] = 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 // Form states
const [number, setNumber] = useState("") const [number, setNumber] = useState("")
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
const fetchChapters = async () => { const fetchChapters = async (pageToFetch = 1) => {
if (!novelId) return if (!novelId) return
setLoading(true)
try { 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") if (!res.ok) throw new Error("Lỗi fetch")
const data = await res.json() const data = await res.json()
setChapters(data) setChapters(data.chapters)
if (data.length > 0) { setTotalPages(data.totalPages)
setNumber((data[data.length - 1].number + 1).toString()) setCurrentPage(data.currentPage)
setTotalChapters(data.totalChapters)
if (data.chapters.length > 0) {
setNumber((data.chapters[data.chapters.length - 1].number + 1).toString())
} else { } else {
setNumber("1") setNumber("1")
} }
@@ -60,8 +100,10 @@ function ChapterManager() {
} }
useEffect(() => { useEffect(() => {
fetchChapters() if (novelId) {
}, [novelId]) fetchChapters(currentPage)
}
}, [novelId, currentPage])
const handleAddSubmit = async (e: React.FormEvent) => { const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault() 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) { if (!novelId) {
return ( return (
<div className="text-center py-20 text-muted-foreground"> <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 chương <FileText className="h-6 w-6 text-primary" /> Quản chương
</h1> </h1>
<Dialog open={openAdd} onOpenChange={setOpenAdd}> <div className="flex gap-3">
<DialogTrigger asChild> <Button variant="secondary" className="gap-2" onClick={() => {
<Button className="gap-2"> setOpenOptimize(true)
<Plus className="h-4 w-4" /> Đăng chương mới setPreviewMode(false)
</Button> }} disabled={chapters.length === 0}>
</DialogTrigger> <Wand2 className="h-4 w-4" /> Tối ưu hóa
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col"> </Button>
<DialogHeader>
<DialogTitle>Đăng Chương Mới</DialogTitle> <Dialog open={openAdd} onOpenChange={setOpenAdd}>
<DialogDescription> <DialogTrigger asChild>
Thêm nội dung một chương truyện đ gửi đến đc giả. <Button className="gap-2">
</DialogDescription> <Plus className="h-4 w-4" /> Đăng chương mới
</DialogHeader> </Button>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden"> </DialogTrigger>
<div className="grid grid-cols-4 gap-4"> <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
<div className="space-y-2 col-span-1"> <DialogHeader>
<label className="text-sm font-medium">Chương số</label> <DialogTitle>Đăng Chương Mới</DialogTitle>
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required /> <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>
<div className="space-y-2 col-span-3"> <div className="space-y-2 flex-1 flex flex-col h-full">
<label className="text-sm font-medium">Tên chương</label> <label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus /> <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> <DialogFooter className="mt-auto pt-4">
<div className="space-y-2 flex-1 flex flex-col h-full"> <Button type="submit" disabled={submitting}>
<label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label> {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Textarea Đăng ngay
value={content} </Button>
onChange={(e) => setContent(e.target.value)} </DialogFooter>
className="flex-1 w-full p-4 resize-none min-h-[300px]" </form>
placeholder="Paste văn bản của chương vào đây..." </DialogContent>
required </Dialog>
/>
</div> <Dialog open={openEdit} onOpenChange={setOpenEdit}>
<DialogFooter className="mt-auto pt-4"> <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
<Button type="submit" disabled={submitting}> <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 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" />} {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Đăng ngay Tiếp tục xóa
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </DialogContent>
</DialogContent> </Dialog>
</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 đá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:" thừa</p>
<p className="text-sm text-muted-foreground mt-1"> 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 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>
<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"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border"> <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 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-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td> <td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right space-x-3"> <td className="px-5 py-4 text-right">
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa nội dung</button> <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> </td>
</tr> </tr>
)) ))
@@ -189,6 +515,51 @@ function ChapterManager() {
</tbody> </tbody>
</table> </table>
</div> </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>
</div> </div>
) )
+336 -9
View File
@@ -13,7 +13,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } 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 { toast } from "sonner"
import Link from "next/link" import Link from "next/link"
@@ -26,6 +26,11 @@ interface Novel {
totalChapters: number totalChapters: number
} }
interface Genre {
id: string
name: string
}
export function NovelClient() { export function NovelClient() {
const [novels, setNovels] = useState<Novel[]>([]) const [novels, setNovels] = useState<Novel[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -37,6 +42,22 @@ export function NovelClient() {
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [authorName, setAuthorName] = useState("") const [authorName, setAuthorName] = useState("")
const [description, setDescription] = 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 () => { const fetchNovels = async () => {
try { 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(() => { useEffect(() => {
fetchNovels() 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) => { const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!title || !authorName || !description) { if (!title || !authorName || !description) {
@@ -67,7 +150,7 @@ export function NovelClient() {
const res = await fetch("/api/mod/truyen", { const res = await fetch("/api/mod/truyen", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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") if (!res.ok) throw new Error("Thêm mới thất bại")
toast.success("Đã thêm truyện thành công!") toast.success("Đã thêm truyện thành công!")
@@ -75,6 +158,8 @@ export function NovelClient() {
setTitle("") setTitle("")
setAuthorName("") setAuthorName("")
setDescription("") setDescription("")
setStatus("Đang ra")
setSelectedGenres([])
fetchNovels() fetchNovels()
} catch { } catch {
toast.error("Lỗi khi thêm truyện mới") 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm"> <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 Tải lên EPUB
</Button> </Button>
<Dialog open={openAdd} onOpenChange={setOpenAdd}> <Dialog open={openAdd} onOpenChange={(val) => {
setOpenAdd(val);
if (val) {
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName("");
}
}}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> Thêm truyện <Plus className="h-4 w-4" /> Thêm truyện
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Thêm Truyện Mới</DialogTitle> <DialogTitle>Thêm Truyện Mới</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -166,6 +343,38 @@ export function NovelClient() {
<label className="text-sm font-medium">Tác giả gốc</label> <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ữ" /> <Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Ví dụ: Vong Ngữ" />
</div> </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 thể loại nào</span>}
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Giới thiệu ngắn ( tả)</label> <label className="text-sm font-medium">Giới thiệu ngắn ( tả)</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Tóm tắt về câu chuyện..." rows={4} /> <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> </form>
</DialogContent> </DialogContent>
</Dialog> </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 ( 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 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>
</div> </div>
@@ -214,11 +529,23 @@ export function NovelClient() {
{novel.status} {novel.status}
</span> </span>
</td> </td>
<td className="px-5 py-4 text-right space-x-3"> <td className="px-5 py-4 text-right">
<Link href={`/mod/chuong?novelId=${novel.id}`} className="font-medium text-blue-500 hover:text-blue-600 hover:underline"> <div className="flex items-center justify-end gap-2">
Đăng chương <Link href={`/mod/chuong?novelId=${novel.id}`}>
</Link> <Button size="sm" variant="outline" className="h-8">
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa</button> 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> </td>
</tr> </tr>
)) ))
+6 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react" import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
import { NovelCard } from "@/components/novel-card" import { NovelCard } from "@/components/novel-card"
import { genres } from "@/lib/data"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
@@ -33,6 +33,10 @@ export default async function HomePage() {
orderBy: { rating: "desc" }, orderBy: { rating: "desc" },
}) })
const genres = await prisma.genre.findMany({
take: 8,
})
// get the most popular as featured (can be empty if DB is new) // get the most popular as featured (can be empty if DB is new)
const featured = popularNovels[0] 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" 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"> <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> </span>
<div> <div>
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h3> <h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h3>
+26 -46
View File
@@ -1,77 +1,57 @@
"use client"
import { use, useState, useMemo } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft } from "lucide-react" import { ChevronLeft } from "lucide-react"
import { getGenreBySlug, getNovelsByGenre } from "@/lib/data" import { prisma } from "@/lib/prisma"
import { NovelCard } from "@/components/novel-card" 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" import { notFound } from "next/navigation"
export default function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) { export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params) const { slug } = await params
const genre = getGenreBySlug(slug)
const genre = await prisma.genre.findUnique({
where: { slug }
})
if (!genre) { if (!genre) {
notFound() notFound()
} }
return <GenreContent genreName={genre.name} genreSlug={genre.slug} genreDescription={genre.description} /> const allNovels = await prisma.novel.findMany({
} where: {
genres: {
function GenreContent({ genreName, genreSlug, genreDescription }: { genreName: string; genreSlug: string; genreDescription: string }) { some: {
const [sortBy, setSortBy] = useState("latest") genreId: genre.id
const allNovels = getNovelsByGenre(genreSlug) }
}
const sortedNovels = useMemo(() => { },
const sorted = [...allNovels] orderBy: {
switch (sortBy) { updatedAt: "desc"
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())
} }
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 ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-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"> <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 <ChevronLeft className="h-4 w-4" /> Thể Loại
</Link> </Link>
<h1 className="text-2xl font-bold text-foreground">{genreName}</h1> <h1 className="text-2xl font-bold text-foreground">{genre.name}</h1>
<p className="mt-1 text-sm text-muted-foreground">{genreDescription}</p> <p className="mt-1 text-sm text-muted-foreground">{genre.description}</p>
</div> </div>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">{sortedNovels.length} truyện</p> <p className="text-sm text-muted-foreground">{allNovels.length} truyện</p>
<Select value={sortBy} onValueChange={setSortBy}> <div className="w-40" /> {/* Spacer for symmetry if we add sort later */}
<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>
</div> </div>
{sortedNovels.length === 0 ? ( {allNovels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<p className="text-lg font-medium">Chưa truyện nào</p> <p className="text-lg font-medium">Chưa truyện nào</p>
<p className="text-sm">Thể loại này chưa truyện, hãy quay lại sau.</p> <p className="text-sm">Thể loại này chưa truyện, hãy quay lại sau.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"> <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} /> <NovelCard key={novel.id} novel={novel} />
))} ))}
</div> </div>
+12 -4
View File
@@ -1,6 +1,6 @@
import Link from "next/link" import Link from "next/link"
import { BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react" 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> = { const iconMap: Record<string, React.ReactNode> = {
Sparkles: <Sparkles className="h-6 w-6" />, Sparkles: <Sparkles className="h-6 w-6" />,
@@ -15,13 +15,21 @@ const iconMap: Record<string, React.ReactNode> = {
Shield: <Shield className="h-6 w-6" />, 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 ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <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> <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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{genres.map((genre) => { {genres.map((genre) => {
const novelCount = getNovelsByGenre(genre.slug).length const novelCount = genre._count.novels
return ( return (
<Link <Link
key={genre.id} 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" 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"> <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> </span>
<div> <div>
<h2 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h2> <h2 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h2>
+86 -81
View File
@@ -1,106 +1,111 @@
"use client" // Server component instead of client component
import { useState, useMemo } from "react"
import { useSearchParams } from "next/navigation"
import { Search } from "lucide-react" import { Search } from "lucide-react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { NovelCard } from "@/components/novel-card" import { NovelCard } from "@/components/novel-card"
import { novels, genres, searchNovels } from "@/lib/data" import { prisma } from "@/lib/prisma"
export default function SearchPage() { export default async function SearchPage({
const searchParams = useSearchParams() searchParams,
const initialQuery = searchParams.get("q") || "" }: {
const initialSort = searchParams.get("sort") || "latest" 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) // Build where clause
const [sortBy, setSortBy] = useState(initialSort) let where: any = {}
const [genreFilter, setGenreFilter] = useState("all")
const [statusFilter, setStatusFilter] = useState("all")
const filteredNovels = useMemo(() => { if (q) {
let results = query.trim() ? searchNovels(query) : [...novels] where.OR = [
{ title: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
]
}
if (genreFilter !== "all") { if (genreFilter !== "all") {
results = results.filter((n) => n.genres.includes(genreFilter)) where.genres = {
some: {
genre: {
slug: genreFilter
}
}
} }
}
if (statusFilter !== "all") { if (statusFilter !== "all") {
results = results.filter((n) => n.status === statusFilter) where.status = statusFilter
} }
switch (sortBy) { // Build order clause
case "popular": let orderBy: any = {}
results.sort((a, b) => b.views - a.views) switch (sortBy) {
break case "popular":
case "rating": orderBy = { views: "desc" }
results.sort((a, b) => b.rating - a.rating) break
break case "rating":
case "name": orderBy = { rating: "desc" }
results.sort((a, b) => a.title.localeCompare(b.title)) break
break case "name":
case "latest": orderBy = { title: "asc" }
default: break
results.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()) case "latest":
} default:
orderBy = { updatedAt: "desc" }
}
return results const filteredNovels = await prisma.novel.findMany({
}, [query, sortBy, genreFilter, statusFilter]) where,
orderBy,
})
const genres = await prisma.genre.findMany()
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <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> <h1 className="mb-6 text-2xl font-bold text-foreground">Tìm Kiếm Truyện</h1>
{/* Search bar */} {/* Search and Filters - This requires a client component wrapper ideally, but for now we can rely on standard form submissions to update searchParams */}
<div className="relative mb-4"> <form method="GET" action="/tim-kiem">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <div className="relative mb-4">
<Input <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
type="search" <Input
placeholder="Tìm theo tên truyện, tác giả..." name="q"
className="pl-9" type="search"
value={query} placeholder="Tìm theo tên truyện, tác giả..."
onChange={(e) => setQuery(e.target.value)} className="pl-9"
/> defaultValue={q}
</div> />
</div>
{/* Filters */} <div className="mb-6 flex flex-wrap gap-3">
<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">
<Select value={genreFilter} onValueChange={setGenreFilter}> <option value="all">Tất cả thể loại</option>
<SelectTrigger className="w-40">
<SelectValue placeholder="Thể loại" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả thể loại</SelectItem>
{genres.map((g) => ( {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}> <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">
<SelectTrigger className="w-36"> <option value="all">Tất cả trạng thái</option>
<SelectValue placeholder="Trạng thái" /> <option value="Đang ra">Đang ra</option>
</SelectTrigger> <option value="Hoàn thành">Hoàn thành</option>
<SelectContent> <option value="Tạm ngưng">Tạm ngưng</option>
<SelectItem value="all">Tất cả</SelectItem> </select>
<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 value={sortBy} onValueChange={setSortBy}> <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">
<SelectTrigger className="w-36"> <option value="latest">Mới nhất</option>
<SelectValue placeholder="Sắp xếp" /> <option value="popular">Xem nhiều</option>
</SelectTrigger> <option value="rating">Đánh giá cao</option>
<SelectContent> <option value="name">Theo tên</option>
<SelectItem value="latest">Mới nhất</SelectItem> </select>
<SelectItem value="popular">Xem nhiều</SelectItem>
<SelectItem value="rating">Đánh giá cao</SelectItem> <button type="submit" className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium">Lọc</button>
<SelectItem value="name">Theo tên</SelectItem> </div>
</SelectContent> </form>
</Select>
</div>
{/* Results */} {/* Results */}
<p className="mb-4 text-sm text-muted-foreground">{filteredNovels.length} kết quả</p> <p className="mb-4 text-sm text-muted-foreground">{filteredNovels.length} kết quả</p>
+26 -1
View File
@@ -34,7 +34,32 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
} }
const maxChapter = await ChapterModel.countDocuments({ novelId: novel.id }) 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 hasPrev = chapterNumber > 1
const hasNext = chapterNumber < maxChapter const hasNext = chapterNumber < maxChapter
+56 -9
View File
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react" 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 { GenreBadge } from "@/components/genre-badge"
import { StarRating } from "@/components/star-rating" import { StarRating } from "@/components/star-rating"
import { ChapterList } from "@/components/chapter-list" import { ChapterList } from "@/components/chapter-list"
@@ -10,8 +10,18 @@ import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose" import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter" 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 { slug } = await params
const { page } = await searchParams
const currentPage = parseInt(page || "1")
const limit = 20
const novel = await prisma.novel.findUnique({ const novel = await prisma.novel.findUnique({
where: { slug }, where: { slug },
@@ -26,12 +36,27 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
notFound() 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 // Fetch chapters from MongoDB
await connectToMongoDB() await connectToMongoDB()
const chapters = await Chapter.find({ novelId: novel.id }) const skip = (currentPage - 1) * limit
.sort({ number: 1 })
.select("id novelId number title createdAt views") const [chapters, totalChapters] = await Promise.all([
.lean() 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 // Convert Mongoose documents to plain objects for Server Component
const formattedChapters = chapters.map(c => ({ 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 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) || [] const novelGenres = novel.genres.map(ng => ng.genre) || []
return ( return (
@@ -77,7 +118,7 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
</span> </span>
</div> </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"> <div className="flex flex-wrap gap-1.5">
{novelGenres.map((g) => ( {novelGenres.map((g) => (
@@ -99,7 +140,13 @@ export default async function NovelDetailPage({ params }: { params: Promise<{ sl
<section className="mt-8"> <section className="mt-8">
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2> <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"> <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> </div>
</section> </section>
+5 -7
View File
@@ -5,7 +5,7 @@ import { BookOpen, BookMarked, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context" import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context" import { useBookmarks } from "@/lib/bookmark-context"
import { getNovelById } from "@/lib/data"
export default function BookshelfPage() { export default function BookshelfPage() {
const { user } = useAuth() const { user } = useAuth()
@@ -24,12 +24,10 @@ export default function BookshelfPage() {
) )
} }
const bookmarkedNovels = bookmarks const bookmarkedNovels = bookmarks.filter(b => b.novel).map(b => ({
.map((b) => { novel: b.novel as any,
const novel = getNovelById(b.novelId) bookmark: b
return novel ? { novel, bookmark: b } : null }))
})
.filter(Boolean) as Array<{ novel: NonNullable<ReturnType<typeof getNovelById>>; bookmark: typeof bookmarks[number] }>
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
+66 -3
View File
@@ -1,14 +1,37 @@
import Link from "next/link" import Link from "next/link"
import { Eye } from "lucide-react" import { Eye } from "lucide-react"
import type { Chapter } from "@/lib/types" import type { Chapter } from "@/lib/types"
import { formatViews } from "@/lib/data" import { formatViews } from "@/lib/utils"
interface ChapterListProps { interface ChapterListProps {
chapters: Chapter[] chapters: {
id: string
novelId: string
number: number
title: string
createdAt: string
views: number
}[]
novelSlug: string novelSlug: string
currentPage: number
totalPages: number
totalChapters: number
} }
export function ChapterList({ chapters, novelSlug }: ChapterListProps) { 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]
}
export function ChapterList({ chapters, novelSlug, currentPage, totalPages, totalChapters }: ChapterListProps) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{chapters.map((chapter) => ( {chapters.map((chapter) => (
@@ -30,6 +53,46 @@ export function ChapterList({ chapters, novelSlug }: ChapterListProps) {
</div> </div>
</Link> </Link>
))} ))}
{totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border p-4 bg-muted/10">
<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">
<Link
href={currentPage > 1 ? `/truyen/${novelSlug}?page=${currentPage - 1}` : '#'}
className={`inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-input px-3 text-sm font-medium shadow-sm transition-colors ${currentPage <= 1 ? 'pointer-events-none opacity-50 bg-muted/50 text-muted-foreground' : 'bg-background hover:bg-accent hover:text-accent-foreground'}`}
aria-disabled={currentPage <= 1}
>
Trước
</Link>
{generatePagination(currentPage, totalPages).map((p, i) => (
<div key={i} className="hidden sm:block">
{p === '...' ? (
<span className="px-2 text-muted-foreground">...</span>
) : (
<Link
href={`/truyen/${novelSlug}?page=${p}`}
className={`inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-md border text-sm font-medium shadow-sm transition-colors ${currentPage === p ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : 'bg-background border-input hover:bg-accent hover:text-accent-foreground'}`}
>
{p}
</Link>
)}
</div>
))}
<Link
href={currentPage < totalPages ? `/truyen/${novelSlug}?page=${currentPage + 1}` : '#'}
className={`inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-input px-3 text-sm font-medium shadow-sm transition-colors ${currentPage >= totalPages ? 'pointer-events-none opacity-50 bg-muted/50 text-muted-foreground' : 'bg-background hover:bg-accent hover:text-accent-foreground'}`}
aria-disabled={currentPage >= totalPages}
>
Sau
</Link>
</div>
</div>
)}
</div> </div>
) )
} }
+23 -15
View File
@@ -19,22 +19,30 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
const [comments, setComments] = useState(initialComments) const [comments, setComments] = useState(initialComments)
const [content, setContent] = useState("") const [content, setContent] = useState("")
const handleSubmit = (e: React.FormEvent) => { const [isSubmitting, setIsSubmitting] = useState(false)
e.preventDefault()
if (!content.trim() || !user) return
const newComment: Comment = { const handleSubmit = async (e: React.FormEvent) => {
id: `c-${Date.now()}`, e.preventDefault()
userId: user.id, if (!content.trim() || !user || isSubmitting) return
username: user.username,
avatarColor: user.avatarColor, setIsSubmitting(true)
novelId, try {
chapterId, const res = await fetch(`/api/truyen/${novelId}/comments`, {
content: content.trim(), method: "POST",
createdAt: new Date().toISOString().split("T")[0], headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: content.trim(), chapterId })
})
if (res.ok) {
const newComment = await res.json()
setComments((prev) => [newComment, ...prev])
setContent("")
}
} catch (e) {
console.error(e)
} finally {
setIsSubmitting(false)
} }
setComments((prev) => [newComment, ...prev])
setContent("")
} }
return ( return (
@@ -62,7 +70,7 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
className="min-h-20 resize-none" className="min-h-20 resize-none"
/> />
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<Button type="submit" size="sm" disabled={!content.trim()}> <Button type="submit" size="sm" disabled={!content.trim() || isSubmitting}>
<Send className="mr-1.5 h-3.5 w-3.5" /> <Send className="mr-1.5 h-3.5 w-3.5" />
Gửi Gửi
</Button> </Button>
+1 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link" import Link from "next/link"
import { BookOpen, Eye, Star } from "lucide-react" import { BookOpen, Eye, Star } from "lucide-react"
import { formatViews } from "@/lib/data" import { formatViews } from "@/lib/utils"
export interface CardNovel { export interface CardNovel {
id: string id: string
+47 -17
View File
@@ -7,14 +7,50 @@ interface StarRatingProps {
rating: number rating: number
ratingCount: number ratingCount: number
interactive?: boolean interactive?: boolean
novelId?: string
onRate?: (value: number) => void onRate?: (value: number) => void
} }
export function StarRating({ rating, ratingCount, interactive = false, onRate }: StarRatingProps) { export function StarRating({ rating: initialRating, ratingCount: initialCount, interactive = false, novelId, onRate }: StarRatingProps) {
const [hover, setHover] = useState(0) const [hover, setHover] = useState(0)
const [selected, setSelected] = useState(0) const [selected, setSelected] = useState(0)
const [currentRating, setCurrentRating] = useState(initialRating)
const [currentCount, setCurrentCount] = useState(initialCount)
const [isSubmitting, setIsSubmitting] = useState(false)
const displayRating = hover || selected || rating const displayRating = hover || selected || currentRating
const handleRate = async (star: number) => {
if (!interactive || isSubmitting) return
setSelected(star)
if (onRate) {
onRate(star)
return
}
if (novelId) {
setIsSubmitting(true)
try {
const res = await fetch(`/api/truyen/${novelId}/rate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score: star })
})
if (res.ok) {
const data = await res.json()
setCurrentRating(data.rating)
setCurrentCount(data.ratingCount)
}
} catch (error) {
console.error("Failed to rate", error)
} finally {
setIsSubmitting(false)
}
}
}
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -23,30 +59,24 @@ export function StarRating({ rating, ratingCount, interactive = false, onRate }:
<button <button
key={star} key={star}
type="button" type="button"
disabled={!interactive} disabled={!interactive || isSubmitting}
className={`${interactive ? "cursor-pointer" : "cursor-default"}`} className={`${interactive && !isSubmitting ? "cursor-pointer" : "cursor-default opacity-80"}`}
onMouseEnter={() => interactive && setHover(star)} onMouseEnter={() => interactive && !isSubmitting && setHover(star)}
onMouseLeave={() => interactive && setHover(0)} onMouseLeave={() => interactive && !isSubmitting && setHover(0)}
onClick={() => { onClick={() => handleRate(star)}
if (interactive && onRate) {
setSelected(star)
onRate(star)
}
}}
aria-label={`${star} sao`} aria-label={`${star} sao`}
> >
<Star <Star
className={`h-4 w-4 transition-colors ${ className={`h-4 w-4 transition-colors ${star <= displayRating
star <= displayRating
? "fill-primary text-primary" ? "fill-primary text-primary"
: "text-muted-foreground/30" : "text-muted-foreground/30"
}`} }`}
/> />
</button> </button>
))} ))}
</div> </div>
<span className="text-sm font-semibold text-foreground">{rating.toFixed(1)}</span> <span className="text-sm font-semibold text-foreground">{currentRating.toFixed(1)}</span>
<span className="text-xs text-muted-foreground">({ratingCount} đánh giá)</span> <span className="text-xs text-muted-foreground">({currentCount} đánh giá)</span>
</div> </div>
) )
} }
+17 -2
View File
@@ -54,12 +54,22 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
const loadVoices = () => { const loadVoices = () => {
const available = speechSynthesis.getVoices() const available = speechSynthesis.getVoices()
// First try to find any Vietnamese voice ('vi-VN', 'Google Tiếng Việt', etc.)
const viVoices = available.filter( const viVoices = available.filter(
(v) => v.lang.startsWith("vi") || v.name.toLowerCase().includes("vietnam") (v) => v.lang.startsWith("vi") || v.name.toLowerCase().includes("vietnam") || v.name.toLowerCase().includes("tiếng việt")
) )
const allUsable = viVoices.length > 0 ? viVoices : available.slice(0, 10)
// Filter out overly robotic generic fallbacks if we have good ones
const goodViVoices = viVoices.filter(v => v.name.includes("Google") || v.name.includes("Microsoft") || v.name.includes("Natural"))
const preferredViVoices = goodViVoices.length > 0 ? goodViVoices : viVoices
// If we still have NO vi voices, fallback to ALL voices so the user isn't stuck with an empty list
const allUsable = preferredViVoices.length > 0 ? preferredViVoices : available
setVoices(allUsable) setVoices(allUsable)
if (allUsable.length > 0 && !selectedVoiceURI) { if (allUsable.length > 0 && !selectedVoiceURI) {
// Automatically default to the first Vietnamese voice if available, otherwise just the first system voice
setSelectedVoiceURI(allUsable[0].voiceURI) setSelectedVoiceURI(allUsable[0].voiceURI)
} }
} }
@@ -170,6 +180,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
if (e.error !== "canceled" && e.error !== "interrupted") { if (e.error !== "canceled" && e.error !== "interrupted") {
setIsPlaying(false) setIsPlaying(false)
releaseWakeLock() releaseWakeLock()
if (e.error === "synthesis-failed" || e.error === "network") {
// Toast notification is tricky here without importing sonner, let's just log and stop cleanly without crashing
console.warn("Trình duyệt không hỗ trợ đọc giọng nói này hoặc bị lỗi kết nối.")
}
} }
} }
+57 -41
View File
@@ -19,62 +19,78 @@ export function BookmarkProvider({ children }: { children: ReactNode }) {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
useEffect(() => { useEffect(() => {
if (user) { let mounted = true
const stored = localStorage.getItem(`truyen-chu-bookmarks-${user.id}`) const fetchBookmarks = async () => {
if (stored) { if (!user) {
try {
setBookmarks(JSON.parse(stored))
} catch {
setBookmarks([])
}
} else {
setBookmarks([]) setBookmarks([])
return
}
try {
const res = await fetch("/api/user/bookmarks")
if (res.ok) {
const data = await res.json()
if (mounted) setBookmarks(data)
}
} catch (e) {
console.error("Failed to fetch bookmarks", e)
} }
} else {
setBookmarks([])
} }
fetchBookmarks()
return () => { mounted = false }
}, [user]) }, [user])
const persist = useCallback((newBookmarks: Bookmark[]) => { const toggleBookmark = useCallback(async (novelId: string) => {
if (user) { if (!user) return
localStorage.setItem(`truyen-chu-bookmarks-${user.id}`, JSON.stringify(newBookmarks))
}
}, [user])
const isBookmarked = useCallback((novelId: string) => { // Optimistic update
return bookmarks.some((b) => b.novelId === novelId)
}, [bookmarks])
const toggleBookmark = useCallback((novelId: string) => {
setBookmarks((prev) => { setBookmarks((prev) => {
const exists = prev.find((b) => b.novelId === novelId) const exists = prev.find((b) => b.novelId === novelId)
const next = exists if (exists) {
? prev.filter((b) => b.novelId !== novelId) return prev.filter((b) => b.novelId !== novelId)
: [...prev, { novelId, addedAt: new Date().toISOString() }]
persist(next)
return next
})
}, [persist])
const updateProgress = useCallback((novelId: string, chapterId: string, chapterNumber: number) => {
setBookmarks((prev) => {
const idx = prev.findIndex((b) => b.novelId === novelId)
let next: Bookmark[]
if (idx >= 0) {
next = [...prev]
next[idx] = { ...next[idx], lastChapterId: chapterId, lastChapterNumber: chapterNumber }
} else {
next = [...prev, { novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber, addedAt: new Date().toISOString() }]
} }
persist(next) return [...prev, { novelId, addedAt: new Date().toISOString() } as any]
return next
}) })
}, [persist])
try {
await fetch("/api/user/bookmarks", {
method: "POST",
body: JSON.stringify({ action: "toggle", novelId })
})
} catch (e) {
console.error(e)
}
}, [user])
const updateProgress = useCallback(async (novelId: string, chapterId: string, chapterNumber: number) => {
if (!user) return
// Optimistic update
setBookmarks((prev) => {
const exists = prev.find((b) => b.novelId === novelId)
if (exists) {
return prev.map(b => b.novelId === novelId ? { ...b, lastChapterId: chapterId, lastChapterNumber: chapterNumber } : b)
}
return [...prev, { novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber, addedAt: new Date().toISOString() } as any]
})
try {
await fetch("/api/user/bookmarks", {
method: "POST",
body: JSON.stringify({ action: "updateProgress", novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber })
})
} catch (e) {
console.error(e)
}
}, [user])
const getProgress = useCallback((novelId: string) => { const getProgress = useCallback((novelId: string) => {
return bookmarks.find((b) => b.novelId === novelId) return bookmarks.find((b) => b.novelId === novelId)
}, [bookmarks]) }, [bookmarks])
const isBookmarked = useCallback((novelId: string) => {
return bookmarks.some((b) => b.novelId === novelId)
}, [bookmarks])
return ( return (
<BookmarkContext.Provider value={{ bookmarks, isBookmarked, toggleBookmark, updateProgress, getProgress }}> <BookmarkContext.Provider value={{ bookmarks, isBookmarked, toggleBookmark, updateProgress, getProgress }}>
{children} {children}
-379
View File
@@ -1,379 +0,0 @@
import type { Genre, Novel, Chapter, Comment } from "./types"
// ============ GENRES ============
export const genres: Genre[] = [
{ id: "1", name: "Tiên Hiệp", slug: "tien-hiep", description: "Tu tiên, luyện đạo, thăng cấp thành tiên", icon: "Sparkles" },
{ id: "2", name: "Huyền Huyễn", slug: "huyen-huyen", description: "Thế giới huyền ảo, phép thuật, dị năng", icon: "Flame" },
{ id: "3", name: "Ngôn Tình", slug: "ngon-tinh", description: "Tình yêu lãng mạn, ngọt ngào", icon: "Heart" },
{ id: "4", name: "Kiếm Hiệp", slug: "kiem-hiep", description: "Giang hồ, võ lâm, kiếm khách", icon: "Sword" },
{ id: "5", name: "Đô Thị", slug: "do-thi", description: "Cuộc sống thành phố, hiện đại", icon: "Building" },
{ id: "6", name: "Khoa Huyễn", slug: "khoa-huyen", description: "Khoa học viễn tưởng, tương lai", icon: "Rocket" },
{ id: "7", name: "Lịch Sử", slug: "lich-su", description: "Bối cảnh lịch sử, cung đấu, chiến tranh", icon: "Crown" },
{ id: "8", name: "Hài Hước", slug: "hai-huoc", description: "Truyện vui, giải trí, nhẹ nhàng", icon: "Laugh" },
{ id: "9", name: "Trinh Thám", slug: "trinh-tham", description: "Phá án, bí ẩn, ly kỳ", icon: "Search" },
{ id: "10", name: "Quân Sự", slug: "quan-su", description: "Chiến tranh, quân đội, chiến lược", icon: "Shield" },
]
// ============ NOVELS ============
export const novels: Novel[] = [
{
id: "1",
title: "Phàm Nhân Tu Tiên",
slug: "pham-nhan-tu-tien",
authorName: "Vong Ngữ",
coverColor: "from-amber-500 to-orange-600",
description: "Hàn Lập, một thiếu niên nghèo khó từ một ngôi làng nhỏ, tình cờ bước vào con đường tu tiên. Không có thiên phú xuất chúng, không có bối cảnh gia thế, chỉ bằng sự kiên trì và trí tuệ phi thường, hắn từng bước vượt qua muôn vàn khó khăn, chiến đấu với yêu ma quỷ quái, đối đầu với các thế lực lớn trong tu chân giới. Từ một phàm nhân bình thường, Hàn Lập dần dần khám phá ra bí mật của thiên địa, tìm kiếm con đường trường sinh bất lão.",
genres: ["tien-hiep", "huyen-huyen"],
status: "Hoàn thành",
totalChapters: 2446,
views: 5840000,
rating: 4.8,
ratingCount: 12500,
bookmarkCount: 89000,
lastUpdated: "2025-12-15",
createdAt: "2020-01-15",
},
{
id: "2",
title: "Đấu Phá Thương Khung",
slug: "dau-pha-thuong-khung",
authorName: "Thiên Tằm Thổ Đậu",
coverColor: "from-blue-500 to-indigo-600",
description: "Tiêu Viêm, từng là thiên tài trẻ tuổi nhất Ô Thản thành, bỗng nhiên mất đi toàn bộ đấu khí vào năm 11 tuổi. Ba năm sau, cậu tình cờ khám phá ra bí mật ẩn giấu trong chiếc nhẫn truyền gia, từ đó bắt đầu hành trình tu luyện phi thường. Với sự giúp đỡ của Dược Lão, Tiêu Viêm quyết tâm lấy lại vinh quang đã mất và chinh phục đỉnh cao của thế giới đấu khí.",
genres: ["huyen-huyen", "tien-hiep"],
status: "Hoàn thành",
totalChapters: 1648,
views: 4920000,
rating: 4.6,
ratingCount: 10800,
bookmarkCount: 75000,
lastUpdated: "2025-11-20",
createdAt: "2019-06-10",
},
{
id: "3",
title: "Hoa Thiên Cốt",
slug: "hoa-thien-cot",
authorName: "Fresh Quả Quả",
coverColor: "from-pink-400 to-rose-500",
description: "Hoa Thiên Cốt kể về câu chuyện tình yêu xuyên suốt ba kiếp giữa Hoa Thiên Cốt và Bạch Tử Họa. Nàng là đệ tử của Trường Lưu môn, chàng là chưởng môn Trường Lưu - sư phụ của nàng. Mối tình cấm đoán giữa sư đồ, những hiểu lầm, hy sinh và sự kiên trung trong tình yêu khiến độc giả không khỏi xúc động.",
genres: ["ngon-tinh", "tien-hiep"],
status: "Hoàn thành",
totalChapters: 580,
views: 3200000,
rating: 4.5,
ratingCount: 8900,
bookmarkCount: 62000,
lastUpdated: "2025-10-05",
createdAt: "2021-03-22",
},
{
id: "4",
title: "Anh Hùng Xạ Điêu",
slug: "anh-hung-xa-dieu",
authorName: "Kim Dung",
coverColor: "from-emerald-500 to-teal-600",
description: "Câu chuyện về Quách Tĩnh, một chàng trai chất phác nhưng kiên trì, cùng Hoàng Dung, cô gái thông minh tuyệt đỉnh. Giữa bối cảnh đất nước bị xâm lăng, hai người cùng nhau trải qua bao sóng gió giang hồ, học được những tuyệt kỹ võ công, và cuối cùng trở thành anh hùng dân tộc.",
genres: ["kiem-hiep", "lich-su"],
status: "Hoàn thành",
totalChapters: 240,
views: 7800000,
rating: 4.9,
ratingCount: 15600,
bookmarkCount: 120000,
lastUpdated: "2025-08-10",
createdAt: "2018-01-01",
},
{
id: "5",
title: "Toàn Chức Cao Thủ",
slug: "toan-chuc-cao-thu",
authorName: "Hồ Điệp Lam",
coverColor: "from-cyan-500 to-blue-600",
description: "Diệp Tu, đỉnh cao của giới game Glory, bị buộc phải rời đội tuyển chuyên nghiệp. Nhưng với mười năm kinh nghiệm và kỹ thuật vô song, anh bắt đầu lại từ đầu tại một quán net nhỏ. Với tài khoản mới và quyết tâm mãnh liệt, Diệp Tu từng bước quay trở lại đỉnh cao vinh quang.",
genres: ["do-thi", "hai-huoc"],
status: "Hoàn thành",
totalChapters: 1728,
views: 3600000,
rating: 4.7,
ratingCount: 9200,
bookmarkCount: 68000,
lastUpdated: "2025-09-28",
createdAt: "2020-07-15",
},
{
id: "6",
title: "Thôn Phệ Tinh Không",
slug: "thon-phe-tinh-khong",
authorName: "Thần Đông",
coverColor: "from-violet-500 to-purple-600",
description: "Trong tương lai, khi Trái Đất trải qua biến cố lớn, con người phát hiện ra năng lực chiến đấu tiềm ẩn. La Phong, một thanh niên bình thường, tình cờ gặp được một sinh vật ngoài hành tinh đặc biệt, từ đó bắt đầu hành trình chinh phục vũ trụ bao la. Từ Trái Đất đến các vì sao, La Phong dần trở thành chiến sĩ mạnh nhất thiên hà.",
genres: ["khoa-huyen", "huyen-huyen"],
status: "Đang ra",
totalChapters: 1890,
views: 2800000,
rating: 4.4,
ratingCount: 7600,
bookmarkCount: 52000,
lastUpdated: "2026-03-01",
createdAt: "2021-11-08",
},
{
id: "7",
title: "Khánh Dư Niên",
slug: "khanh-du-nien",
authorName: "Miêu Nị",
coverColor: "from-yellow-500 to-amber-600",
description: "Phạm Nhàn, một thanh niên từ thế giới hiện đại, xuyên không đến một thế giới cổ đại với ký ức về một nền văn minh đã mất. Với kiến thức từ kiếp trước, hắn dần vượt qua các âm mưu cung đình, chiến đấu với các thế lực ngầm, và khám phá ra bí mật kinh thiên về nguồn gốc của thế giới này.",
genres: ["lich-su", "huyen-huyen"],
status: "Hoàn thành",
totalChapters: 1168,
views: 4100000,
rating: 4.7,
ratingCount: 11200,
bookmarkCount: 85000,
lastUpdated: "2025-07-20",
createdAt: "2019-12-01",
},
{
id: "8",
title: "Yêu Thần Ký",
slug: "yeu-than-ky",
authorName: "Phát Tiêu Đích Mao Nhi",
coverColor: "from-red-500 to-rose-600",
description: "Nhiếp Ly, vị Yêu Thần hùng mạnh nhất, bị phản bội và hy sinh trong trận chiến cuối cùng. Nhưng khi tỉnh dậy, hắn phát hiện mình đã quay trở lại thời niên thiếu. Với kinh nghiệm và kiến thức từ kiếp trước, Nhiếp Ly quyết tâm thay đổi vận mệnh, cứu lấy những người thân yêu và ngăn chặn thảm họa sắp xảy đến.",
genres: ["huyen-huyen", "tien-hiep"],
status: "Đang ra",
totalChapters: 956,
views: 2100000,
rating: 4.3,
ratingCount: 6500,
bookmarkCount: 43000,
lastUpdated: "2026-02-28",
createdAt: "2022-05-10",
},
{
id: "9",
title: "Thiên Quan Tứ Phúc",
slug: "thien-quan-tu-phuc",
authorName: "Mặc Hương Đồng Khứu",
coverColor: "from-sky-400 to-indigo-500",
description: "Tạ Liên, thái tử triều đại Tiên Lạc, ba lần phi thăng thành thiên quan và ba lần bị đánh rơi. Tám trăm năm sau, ngài lại một lần nữa phi thăng, nhưng lần này không ai chào đón. Trong hành trình thu thập công đức, Tạ Liên gặp lại Hoa Thành - một Quỷ vương bí ẩn có mối quan hệ sâu xa với ngài từ tám trăm năm trước.",
genres: ["ngon-tinh", "huyen-huyen"],
status: "Hoàn thành",
totalChapters: 244,
views: 5200000,
rating: 4.9,
ratingCount: 14800,
bookmarkCount: 98000,
lastUpdated: "2025-06-15",
createdAt: "2020-09-01",
},
{
id: "10",
title: "Thám Tử Lừng Danh",
slug: "tham-tu-lung-danh",
authorName: "Linh Vũ",
coverColor: "from-slate-500 to-zinc-700",
description: "Lâm Phong, một thanh tra cảnh sát trẻ tuổi với khả năng quan sát phi thường, liên tiếp phá giải những vụ án bí ẩn nhất thành phố. Mỗi vụ án đều ẩn chứa những bí mật đen tối, và càng đi sâu, Lâm Phong càng phát hiện ra một tổ chức tội phạm khổng lồ đang ẩn nấp trong bóng tối.",
genres: ["trinh-tham", "do-thi"],
status: "Đang ra",
totalChapters: 678,
views: 1800000,
rating: 4.5,
ratingCount: 5400,
bookmarkCount: 35000,
lastUpdated: "2026-03-02",
createdAt: "2023-01-20",
},
{
id: "11",
title: "Đại Quân Sư",
slug: "dai-quan-su",
authorName: "Trần Phong",
coverColor: "from-green-600 to-emerald-700",
description: "Trương Lương, một thiên tài quân sự thời hiện đại, xuyên không về thời Tam Quốc. Với kiến thức chiến thuật vượt thời đại, hắn trở thành quân sư cho một thế lực nhỏ và từng bước thay đổi cục diện thiên hạ. Những trận chiến sử thi, những mưu kế thâm sâu, tất cả đều được tái hiện qua góc nhìn của một người hiện đại.",
genres: ["quan-su", "lich-su"],
status: "Đang ra",
totalChapters: 420,
views: 1500000,
rating: 4.4,
ratingCount: 4800,
bookmarkCount: 28000,
lastUpdated: "2026-03-03",
createdAt: "2023-06-15",
},
{
id: "12",
title: "Vạn Giới Thần Chủ",
slug: "van-gioi-than-chu",
authorName: "Nhất Niệm Vĩnh Hằng",
coverColor: "from-orange-500 to-red-600",
description: "Lâm Phàm tình cờ có được một mảnh ngọc bội cổ xưa có thể mở cánh cửa đến vạn giới. Mỗi thế giới đều có quy tắc riêng, sức mạnh riêng, và nguy hiểm riêng. Lâm Phàm phải chinh phục từng thế giới, thu thập sức mạnh và trí tuệ, để cuối cùng trở thành bá chủ vạn giới.",
genres: ["tien-hiep", "huyen-huyen"],
status: "Đang ra",
totalChapters: 1200,
views: 2300000,
rating: 4.2,
ratingCount: 5800,
bookmarkCount: 38000,
lastUpdated: "2026-03-04",
createdAt: "2022-08-20",
},
]
// ============ SAMPLE CHAPTER CONTENT ============
const sampleContent = `
Buổi sáng hôm ấy, khi ánh nắng đầu tiên xuyên qua lớp sương mù dày đặc bao phủ ngọn núi, một bóng người mờ ảo xuất hiện trên con đường mòn dẫn lên đỉnh.
Gió thổi nhẹ, mang theo hương thơm của hoa dại hai bên đường. Những giọt sương còn đọng trên lá cỏ lấp lánh như những viên ngọc nhỏ dưới ánh mặt trời. Cảnh vật yên bình đến lạ thường, hoàn toàn trái ngược với tâm trạng hỗn loạn bên trong người thanh niên đang bước đi.
"Ta phải mạnh hơn nữa," hắn tự nhủ, đôi mắt nhìn thẳng về phía trước với ánh quyết tâm. "Chỉ có sức mạnh mới có thể bảo vệ được những người quan trọng."
Hắn dừng lại trước một tảng đá lớn, nơi có khắc một dòng chữ cổ đã mờ theo thời gian. Dù không đọc được hết, nhưng hắn hiểu ý nghĩa của nó - đây là ranh giới giữa thế giới phàm trần và cõi tu tiên.
Hít một hơi thật sâu, hắn bước qua tảng đá. Ngay lập tức, linh khí tràn ngập khắp cơ thể, mỗi tế bào đều rung động như được tiếp thêm sức sống mới. Cảm giác này... thật tuyệt vời.
"Chào mừng ngươi đến Thanh Vân Sơn," một giọng nói trầm ấm vang lên từ phía trước. Một vị lão nhân áo trắng xuất hiện, tóc bạc phơ nhưng khuôn mặt hồng hào, đôi mắt sáng như sao.
"Vãn bối bái kiến tiền bối," hắn vội vàng cúi đầu hành lễ.
Vị lão nhân mỉm cười, vẫy tay: "Không cần đa lễ. Ta đã chờ ngươi rất lâu rồi. Ngươi có muốn biết vì sao ta biết ngươi sẽ đến đây không?"
Hắn ngẩng đầu, đôi mắt tràn đầy tò mò. Đây chính là bước ngoặt thay đổi cuộc đời hắn mãi mãi.
Lão nhân quay người, bước chân nhẹ nhàng như lướt trên mặt đất: "Đi theo ta. Con đường phía trước còn rất dài, nhưng mỗi bước đi đều có ý nghĩa của nó."
Và thế là, câu chuyện về một phàm nhân bước chân vào thế giới tu tiên đã chính thức bắt đầu. Không ai biết được rằng, chàng trai trẻ bình thường này, một ngày nào đó sẽ khiến cả tam giới phải rung chuyển.
Hai người đi dọc theo con đường đá quanh co, xuyên qua những rừng trúc xanh mướt. Tiếng suối chảy róc rách đâu đó phía xa, hòa cùng tiếng chim hót líu lo tạo nên một bản nhạc thiên nhiên tuyệt đẹp.
"Thanh Vân Sơn có bảy đỉnh," vị lão nhân vừa đi vừa giải thích. "Mỗi đỉnh đại diện cho một phái tu luyện khác nhau. Ngươi sẽ được phân vào đỉnh phù hợp nhất với căn cốt của mình."
"Căn cốt?" hắn hỏi, không giấu được sự tò mò.
"Đúng vậy. Mỗi người đều có căn cốt khác nhau, quyết định con đường tu luyện của họ. Có người sinh ra với kim linh căn, thích hợp luyện kiếm. Có người mang mộc linh căn, giỏi về y thuật và đan dược. Và cũng có những người..."
Lão nhân dừng lại, nhìn hắn với ánh mắt đầy ý nghĩa: "...có những người mang trong mình căn cốt đặc biệt mà ngàn năm mới xuất hiện một lần."
Tim hắn đập nhanh hơn. Liệu mình có phải là người như vậy không? Hay chỉ là một phàm nhân bình thường giữa biết bao thiên tài?
Dù thế nào đi nữa, hắn đã quyết định rồi. Dù phải đối mặt với bao nhiêu khó khăn, dù con đường phía trước có gian nan đến đâu, hắn sẽ không bao giờ bỏ cuộc.
Bởi vì, đó là lời hứa hắn đã thề với bản thân mình.
`
// ============ CHAPTERS ============
function generateChapters(novelId: string, count: number): Chapter[] {
const chapterTitles: Record<string, string[]> = {
"1": ["Thiếu niên nhập môn", "Mặc Đại Phu", "Bảy Huyền Môn", "Luyện khí kỳ", "Thất tinh kiếm", "Đại chiến đầu tiên", "Bí mật chiếc nhẫn", "Huyết sắc thí luyện", "Thiên cơ bất khả lộ", "Kim đan kỳ"],
"2": ["Thiên tài sụp đổ", "Dược Lão bí ẩn", "Đấu khí hồi phục", "Thi đấu gia tộc", "Vân Lam tông", "Xà mãng thôn", "Hỏa diễm cốc", "Thiên giai đấu kỹ", "Hắc giác vực", "Đấu đế truyền thừa"],
"3": ["Trường Lưu sơn", "Sư phụ bí ẩn", "Kiếm pháp nhập môn", "Thi luyện bắt đầu", "Yêu thần xuất hiện", "Ký ức tiền kiếp", "Huyết lệ chi hoa", "Đại chiến ma tộc", "Tam sinh duyên", "Tình kiếp luân hồi"],
"4": ["Gió tanh mưa máu", "Quách Tĩnh luyện công", "Hoàng Dung xuất hiện", "Đào Hoa Đảo", "Cửu Âm Chân Kinh", "Hoa Sơn luận kiếm", "Tương Dương thành", "Đại chiến Kim quốc", "Anh hùng hội", "Thiên hạ đệ nhất"],
"5": ["Vinh quang đánh mất", "Quán net nhỏ", "Tài khoản mới", "Đồng đội cũ", "Giải đấu mùa xuân", "Chiến thuật mới", "Đối thủ xứng tầm", "Bán kết kịch tính", "Vinh quang trở lại", "Đỉnh cao Glory"],
"6": ["Trái Đất biến cố", "Năng lực thức tỉnh", "Sinh vật ngoài hành tinh", "Hành tinh số 9", "Chiến binh tinh cầu", "Vương quốc băng giá", "Đại chiến thiên hà", "Hố đen vũ trụ", "Siêu cấp tiến hóa", "Bá chủ tinh không"],
"7": ["Xuyên không kỳ duyên", "Đan Miếu kỳ ngộ", "Kinh đô phong vân", "Bắc Tề sứ đoàn", "Giám Sát Viện", "Ám sát chi vương", "Đại Đông Sơn", "Thiên tử thủ đoạn", "Khánh quốc phong vân", "Nhất niệm vĩnh hằng"],
"8": ["Quay ngược thời gian", "Khởi đầu mới", "Ngọn lửa Yêu Linh", "Yêu Thần truyền thừa", "Hắc Hỏa gia tộc", "Thánh linh sơn", "Ma thú rừng su", "Đại chiến Yêu tộc", "Linh hồn thức tỉnh", "Đỉnh cao Yêu Thần"],
"9": ["Ba lần phi thăng", "Phế thần rơi rụng", "Thu rác kiếm tiền", "Quỷ vương xuất hiện", "Bàn Ty động", "Bán Nguyệt Quan", "Cổ thư bí mật", "Tứ đại hại", "Thiên đình gió mây", "Hoa nở thành đôi"],
"10": ["Vụ án đầu tiên", "Dấu vết bí ẩn", "Nhân chứng im lặng", "Bóng tối rình rập", "Sự thật phơi bày", "Kẻ chủ mưu", "Mạng lưới tội ác", "Đối mặt quỷ dữ", "Công lý phán xét", "Ánh sáng cuối đường"],
"11": ["Xuyên về Tam Quốc", "Quân sư nhỏ", "Trận chiến đầu tiên", "Liên minh bất ngờ", "Xích Bích phong vân", "Mưu kế thâm sâu", "Thiên hạ tam phân", "Bắc phạt đại kế", "Long tranh hổ đấu", "Thống nhất thiên hạ"],
"12": ["Ngọc bội cổ xưa", "Thế giới đầu tiên", "Quy tắc dị giới", "Sức mạnh nguyên thủy", "Cánh cửa thứ hai", "Vạn giới chi bí", "Tử thần thế giới", "Linh hồn bất diệt", "Vạn giới đại chiến", "Thần chủ giáng lâm"],
}
const titles = chapterTitles[novelId] || chapterTitles["1"]
const displayCount = Math.min(count, 10)
const chapters: Chapter[] = []
for (let i = 1; i <= displayCount; i++) {
chapters.push({
id: `${novelId}-${i}`,
novelId,
number: i,
title: titles[(i - 1) % titles.length],
content: sampleContent,
views: ((parseInt(novelId) * 7919 + i * 6131) % 50000) + 5000,
createdAt: new Date(2025, 0, i * 3).toISOString().split("T")[0],
})
}
return chapters
}
// ============ COMMENTS ============
export const comments: Comment[] = [
{ id: "c1", userId: "u1", username: "BookLover99", avatarColor: "bg-blue-500", novelId: "1", content: "Truyện hay quá! Phàm Nhân Tu Tiên là kinh điển của thể loại tiên hiệp.", createdAt: "2026-02-15" },
{ id: "c2", userId: "u2", username: "TienHiepFan", avatarColor: "bg-green-500", novelId: "1", content: "Đọc đi đọc lại mấy lần vẫn thấy hay. Hàn Lập là nhân vật nam chính được xây dựng tốt nhất.", createdAt: "2026-02-20" },
{ id: "c3", userId: "u3", username: "MeowReader", avatarColor: "bg-pink-500", novelId: "1", content: "Plot twist ở phần sau quá đỉnh, không đoán được luôn.", createdAt: "2026-03-01" },
{ id: "c4", userId: "u4", username: "NightOwl", avatarColor: "bg-amber-500", novelId: "2", content: "Tiêu Viêm quá bá đạo, mê truyện này từ lâu rồi.", createdAt: "2026-01-10" },
{ id: "c5", userId: "u5", username: "StoryHunter", avatarColor: "bg-violet-500", novelId: "2", content: "Thích nhất đoạn Tiêu Viêm thi đấu ở Gia Mã đế quốc.", createdAt: "2026-02-05" },
{ id: "c6", userId: "u1", username: "BookLover99", avatarColor: "bg-blue-500", novelId: "3", content: "Ngôn tình hay nhất tôi từng đọc. Khóc hết nước mắt.", createdAt: "2026-01-25" },
{ id: "c7", userId: "u3", username: "MeowReader", avatarColor: "bg-pink-500", novelId: "4", content: "Kim Dung viết kiếm hiệp đỉnh nhất, không ai sánh bằng.", createdAt: "2026-02-10" },
{ id: "c8", userId: "u2", username: "TienHiepFan", avatarColor: "bg-green-500", novelId: "9", content: "Thiên Quan Tứ Phúc quá hay, đọc xong muốn đọc lại ngay.", createdAt: "2026-03-02" },
{ id: "c9", userId: "u4", username: "NightOwl", avatarColor: "bg-amber-500", novelId: "7", content: "Khánh Dư Niên xây dựng thế giới quá tốt, mỗi chi tiết đều có ý nghĩa.", createdAt: "2026-02-28" },
{ id: "c10", userId: "u5", username: "StoryHunter", avatarColor: "bg-violet-500", novelId: "5", content: "Ai thích game thì phải đọc Toàn Chức Cao Thủ, cực kỳ hấp dẫn.", createdAt: "2026-01-15" },
{ id: "c11", userId: "u1", username: "BookLover99", avatarColor: "bg-blue-500", novelId: "1", chapterId: "1-1", content: "Chương mở đầu rất cuốn hút!", createdAt: "2026-02-18" },
{ id: "c12", userId: "u3", username: "MeowReader", avatarColor: "bg-pink-500", novelId: "1", chapterId: "1-1", content: "Cách miêu tả cảnh vật rất sinh động.", createdAt: "2026-02-19" },
]
// ============ DATA ACCESS FUNCTIONS ============
export function getNovelById(id: string): Novel | undefined {
return novels.find((n) => n.id === id)
}
export function getNovelBySlug(slug: string): Novel | undefined {
return novels.find((n) => n.slug === slug)
}
export function getNovelsByGenre(genreSlug: string): Novel[] {
return novels.filter((n) => n.genres.includes(genreSlug))
}
export function getGenreBySlug(slug: string): Genre | undefined {
return genres.find((g) => g.slug === slug)
}
export function getChaptersByNovelId(novelId: string): Chapter[] {
const novel = novels.find((n) => n.id === novelId)
if (!novel) return []
return generateChapters(novelId, novel.totalChapters)
}
export function getChapter(novelId: string, chapterNumber: number): Chapter | undefined {
const chapters = getChaptersByNovelId(novelId)
return chapters.find((c) => c.number === chapterNumber)
}
export function getCommentsByNovelId(novelId: string): Comment[] {
return comments.filter((c) => c.novelId === novelId && !c.chapterId)
}
export function getCommentsByChapterId(chapterId: string): Comment[] {
return comments.filter((c) => c.chapterId === chapterId)
}
export function searchNovels(query: string): Novel[] {
const q = query.toLowerCase()
return novels.filter(
(n) =>
n.title.toLowerCase().includes(q) ||
n.authorName.toLowerCase().includes(q) ||
n.description.toLowerCase().includes(q)
)
}
export function getPopularNovels(limit = 6): Novel[] {
return [...novels].sort((a, b) => b.views - a.views).slice(0, limit)
}
export function getLatestNovels(limit = 6): Novel[] {
return [...novels].sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()).slice(0, limit)
}
export function getTopRatedNovels(limit = 6): Novel[] {
return [...novels].sort((a, b) => b.rating - a.rating).slice(0, limit)
}
export function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + "M"
if (views >= 1000) return (views / 1000).toFixed(1) + "K"
return views.toString()
}
+1
View File
@@ -59,4 +59,5 @@ export interface Bookmark {
lastChapterId?: string lastChapterId?: string
lastChapterNumber?: number lastChapterNumber?: number
addedAt: string addedAt: string
novel?: any
} }
+21
View File
@@ -4,3 +4,24 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function slugify(str: string) {
if (!str) return ""
return str
.toLowerCase()
.replace(/a|á|à|ả|ã|ạ|ă|ắ|ằ|ẳ|ẵ|ặ|â|ấ|ầ|ẩ|ẫ|ậ/gi, 'a')
.replace(/e|é|è|ẻ|ẽ|ẹ|ê|ế|ề|ể|ễ|ệ/gi, 'e')
.replace(/i|í|ì|ỉ|ĩ|ị/gi, 'i')
.replace(/o|ó|ò|ỏ|õ|ọ|ô|ố|ồ|ổ|ỗ|ộ|ơ|ớ|ờ|ở|ỡ|ợ/gi, 'o')
.replace(/u|ú|ù|ủ|ũ|ụ|ư|ứ|ừ|ử|ữ|ự/gi, 'u')
.replace(/y|ý|ỳ|ỷ|ỹ|ỵ/gi, 'y')
.replace(/đ/gi, 'd')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + "M"
if (views >= 1000) return (views / 1000).toFixed(1) + "K"
return views.toString()
}
+5 -3
View File
@@ -125,9 +125,11 @@ model Comment {
} }
model Bookmark { model Bookmark {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
novelId String novelId String
lastChapterId String?
lastChapterNumber Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade) novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
+60
View File
@@ -0,0 +1,60 @@
const { PrismaClient } = require('@prisma/client')
const mongoose = require('mongoose')
require('dotenv').config({ path: '.env.local' })
require('dotenv').config()
const prisma = new PrismaClient()
async function main() {
console.log('Connecting to MongoDB...')
// Connect to MongoDB using MONGODB_URI
const mongoUri = process.env.MONGODB_URI
if (!mongoUri) {
throw new Error('MONGODB_URI is not defined in env')
}
await mongoose.connect(mongoUri)
// Wipe MongoDB Chapters
console.log('Wiping chapters from MongoDB...')
try {
const chapterSchema = new mongoose.Schema({}, { strict: false })
const Chapter = mongoose.models.Chapter || mongoose.model('Chapter', chapterSchema, 'chapters')
const res = await Chapter.deleteMany({})
console.log(`Deleted ${res.deletedCount} chapters.`)
} catch (e) {
console.error('Error wiping mongo chapters', e)
}
// Wipe PostgreSQL Content
console.log('Wiping Novels, Genres, Comments, Bookmarks from PostgreSQL...')
try {
// Delete in order to respect foreign keys if Cascade isn't perfect, but Cascade is set on most.
await prisma.comment.deleteMany({})
console.log('Deleted all comments.')
await prisma.bookmark.deleteMany({})
console.log('Deleted all bookmarks.')
await prisma.novelGenre.deleteMany({})
console.log('Deleted all novel_genres.')
await prisma.genre.deleteMany({})
console.log('Deleted all genres.')
await prisma.novel.deleteMany({})
console.log('Deleted all novels.')
} catch (error) {
console.error('Error wiping postgres', error)
}
console.log('Cleanup complete.')
}
main()
.catch(console.error)
.finally(async () => {
await prisma.$disconnect()
await mongoose.disconnect()
process.exit(0)
})
+1 -1
View File
File diff suppressed because one or more lines are too long