Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-11 17:02:31 +07:00
parent 1139125460
commit 5686753ab7
42 changed files with 4659 additions and 309 deletions
+27 -1
View File
@@ -3,6 +3,7 @@ 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 PUT(req: Request) {
const session = await getServerSession(authOptions)
@@ -18,10 +19,34 @@ export async function PUT(req: Request) {
return NextResponse.json({ error: "Tham số không hợp lệ" }, { status: 400 })
}
const novel = await prisma.novel.findUnique({
where: { id: novelId },
select: { id: true, uploaderId: true }
})
if (!novel) {
return NextResponse.json({ error: "Không tìm thấy truyện" }, { status: 404 })
}
if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const validUpdates = updates.filter((update: any) =>
update &&
typeof update.id === "string" &&
typeof update.number === "number" &&
typeof update.title === "string"
)
if (validUpdates.length === 0) {
return NextResponse.json({ message: "Không có thay đổi nào" }, { status: 200 })
}
await connectToMongoDB()
// Prepare bulk operations for mongoose
const bulkOps = updates.map((update: any) => ({
const bulkOps = validUpdates.map((update: any) => ({
updateOne: {
filter: { _id: update.id, novelId: novelId },
update: {
@@ -41,6 +66,7 @@ export async function PUT(req: Request) {
return NextResponse.json({
message: "Cập nhật thành công",
matchedCount: result.matchedCount,
modifiedCount: result.modifiedCount
}, { status: 200 })
+19 -3
View File
@@ -5,6 +5,12 @@ import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
import { prisma } from "@/lib/prisma"
function toNullableNumber(value: any): number | null {
if (value === null || value === undefined || value === "") return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId")
@@ -48,7 +54,7 @@ export async function POST(req: Request) {
try {
const data = await req.json()
const { novelId, number, title, content } = data
const { novelId, number, title, content, volumeNumber, volumeTitle, volumeChapterNumber } = data
// Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({
@@ -70,6 +76,9 @@ export async function POST(req: Request) {
const newChapter = await Chapter.create({
novelId,
number,
volumeNumber: toNullableNumber(volumeNumber),
volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null,
volumeChapterNumber: toNullableNumber(volumeChapterNumber),
title,
content,
})
@@ -96,7 +105,7 @@ export async function PUT(req: Request) {
try {
const data = await req.json()
const { id, novelId, number, title, content } = data
const { id, novelId, number, title, content, volumeNumber, volumeTitle, volumeChapterNumber } = data
// Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({
@@ -111,7 +120,14 @@ export async function PUT(req: Request) {
const updatedChapter = await Chapter.findOneAndUpdate(
{ _id: id, novelId },
{ number, title, content },
{
number,
title,
content,
volumeNumber: toNullableNumber(volumeNumber),
volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null,
volumeChapterNumber: toNullableNumber(volumeChapterNumber),
},
{ new: true }
)
+706 -38
View File
@@ -9,6 +9,590 @@ import os from "os"
import { promises as fs } from "fs"
import { convert } from "html-to-text"
import { slugify } from "@/lib/utils"
import { uploadBufferToR2 } from "@/lib/r2"
type SplitMode = "toc" | "regex"
type SeriesMode = "none" | "existing" | "new"
interface EpubSection {
sourceTitle: string
content: string
}
interface ParsedChapter {
title: string
content: string
detectedChapterNumber: number | null
finalNumber?: number
volumeNumber: number | null
volumeTitle: string | null
volumeChapterNumber: number | null
isPlaceholder?: boolean
}
interface EpubCoverAsset {
buffer: Buffer | null
mimeType: string | null
sourceId: string | null
}
const CHAPTER_REGEX_PRESETS: Record<string, string> = {
vi_chuong: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
en_chapter: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
mix_chapter: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
bracket_chapter: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$",
}
const NOISE_TITLE_REGEX = /^(?:mục lục|table of contents|toc|cover|bìa|copyright)$/i
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*:?$/i
function normalizeMetaText(value: any, fallback: string) {
if (typeof value === "string" && value.trim().length > 0) return value.trim()
if (Array.isArray(value)) {
const first = value.find((v) => typeof v === "string" && v.trim().length > 0)
if (first) return first.trim()
}
return fallback
}
function extractVolumeNumber(title: string): number | null {
const matched = title.match(/(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*([0-9]+)/i)
if (!matched) return null
const parsed = Number(matched[1])
return Number.isFinite(parsed) ? parsed : null
}
function extractChapterNumber(title: string): number | null {
const matched = title.match(/(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+(?:\.[0-9]+)?)/i)
if (!matched) return null
const parsed = Number(matched[1])
return Number.isFinite(parsed) ? parsed : null
}
function extractStrictChapterNumber(title: string): number | null {
const number = extractChapterNumber(title)
if (number === null) return null
if (!Number.isInteger(number)) return null
if (number <= 0) return null
if (number > 50000) return null
return number
}
function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string } {
const lines = content.split(/\r?\n/)
const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim().length > 0)
if (firstNonEmptyLineIndex < 0) return { title, content }
const firstLineRaw = lines[firstNonEmptyLineIndex]
const firstLine = firstLineRaw.trim()
if (!firstLine || firstLine.length > 140) return { title, content }
const baseTitle = title.trim()
const isSimpleBaseTitle = SIMPLE_CHAPTER_TITLE_REGEX.test(baseTitle)
if (!isSimpleBaseTitle) {
return { title, content }
}
let nextTitle = baseTitle
// Case 1: The first line already contains full chapter heading, use it directly.
if (/^(?:ch(?:ương|apter)?|ch\.)\s*\d+/i.test(firstLine) && firstLine.length > baseTitle.length + 2) {
nextTitle = firstLine
} else if (!SIMPLE_CHAPTER_TITLE_REGEX.test(firstLine) && !isVolumeHeading(firstLine) && !NOISE_TITLE_REGEX.test(firstLine)) {
// Case 2: TOC title is only "Chương N", subtitle is on next line.
nextTitle = `${baseTitle.replace(/[:\s]+$/g, "")}: ${firstLine}`
} else {
return { title, content }
}
const newLines = [...lines]
newLines.splice(firstNonEmptyLineIndex, 1)
const nextContent = newLines.join("\n").trim()
return {
title: nextTitle,
content: nextContent.length > 0 ? nextContent : content,
}
}
function isVolumeHeading(title: string): boolean {
return /^(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*[0-9]+(?:\s*[:-].*)?$/i.test(title.trim())
}
function normalizeSplitMode(value: FormDataEntryValue | null): SplitMode {
return value === "regex" ? "regex" : "toc"
}
function normalizeSeriesMode(value: FormDataEntryValue | null): SeriesMode {
if (value === "existing") return "existing"
if (value === "new") return "new"
return "none"
}
function readFormText(formData: FormData, key: string): string {
const value = formData.get(key)
return typeof value === "string" ? value.trim() : ""
}
async function resolveSeriesIdForEpubImport(options: {
mode: SeriesMode
seriesId: string
seriesName: string
userRole: "USER" | "MOD" | "ADMIN"
userId: string
}) {
if (options.mode === "none") return null
if (options.mode === "existing") {
if (!options.seriesId) {
throw new Error("Thiếu series để thêm vào")
}
const targetSeries = await prisma.series.findFirst({
where: options.userRole === "ADMIN"
? { id: options.seriesId }
: {
id: options.seriesId,
OR: [
{ novels: { some: { uploaderId: options.userId } } },
{ novels: { some: { uploaderId: null } } },
{ novels: { none: {} } },
],
},
select: { id: true },
})
if (!targetSeries) {
throw new Error("Series không tồn tại hoặc không đủ quyền")
}
return targetSeries.id
}
if (!options.seriesName) {
throw new Error("Thiếu tên series mới")
}
const existed = await prisma.series.findFirst({
where: { name: { equals: options.seriesName, mode: "insensitive" } },
select: { id: true },
})
if (existed) return existed.id
const baseSlug = slugify(options.seriesName)
let slug = baseSlug
let counter = 1
while (await prisma.series.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`
counter += 1
}
const created = await prisma.series.create({
data: {
name: options.seriesName,
slug,
},
select: { id: true },
})
return created.id
}
function resolveRegexPattern(formData: FormData): { regexInput: string; regexPreset: string | null } {
const preset = readFormText(formData, "chapterRegexPreset")
const custom = readFormText(formData, "chapterRegex")
if (custom) {
return { regexInput: custom, regexPreset: preset || "custom" }
}
if (preset && CHAPTER_REGEX_PRESETS[preset]) {
return { regexInput: CHAPTER_REGEX_PRESETS[preset], regexPreset: preset }
}
return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong, regexPreset: "vi_chuong" }
}
function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: string } {
if (!regexInput || regexInput.length > 300) {
throw new Error("Regex không hợp lệ")
}
let pattern = regexInput
let flags = ""
const slashWrapped = regexInput.match(/^\/(.+)\/([gimsuy]*)$/)
if (slashWrapped) {
pattern = slashWrapped[1]
flags = slashWrapped[2]
}
const flagSet = new Set(flags.split(""))
flagSet.add("g")
flagSet.add("m")
const normalizedFlags = Array.from(flagSet).join("")
const regex = new RegExp(pattern, normalizedFlags)
return { regex, normalized: `/${pattern}/${normalizedFlags}` }
}
function enrichVolumeMetadata(chapters: Array<{ title: string; content: string }>): ParsedChapter[] {
let currentVolumeNumber: number | null = null
let currentVolumeTitle: string | null = null
let volumeChapterCounter = 0
return chapters.map((chapter) => {
const title = chapter.title.trim()
const explicitVolumeNumber = extractVolumeNumber(title)
if (explicitVolumeNumber !== null) {
if (currentVolumeNumber !== explicitVolumeNumber) {
volumeChapterCounter = 0
}
currentVolumeNumber = explicitVolumeNumber
currentVolumeTitle = isVolumeHeading(title)
? title
: (currentVolumeTitle || `Quyển ${explicitVolumeNumber}`)
}
const explicitChapterNumber = extractChapterNumber(title)
let volumeChapterNumber: number | null = null
if (currentVolumeNumber !== null) {
if (explicitChapterNumber !== null) {
volumeChapterCounter = explicitChapterNumber
} else {
volumeChapterCounter += 1
}
volumeChapterNumber = volumeChapterCounter
}
return {
title,
content: chapter.content,
detectedChapterNumber: extractStrictChapterNumber(title),
volumeNumber: currentVolumeNumber,
volumeTitle: currentVolumeTitle,
volumeChapterNumber,
}
})
}
function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[] {
const chapters: ParsedChapter[] = []
let currentVolumeNumber: number | null = null
let currentVolumeTitle: string | null = null
let currentVolumeChapterCounter = 0
let fallbackVolumeCounter = 0
for (let i = 0; i < sections.length; i++) {
const section = sections[i]
const rawTitle = section.sourceTitle || `Chương ${i + 1}`
const cleanTitle = rawTitle.replace(/\s+/g, " ").trim()
const cleanContent = section.content.trim()
if (!cleanContent) continue
if (isVolumeHeading(cleanTitle)) {
const extracted = extractVolumeNumber(cleanTitle)
if (extracted !== null) {
currentVolumeNumber = extracted
} else {
fallbackVolumeCounter += 1
currentVolumeNumber = fallbackVolumeCounter
}
currentVolumeTitle = cleanTitle
currentVolumeChapterCounter = 0
if (cleanContent.length <= 240) {
continue
}
}
if (NOISE_TITLE_REGEX.test(cleanTitle) && cleanContent.length <= 240) {
continue
}
const explicitVolumeFromTitle = extractVolumeNumber(cleanTitle)
if (explicitVolumeFromTitle !== null) {
if (currentVolumeNumber !== explicitVolumeFromTitle) {
currentVolumeChapterCounter = 0
}
currentVolumeNumber = explicitVolumeFromTitle
if (!currentVolumeTitle || isVolumeHeading(cleanTitle)) {
currentVolumeTitle = `Quyển ${explicitVolumeFromTitle}`
}
}
let volumeChapterNumber: number | null = null
const detectedChapterNumber = extractStrictChapterNumber(cleanTitle)
if (currentVolumeNumber !== null) {
const explicitChapter = extractChapterNumber(cleanTitle)
if (explicitChapter !== null) {
currentVolumeChapterCounter = explicitChapter
} else {
currentVolumeChapterCounter += 1
}
volumeChapterNumber = currentVolumeChapterCounter
}
const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent)
chapters.push({
title: enhanced.title,
content: enhanced.content,
detectedChapterNumber,
volumeNumber: currentVolumeNumber,
volumeTitle: currentVolumeTitle,
volumeChapterNumber,
})
}
return chapters
}
function buildChaptersFromRegexSections(sections: EpubSection[], regex: RegExp): ParsedChapter[] {
const combinedText = sections
.map((section) => section.content.trim())
.filter(Boolean)
.join("\n\n")
const matches = Array.from(combinedText.matchAll(regex))
if (matches.length === 0) {
return []
}
const parsed: Array<{ title: string; content: string }> = []
for (let i = 0; i < matches.length; i++) {
const match = matches[i]
if (match.index === undefined) continue
const nextMatch = matches[i + 1]
const headingRaw = (match[1] || match[0] || "").replace(/\s+/g, " ").trim()
const sectionStart = match.index + match[0].length
const sectionEnd = nextMatch?.index ?? combinedText.length
const body = combinedText.slice(sectionStart, sectionEnd).trim()
if (!headingRaw || body.length === 0) {
continue
}
const enhanced = enhanceChapterTitleFromContent(headingRaw, body)
parsed.push({
title: enhanced.title,
content: enhanced.content,
})
}
return enrichVolumeMetadata(parsed)
}
function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
chapters: ParsedChapter[]
insertedCount: number
detectedMax: number
detectedNumberAssignments: number
} {
const detectedNumbers = chapters
.map((chapter) => chapter.detectedChapterNumber)
.filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n > 0)
let insertedCount = 0
let detectedNumberAssignments = 0
let currentNumber = 0
const maxDetected = detectedNumbers.length > 0 ? Math.max(...detectedNumbers) : chapters.length
const normalized: ParsedChapter[] = []
const MAX_ALLOWED_GAP = 40
for (const chapter of chapters) {
const detected = chapter.detectedChapterNumber
const canUseDetected =
typeof detected === "number" &&
detected > currentNumber &&
detected - currentNumber <= MAX_ALLOWED_GAP
if (canUseDetected) {
for (let missing = currentNumber + 1; missing < detected; missing++) {
insertedCount += 1
normalized.push({
title: `Chương ${missing} (Thiếu)`,
content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`,
detectedChapterNumber: missing,
finalNumber: missing,
volumeNumber: null,
volumeTitle: null,
volumeChapterNumber: null,
isPlaceholder: true,
})
}
detectedNumberAssignments += 1
currentNumber = detected
normalized.push({
...chapter,
finalNumber: currentNumber,
volumeChapterNumber: chapter.volumeChapterNumber,
})
continue
}
currentNumber += 1
normalized.push({
...chapter,
finalNumber: currentNumber,
volumeChapterNumber: chapter.volumeChapterNumber,
})
}
return {
chapters: normalized,
insertedCount,
detectedMax: maxDetected,
detectedNumberAssignments,
}
}
async function extractCoverFromEpub(epub: any): Promise<EpubCoverAsset> {
const manifest = epub.manifest || {}
const metadataCover = epub.metadata?.cover ? String(epub.metadata.cover) : null
const candidateIds: string[] = []
if (metadataCover) candidateIds.push(metadataCover)
for (const [key, value] of Object.entries(manifest)) {
const item = value as any
const id = String(item?.id || key)
const href = String(item?.href || "")
const mediaType = String(item?.mediaType || item?.["media-type"] || "")
const properties = String(item?.properties || "")
if (
/cover-image/i.test(properties) ||
/cover/i.test(id) ||
/cover/i.test(href)
) {
candidateIds.push(id)
continue
}
if (/image\//i.test(mediaType) && /cover/i.test(href)) {
candidateIds.push(id)
}
}
const uniqueCandidateIds = Array.from(new Set(candidateIds.filter(Boolean)))
if (uniqueCandidateIds.length === 0) {
return { buffer: null, mimeType: null, sourceId: null }
}
for (const id of uniqueCandidateIds) {
const fromImage = await new Promise<EpubCoverAsset>((resolve) => {
if (typeof epub.getImage !== "function") {
resolve({ buffer: null, mimeType: null, sourceId: null })
return
}
epub.getImage(id, (err: any, data: any, mimeType?: string) => {
if (err || !data) {
resolve({ buffer: null, mimeType: null, sourceId: null })
return
}
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id })
})
})
if (fromImage.buffer) {
return fromImage
}
const fromFile = await new Promise<EpubCoverAsset>((resolve) => {
if (typeof epub.getFile !== "function") {
resolve({ buffer: null, mimeType: null, sourceId: null })
return
}
epub.getFile(id, (err: any, data: any, mimeType?: string) => {
if (err || !data) {
resolve({ buffer: null, mimeType: null, sourceId: null })
return
}
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id })
})
})
if (fromFile.buffer) {
return fromFile
}
}
return { buffer: null, mimeType: null, sourceId: null }
}
async function saveCoverBufferToR2(cover: EpubCoverAsset): Promise<string | null> {
if (!cover.buffer) return null
return uploadBufferToR2({
buffer: cover.buffer,
contentType: cover.mimeType,
keyPrefix: "covers/epub",
fileNameHint: cover.sourceId || undefined,
})
}
async function parseEpubSections(tempFilePath: string): Promise<{ metadata: any; sections: EpubSection[]; cover: EpubCoverAsset }> {
return new Promise((resolve, reject) => {
const EPub = require("epub2").EPub || require("epub2")
const epub = new EPub(tempFilePath, "", "")
epub.on("error", (err: any) => reject(err))
epub.on("end", async () => {
try {
const metadata = epub.metadata
const flow = epub.flow
const sections: EpubSection[] = []
const cover = await extractCoverFromEpub(epub)
for (let i = 0; i < flow.length; i++) {
const item = flow[i]
const text = await new Promise<string>((res) => {
epub.getChapter(item.id, (err: any, data: string) => {
if (err) res("")
else res(data)
})
})
if (!text || text.trim().length === 0) continue
const plainText = convert(text, { wordwrap: false }).trim()
if (!plainText) continue
sections.push({
sourceTitle: item.title || `Mục ${i + 1}`,
content: plainText,
})
}
resolve({ metadata, sections, cover })
} catch (err) {
reject(err)
}
})
epub.parse()
})
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
@@ -19,6 +603,12 @@ export async function POST(req: Request) {
try {
const formData = await req.formData()
const epubFile = formData.get("file") as File
const previewOnly = String(formData.get("preview") || "").toLowerCase() === "true"
const splitMode = normalizeSplitMode(formData.get("splitMode"))
const seriesMode = normalizeSeriesMode(formData.get("seriesMode"))
const seriesIdInput = readFormText(formData, "seriesId")
const seriesNameInput = readFormText(formData, "seriesName")
if (!epubFile) {
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 })
}
@@ -27,50 +617,117 @@ export async function POST(req: Request) {
const tempFilePath = path.join(os.tmpdir(), `upload-${Date.now()}.epub`)
await fs.writeFile(tempFilePath, buffer)
// Phân tích EPUB file
const parsedData = await new Promise<any>((resolve, reject) => {
const EPub = require("epub2").EPub || require("epub2")
const epub = new EPub(tempFilePath, "", "")
let parsedData: any = null
try {
const { metadata, sections, cover } = await parseEpubSections(tempFilePath)
epub.on("error", (err: any) => reject(err))
epub.on("end", async () => {
const metadata = epub.metadata
const flow = epub.flow // TOC array
const chapters = []
let regexNormalized: string | null = null
let regexPreset: string | null = null
let chapters: ParsedChapter[] = []
for (let i = 0; i < flow.length; i++) {
const chapterData = flow[i]
const text = await new Promise<string>((res) => {
epub.getChapter(chapterData.id, (err: any, d: string) => {
if (err) res("")
else res(d)
})
})
if (splitMode === "regex") {
const regexResolved = resolveRegexPattern(formData)
const compiled = buildRegexFromInput(regexResolved.regexInput)
chapters = buildChaptersFromRegexSections(sections, compiled.regex)
regexNormalized = compiled.normalized
regexPreset = regexResolved.regexPreset
if (text && text.trim().length > 0) {
const plainText = convert(text, { wordwrap: false })
chapters.push({
title: chapterData.title || `Chương ${i + 1}`,
content: plainText
})
}
if (chapters.length === 0) {
return NextResponse.json(
{
error: "Regex không tách được chương nào. Hãy thử regex khác hoặc chuyển về TOC.",
parserInfo: {
splitMode,
chapterRegexUsed: regexNormalized,
regexPreset,
sourceSections: sections.length,
chaptersDetected: 0,
}
},
{ status: 400 }
)
}
} else {
chapters = buildChaptersFromTOCSections(sections)
if (chapters.length === 0) {
return NextResponse.json(
{ error: "Không tìm thấy chương hợp lệ từ TOC. Bạn có thể thử chế độ Regex." },
{ status: 400 }
)
}
}
resolve({ metadata, chapters })
const gapFilled = withMissingChapterPlaceholders(chapters)
parsedData = {
metadata,
sections,
chapters: gapFilled.chapters,
cover,
parserInfo: {
splitMode,
chapterRegexUsed: regexNormalized,
regexPreset,
sourceSections: sections.length,
chaptersDetected: chapters.length,
chaptersFinal: gapFilled.chapters.length,
insertedMissingChapters: gapFilled.insertedCount,
detectedMaxChapterNumber: gapFilled.detectedMax,
detectedNumberAssignments: gapFilled.detectedNumberAssignments,
}
}
} finally {
// Xóa file tạm
await fs.unlink(tempFilePath).catch(() => { })
}
const { metadata, chapters, parserInfo, cover } = parsedData
const metadataTitle = normalizeMetaText(metadata?.title, "Truyện chưa đặt tên")
const metadataAuthor = normalizeMetaText(metadata?.creator, "Khuyết danh")
const metadataDescRaw = normalizeMetaText(metadata?.description, "Chưa có giới thiệu")
const metadataDesc = convert(metadataDescRaw, { wordwrap: false })
const novelTitle = normalizeMetaText(readFormText(formData, "title"), metadataTitle)
const novelAuthor = normalizeMetaText(readFormText(formData, "authorName"), metadataAuthor)
const novelDesc = normalizeMetaText(readFormText(formData, "description"), metadataDesc)
const hasDetectedVolumes = chapters.some((ch: any) => ch.volumeNumber !== null)
if (previewOnly) {
return NextResponse.json({
preview: true,
fileName: epubFile.name,
splitMode,
detectedStructureType: hasDetectedVolumes ? "light_novel" : "standard",
parserInfo,
hasCoverFromEpub: !!cover?.buffer,
novel: {
title: novelTitle,
authorName: novelAuthor,
description: novelDesc,
totalChapters: chapters.length,
},
chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({
number: ch.finalNumber || i + 1,
title: ch.title,
isPlaceholder: !!ch.isPlaceholder,
volumeNumber: ch.volumeNumber,
volumeTitle: ch.volumeTitle,
volumeChapterNumber: ch.volumeChapterNumber,
excerpt: (ch.content || "").slice(0, 180),
})),
})
}
epub.parse()
const selectedSeriesId = await resolveSeriesIdForEpubImport({
mode: seriesMode,
seriesId: seriesIdInput,
seriesName: seriesNameInput,
userRole: session.user.role,
userId: session.user.id,
})
// Xóa file tạm
await fs.unlink(tempFilePath).catch(() => { })
const { metadata, chapters } = parsedData
let novelTitle = metadata.title || "Truyện chưa đặt tên"
let novelAuthor = metadata.creator || "Khuyết danh"
let novelDesc = metadata.description || "Chưa có giới thiệu"
// Generate base slug
const baseSlug = slugify(novelTitle)
@@ -83,12 +740,16 @@ export async function POST(req: Request) {
slugCounter++
}
const coverUrl = await saveCoverBufferToR2(cover)
const newNovel = await prisma.novel.create({
data: {
title: novelTitle,
slug: slug,
authorName: novelAuthor,
description: convert(novelDesc, { wordwrap: false }), // metadata metadata có thể chứa html
description: novelDesc,
coverUrl,
seriesId: selectedSeriesId,
uploaderId: session.user.id,
totalChapters: chapters.length,
},
@@ -98,7 +759,10 @@ export async function POST(req: Request) {
await connectToMongoDB()
const chapterDocs = chapters.map((ch: any, i: number) => ({
novelId: newNovel.id,
number: i + 1,
number: ch.finalNumber || (i + 1),
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
views: 0
@@ -108,7 +772,11 @@ export async function POST(req: Request) {
await Chapter.insertMany(chapterDocs)
}
return NextResponse.json(newNovel, { status: 201 })
return NextResponse.json({
...newNovel,
parserInfo,
hasCoverFromEpub: !!coverUrl,
}, { status: 201 })
} catch (error: any) {
console.error("EPUB upload error:", error)
return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 })
+200
View File
@@ -0,0 +1,200 @@
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"
function normalizeText(value: any): string {
return typeof value === "string" ? value.trim() : ""
}
async function resolveEditableSeries(
id: string,
session: { user: { role: "USER" | "MOD" | "ADMIN"; id: string } }
) {
return prisma.series.findFirst({
where: session.user.role === "ADMIN"
? { id }
: {
id,
OR: [
{ novels: { some: { uploaderId: session.user.id } } },
{ novels: { some: { uploaderId: null } } },
{ novels: { none: {} } },
],
},
select: { id: true },
})
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const series = await prisma.series.findMany({
where: session.user.role === "ADMIN"
? undefined
: {
OR: [
{ novels: { some: { uploaderId: session.user.id } } },
{ novels: { some: { uploaderId: null } } },
{ novels: { none: {} } },
],
},
orderBy: { updatedAt: "desc" },
select: {
id: true,
name: true,
slug: true,
description: true,
_count: { select: { novels: true } },
},
})
return NextResponse.json(series)
} catch {
return NextResponse.json({ error: "Failed to fetch series" }, { status: 500 })
}
}
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 body = await req.json()
const name = normalizeText(body?.name)
const description = normalizeText(body?.description)
if (!name) {
return NextResponse.json({ error: "Tên series không được để trống" }, { status: 400 })
}
const existing = await prisma.series.findFirst({
where: { name: { equals: name, mode: "insensitive" } },
select: { id: true, name: true, slug: true, description: true },
})
if (existing) {
return NextResponse.json(existing)
}
const baseSlug = slugify(name)
let slug = baseSlug
let counter = 1
while (await prisma.series.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`
counter += 1
}
const created = await prisma.series.create({
data: { name, slug, description: description || null },
select: { id: true, name: true, slug: true, description: true },
})
return NextResponse.json(created, { status: 201 })
} catch {
return NextResponse.json({ error: "Failed to create series" }, { 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 body = await req.json()
const id = normalizeText(body?.id)
const name = normalizeText(body?.name)
const description = normalizeText(body?.description)
if (!id || !name) {
return NextResponse.json({ error: "Thiếu thông tin series" }, { status: 400 })
}
const target = await resolveEditableSeries(id, session as any)
if (!target) {
return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 })
}
const duplicated = await prisma.series.findFirst({
where: {
id: { not: id },
name: { equals: name, mode: "insensitive" },
},
select: { id: true },
})
if (duplicated) {
return NextResponse.json({ error: "Tên series đã tồn tại" }, { status: 409 })
}
const baseSlug = slugify(name)
let slug = baseSlug
let counter = 1
while (await prisma.series.findFirst({ where: { slug, id: { not: id } }, select: { id: true } })) {
slug = `${baseSlug}-${counter}`
counter += 1
}
const updated = await prisma.series.update({
where: { id },
data: {
name,
slug,
description: description || null,
},
select: {
id: true,
name: true,
slug: true,
description: true,
_count: { select: { novels: true } },
},
})
return NextResponse.json(updated)
} catch {
return NextResponse.json({ error: "Failed to update series" }, { 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 = normalizeText(url.searchParams.get("id"))
if (!id) {
return NextResponse.json({ error: "Thiếu id series" }, { status: 400 })
}
const target = await resolveEditableSeries(id, session as any)
if (!target) {
return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 })
}
const usedCount = await prisma.novel.count({ where: { seriesId: id } })
if (usedCount > 0) {
return NextResponse.json({ error: "Series đang chứa truyện, không thể xóa" }, { status: 409 })
}
await prisma.series.delete({ where: { id } })
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: "Failed to delete series" }, { status: 500 })
}
}
+11 -5
View File
@@ -14,12 +14,18 @@ export async function GET(
}
try {
const novel = await prisma.novel.findUnique({
where: {
id,
uploaderId: session.user.id,
},
const novel = await prisma.novel.findFirst({
where: session.user.role === "ADMIN"
? { id }
: {
id,
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
},
include: {
series: true,
genres: {
include: {
genre: true
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
import { deleteR2ObjectByUrl } from "@/lib/r2"
function normalizeIds(value: any): string[] {
if (!Array.isArray(value)) return []
return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
}
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 body = await req.json()
const action = typeof body?.action === "string" ? body.action : ""
const ids = normalizeIds(body?.ids)
if (ids.length === 0) {
return NextResponse.json({ error: "Danh sách truyện trống" }, { status: 400 })
}
const accessibleNovels = await prisma.novel.findMany({
where: session.user.role === "ADMIN"
? { id: { in: ids } }
: {
id: { in: ids },
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
},
select: {
id: true,
coverUrl: true,
},
})
if (accessibleNovels.length === 0) {
return NextResponse.json({ error: "Không có truyện hợp lệ để thao tác" }, { status: 404 })
}
const accessibleIds = accessibleNovels.map((novel) => novel.id)
if (action === "delete") {
await connectToMongoDB()
await Chapter.deleteMany({ novelId: { $in: accessibleIds } })
await prisma.novel.deleteMany({
where: { id: { in: accessibleIds } },
})
await Promise.all(
accessibleNovels.map((novel) => deleteR2ObjectByUrl(novel.coverUrl).catch(() => {}))
)
return NextResponse.json({ success: true, deletedCount: accessibleIds.length })
}
return NextResponse.json({ error: "Chỉ hỗ trợ xóa hàng loạt" }, { status: 400 })
} catch {
return NextResponse.json({ error: "Bulk operation failed" }, { status: 500 })
}
}
+190 -9
View File
@@ -3,6 +3,75 @@ import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { slugify } from "@/lib/utils"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
import { deleteR2ObjectByUrl } from "@/lib/r2"
function normalizeOptionalText(value: any): string {
return typeof value === "string" ? value.trim() : ""
}
async function resolveSeriesIdForWrite(
seriesIdInput: any,
seriesNameInput: any,
userRole: "USER" | "MOD" | "ADMIN",
userId: string
): Promise<string | null> {
const seriesId = normalizeOptionalText(seriesIdInput)
const seriesName = normalizeOptionalText(seriesNameInput)
if (seriesId) {
const series = await prisma.series.findFirst({
where: userRole === "ADMIN"
? { id: seriesId }
: {
id: seriesId,
OR: [
{ novels: { some: { uploaderId: userId } } },
{ novels: { some: { uploaderId: null } } },
{ novels: { none: {} } },
],
},
select: { id: true },
})
if (!series) {
throw new Error("Series không tồn tại hoặc bạn không có quyền sử dụng")
}
return series.id
}
if (!seriesName) return null
const existingSeries = await prisma.series.findFirst({
where: { name: { equals: seriesName, mode: "insensitive" } },
select: { id: true },
})
if (existingSeries) {
return existingSeries.id
}
const baseSlug = slugify(seriesName)
let slug = baseSlug
let counter = 1
while (await prisma.series.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`
counter += 1
}
const createdSeries = await prisma.series.create({
data: {
name: seriesName,
slug,
},
select: { id: true },
})
return createdSeries.id
}
export async function GET() {
const session = await getServerSession(authOptions)
@@ -12,7 +81,19 @@ export async function GET() {
try {
const novels = await prisma.novel.findMany({
where: { uploaderId: session.user.id },
where: session.user.role === "ADMIN"
? undefined
: {
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
},
include: {
series: {
select: { id: true, name: true, slug: true }
}
},
orderBy: { updatedAt: "desc" },
})
return NextResponse.json(novels)
@@ -30,6 +111,7 @@ export async function POST(req: Request) {
try {
const data = await req.json()
const { title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds = [] } = data
const seriesId = await resolveSeriesIdForWrite(data?.seriesId, data?.seriesName, session.user.role, session.user.id)
// Tạo slug từ title
const slug = slugify(title)
@@ -42,6 +124,7 @@ export async function POST(req: Request) {
originalAuthorName,
description,
coverUrl,
seriesId,
uploaderId: session.user.id,
genres: {
create: genreIds.map((id: string) => ({
@@ -65,10 +148,76 @@ export async function PUT(req: Request) {
try {
const data = await req.json()
const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data
const targetNovel = await prisma.novel.findFirst({
where: session.user.role === "ADMIN"
? { id }
: {
id,
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
},
select: { id: true, seriesId: true },
})
if (!targetNovel) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 404 })
}
// Disable editing series relation from novel edit form: keep current seriesId.
const fixedSeriesId = targetNovel.seriesId
if (fixedSeriesId) {
const seriesNovels = await prisma.novel.findMany({
where: { seriesId: fixedSeriesId },
select: { id: true },
})
const seriesNovelIds = seriesNovels.map((novel) => novel.id)
const updatedNovel = await prisma.$transaction(async (tx) => {
// Sync shared metadata for all novels in the same series.
await tx.novel.updateMany({
where: { id: { in: seriesNovelIds } },
data: {
originalTitle,
authorName,
originalAuthorName,
description,
status,
},
})
if (genreIds !== undefined) {
await tx.novelGenre.deleteMany({
where: { novelId: { in: seriesNovelIds } },
})
if (genreIds.length > 0) {
await tx.novelGenre.createMany({
data: seriesNovelIds.flatMap((novelId) =>
genreIds.map((genreId: string) => ({ novelId, genreId }))
),
})
}
}
// Only current novel keeps its own title and cover.
return tx.novel.update({
where: { id },
data: {
title,
coverUrl,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
},
})
})
return NextResponse.json(updatedNovel)
}
// 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
where: { id },
data: {
title,
originalTitle,
@@ -77,7 +226,8 @@ export async function PUT(req: Request) {
description,
coverUrl,
status,
// Replace all existing genres if genreIds is provided
seriesId: fixedSeriesId,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
...(genreIds !== undefined && {
genres: {
deleteMany: {},
@@ -88,6 +238,7 @@ export async function PUT(req: Request) {
})
},
})
return NextResponse.json(updatedNovel)
} catch (error) {
return NextResponse.json({ error: "Failed to update novel" }, { status: 500 })
@@ -106,13 +257,43 @@ export async function DELETE(req: Request) {
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 },
const novel = await prisma.novel.findFirst({
where: session.user.role === "ADMIN"
? { id }
: {
id,
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
},
select: { id: true, coverUrl: true, seriesId: true }
})
return NextResponse.json({ message: "Đã xóa truyện thành công" })
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 404 })
}
await connectToMongoDB()
const chapterDeleteResult = await Chapter.deleteMany({ novelId: id })
await prisma.novel.delete({
where: { id },
})
await deleteR2ObjectByUrl(novel.coverUrl).catch(() => { })
if (novel.seriesId) {
const remainingSeriesNovels = await prisma.novel.count({ where: { seriesId: novel.seriesId } })
if (remainingSeriesNovels === 0) {
await prisma.series.delete({ where: { id: novel.seriesId } }).catch(() => { })
}
}
return NextResponse.json({
message: "Đã xóa truyện và toàn bộ chương thành công",
deletedChapters: chapterDeleteResult.deletedCount || 0
})
} catch (error) {
return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 })
}
+8 -17
View File
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { writeFile } from "fs/promises"
import path from "path"
import fs from "fs"
import { uploadBufferToR2 } from "@/lib/r2"
export async function POST(req: Request) {
try {
@@ -26,21 +24,14 @@ export async function POST(req: Request) {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9)
const ext = path.extname(file.name) || ".jpg"
const filename = `cover-${uniqueSuffix}${ext}`
const url = await uploadBufferToR2({
buffer,
contentType: file.type,
keyPrefix: "covers/manual",
fileNameHint: file.name,
})
const uploadDir = path.join(process.cwd(), "public", "uploads", "covers")
// Ensure directory exists
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
const filepath = path.join(uploadDir, filename)
await writeFile(filepath, buffer)
return NextResponse.json({ url: `/uploads/covers/${filename}` })
return NextResponse.json({ url })
} catch (error: any) {
console.error("Cover upload error:", error)
return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 })
+4 -1
View File
@@ -22,7 +22,7 @@ export async function GET(
.sort({ number: 1 })
.skip(skip)
.limit(limit)
.select("number title createdAt") // don't return content
.select("number title createdAt volumeNumber volumeTitle volumeChapterNumber") // don't return content
.lean(),
Chapter.countDocuments({ novelId })
])
@@ -32,6 +32,9 @@ export async function GET(
id: c._id.toString(),
number: c.number,
title: c.title,
volumeNumber: (c as any).volumeNumber ?? null,
volumeTitle: (c as any).volumeTitle ?? null,
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
createdAt: (c.createdAt as Date).toISOString()
})),
totalChapters,
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const q = url.searchParams.get("q")?.trim() || ""
if (q.length < 2) {
return NextResponse.json([])
}
const novels = await prisma.novel.findMany({
where: {
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
{ series: { name: { contains: q, mode: "insensitive" } } },
],
},
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
series: {
select: {
id: true,
name: true,
},
},
},
orderBy: [{ views: "desc" }, { updatedAt: "desc" }],
take: 8,
})
return NextResponse.json(novels)
} catch {
return NextResponse.json({ error: "Failed to fetch suggestions" }, { status: 500 })
}
}
+24 -1
View File
@@ -39,6 +39,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
// Core states
const [number, setNumber] = useState("")
const [volumeNumber, setVolumeNumber] = useState("")
const [volumeTitle, setVolumeTitle] = useState("")
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [originalNovelId, setOriginalNovelId] = useState("")
@@ -77,6 +80,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
const data = await res.json()
setNumber(data.number.toString())
setVolumeNumber(data.volumeNumber ? String(data.volumeNumber) : "")
setVolumeTitle(data.volumeTitle || "")
setVolumeChapterNumber(data.volumeChapterNumber ? String(data.volumeChapterNumber) : "")
setTitle(data.title)
setContent(data.content)
setOriginalNovelId(data.novelId)
@@ -189,6 +195,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
id: chapterId,
novelId: originalNovelId,
number: parseInt(number),
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
volumeTitle: volumeTitle.trim() || null,
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
title,
content
})
@@ -477,12 +486,26 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
{/* Editor Workspace */}
<div className="flex flex-col flex-1 pb-4 min-h-0">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-6 gap-4 mb-4 shrink-0">
<div className="space-y-1">
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label>
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
</div>
<div className="space-y-1">
<label className="text-xs font-semibold uppercase text-muted-foreground">Quyển số</label>
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} className="font-mono" placeholder="VD: 1" />
</div>
<div className="space-y-1">
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương trong quyển</label>
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} className="font-mono" placeholder="VD: 3" />
</div>
<div className="space-y-1 md:col-span-3">
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên quyển</label>
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 mb-4 shrink-0">
<div className="space-y-1">
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
+150 -33
View File
@@ -24,6 +24,9 @@ import * as mammoth from "mammoth"
interface Chapter {
_id: string
number: number
volumeNumber?: number | null
volumeTitle?: string | null
volumeChapterNumber?: number | null
title: string
views: number
createdAt: string
@@ -60,9 +63,11 @@ function ChapterManager() {
const [openOptimize, setOpenOptimize] = useState(false)
const [previewMode, setPreviewMode] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
const [optRenumber, setOptRenumber] = useState(true)
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
// Edit states
const [openEdit, setOpenEdit] = useState(false)
@@ -76,6 +81,9 @@ function ChapterManager() {
// Form states
const [number, setNumber] = useState("")
const [volumeNumber, setVolumeNumber] = useState("")
const [volumeTitle, setVolumeTitle] = useState("")
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
@@ -115,6 +123,29 @@ function ChapterManager() {
}
}, [novelId, currentPage])
const fetchAllChaptersForOptimize = async (): Promise<Chapter[]> => {
if (!novelId) return []
const limit = 200
let page = 1
let total = 1
const all: Chapter[] = []
while (page <= total) {
const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${page}&limit=${limit}`)
if (!res.ok) {
throw new Error("Không thể tải toàn bộ chương để tối ưu")
}
const data = await res.json()
all.push(...(data.chapters || []))
total = data.totalPages || 1
page++
}
return all.sort((a, b) => a.number - b.number)
}
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!number || !title || !content || !novelId) {
@@ -127,7 +158,15 @@ function ChapterManager() {
const res = await fetch("/api/mod/chuong", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId, number: parseInt(number), title, content }),
body: JSON.stringify({
novelId,
number: parseInt(number),
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
volumeTitle: volumeTitle.trim() || null,
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
title,
content,
}),
})
const resData = await res.json()
@@ -137,6 +176,9 @@ function ChapterManager() {
setOpenAdd(false)
setTitle("")
setContent("")
setVolumeNumber("")
setVolumeTitle("")
setVolumeChapterNumber("")
setNumber((parseInt(number) + 1).toString())
fetchChapters()
} catch (error: any) {
@@ -205,38 +247,73 @@ function ChapterManager() {
}
}
const handlePreviewOptimize = () => {
let newChapters = [...chapters]
const handlePreviewOptimize = async () => {
if (!novelId) return
if (optRenumber) {
newChapters.sort((a, b) => a.number - b.number)
newChapters = newChapters.map((ch, idx) => ({
...ch,
number: idx + 1
}))
if (!optRemovePrefix && !optRenumber) {
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
return
}
if (optRemovePrefix) {
newChapters = newChapters.map((ch, i) => {
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
if (!newTitle) newTitle = `Chương ${ch.number}`
return { ...ch, title: newTitle }
})
}
setLoadingOptimizeSource(true)
setOptimizedChapters(newChapters)
setPreviewMode(true)
try {
const allChapters = await fetchAllChaptersForOptimize()
if (allChapters.length === 0) {
toast.info("Truyện này chưa có chương để tối ưu")
return
}
setOptimizeSourceChapters(allChapters)
let newChapters = [...allChapters]
if (optRenumber) {
newChapters.sort((a, b) => a.number - b.number)
newChapters = newChapters.map((ch, idx) => ({
...ch,
number: idx + 1
}))
}
if (optRemovePrefix) {
newChapters = newChapters.map((ch) => {
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
if (!newTitle) newTitle = `Chương ${ch.number}`
return { ...ch, title: newTitle }
})
}
setOptimizedChapters(newChapters)
setPreviewMode(true)
toast.success(`Đã tạo xem trước cho toàn bộ ${newChapters.length} chương`)
} catch (error: any) {
toast.error(error.message || "Không thể tạo bản xem trước")
} finally {
setLoadingOptimizeSource(false)
}
}
const handleApplyOptimize = async () => {
if (optimizedChapters.length === 0) return
setOptimizing(true)
try {
const updates = optimizedChapters.map(ch => ({
id: ch._id,
title: ch.title,
number: ch.number
}))
const sourceById = new Map(optimizeSourceChapters.map((ch) => [ch._id, ch]))
const updates = optimizedChapters
.filter((ch) => {
const old = sourceById.get(ch._id)
return !!old && (old.number !== ch.number || old.title !== ch.title)
})
.map((ch) => ({
id: ch._id,
title: ch.title,
number: ch.number
}))
if (updates.length === 0) {
toast.info("Không có thay đổi nào cần lưu")
setOptimizing(false)
return
}
const res = await fetch("/api/mod/chuong/optimize", {
method: "PUT",
@@ -247,9 +324,11 @@ function ChapterManager() {
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa")
toast.success(`Đã ti ưu ${data.modifiedCount} chương!`)
toast.success(`Đã ti ưu ${data.modifiedCount} chương trên toàn bộ truyện!`)
setOpenOptimize(false)
setPreviewMode(false)
setOptimizedChapters([])
setOptimizeSourceChapters([])
fetchChapters(currentPage)
} catch (error: any) {
toast.error(error.message)
@@ -298,6 +377,8 @@ function ChapterManager() {
)
}
const optimizeSourceMap = new Map(optimizeSourceChapters.map((source) => [source._id, source]))
return (
<div className="space-y-6">
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
@@ -310,6 +391,8 @@ function ChapterManager() {
<Button variant="secondary" className="gap-2" onClick={() => {
setOpenOptimize(true)
setPreviewMode(false)
setOptimizedChapters([])
setOptimizeSourceChapters([])
}} disabled={chapters.length === 0}>
<Wand2 className="h-4 w-4" /> Tối ưu hóa
</Button>
@@ -341,12 +424,26 @@ function ChapterManager() {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-6 gap-4">
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Chương số</label>
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
</div>
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Quyển số</label>
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
</div>
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Chương trong quyển</label>
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} placeholder="VD: 5" />
</div>
<div className="space-y-2 col-span-3">
<label className="text-sm font-medium">Tên quyển (Tuỳ chọn)</label>
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2 col-span-4">
<label className="text-sm font-medium">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
</div>
@@ -389,12 +486,23 @@ function ChapterManager() {
</DialogContent>
</Dialog>
<Dialog open={openOptimize} onOpenChange={setOpenOptimize}>
<Dialog
open={openOptimize}
onOpenChange={(nextOpen) => {
setOpenOptimize(nextOpen)
if (!nextOpen) {
setPreviewMode(false)
setOptimizedChapters([])
setOptimizeSourceChapters([])
setLoadingOptimizeSource(false)
}
}}
>
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Tối Ưu Hóa Chương Hàng Loạt</DialogTitle>
<DialogDescription>
Công cụ dọn dẹp tên chương đánh lại số thứ tự tự đng tiện lợi sau khi đăng ép từ tệp EPUB.
Công cụ sẽ áp dụng trên tn bộ chương của truyện hiện tại, không chỉ page bạn đang xem.
</DialogDescription>
</DialogHeader>
@@ -425,8 +533,8 @@ function ChapterManager() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{optimizedChapters.map((newCh, i) => {
const oldCh = chapters[i]
{optimizedChapters.map((newCh) => {
const oldCh = optimizeSourceMap.get(newCh._id) || newCh
return (
<tr key={newCh._id} className="hover:bg-muted/20">
<td className="px-4 py-3 border-r text-muted-foreground">
@@ -448,10 +556,13 @@ function ChapterManager() {
<DialogFooter className="mt-auto pt-2">
<Button variant="outline" onClick={() => setOpenOptimize(false)}>Hủy bỏ</Button>
{!previewMode ? (
<Button onClick={handlePreviewOptimize}>Kiểm tra trước</Button>
<Button onClick={handlePreviewOptimize} disabled={loadingOptimizeSource}>
{loadingOptimizeSource && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Kiểm tra toàn bộ truyện
</Button>
) : (
<>
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing}>Quay lại Option</Button>
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing || loadingOptimizeSource}>Quay lại Option</Button>
<Button onClick={handleApplyOptimize} disabled={optimizing}>
{optimizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Lưu thay đi vào DB
@@ -471,6 +582,7 @@ function ChapterManager() {
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr>
<th scope="col" className="px-5 py-4 font-semibold w-24">Chương</th>
<th scope="col" className="px-5 py-4 font-semibold">Quyển</th>
<th scope="col" className="px-5 py-4 font-semibold">Tên chương</th>
<th scope="col" className="px-5 py-4 font-semibold text-right">Lượt đc</th>
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao tác</th>
@@ -478,13 +590,18 @@ function ChapterManager() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
) : chapters.length === 0 ? (
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr>
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr>
) : (
chapters.map((ch) => (
<tr key={ch._id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0">
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
<td className="px-5 py-4 text-muted-foreground">
{ch.volumeNumber || ch.volumeTitle
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
: "-"}
</td>
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right">
+1 -4
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import Link from "next/link"
import { BookOpen, Users, List, Home } from "lucide-react"
import { BookOpen, Home } from "lucide-react"
export default async function ModLayout({
children,
@@ -28,9 +28,6 @@ export default async function ModLayout({
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<BookOpen className="h-4 w-4" /> Quản truyện
</Link>
<Link href="/mod/chuong" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<List className="h-4 w-4" /> Quản chương
</Link>
</nav>
</aside>
+45 -5
View File
@@ -1,9 +1,45 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export default async function ModDashboardPage() {
const session = await getServerSession(authOptions)
const novelWhere = session?.user.role === "ADMIN"
? {}
: {
OR: [
{ uploaderId: session?.user.id },
{ uploaderId: null },
],
}
const [novelCount, novelViewsAgg, commentCount, seriesCount] = await Promise.all([
prisma.novel.count({ where: novelWhere }),
prisma.novel.aggregate({
where: novelWhere,
_sum: { views: true },
}),
prisma.comment.count({
where: {
novel: novelWhere,
},
}),
prisma.series.count({
where: session?.user.role === "ADMIN"
? {}
: {
OR: [
{ novels: { some: { uploaderId: session?.user.id } } },
{ novels: { some: { uploaderId: null } } },
{ novels: { none: {} } },
],
},
}),
])
const totalViews = novelViewsAgg._sum.views || 0
return (
<div>
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1>
@@ -11,18 +47,22 @@ export default async function ModDashboardPage() {
Chào mừng bạn đến với trang quản trị dành cho Moderator.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Truyện của bạn</h3>
<p className="text-3xl font-bold mt-2">0</p>
<h3 className="font-semibold text-lg">Tổng truyện</h3>
<p className="text-3xl font-bold mt-2">{novelCount}</p>
</div>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Tổng lượt xem</h3>
<p className="text-3xl font-bold mt-2">0</p>
<p className="text-3xl font-bold mt-2">{totalViews}</p>
</div>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Bình luận mới</h3>
<p className="text-3xl font-bold mt-2">0</p>
<p className="text-3xl font-bold mt-2">{commentCount}</p>
</div>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Tổng series</h3>
<p className="text-3xl font-bold mt-2">{seriesCount}</p>
</div>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { SeriesClient } from "./series-client"
export default async function ModSeriesPage() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <SeriesClient />
}
+230
View File
@@ -0,0 +1,230 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Layers, Loader2, Pencil, Plus, Save, Trash2, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
interface SeriesItem {
id: string
name: string
slug: string
description?: string | null
_count?: {
novels: number
}
}
export function SeriesClient() {
const [series, setSeries] = useState<SeriesItem[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [editingId, setEditingId] = useState<string | null>(null)
const [keyword, setKeyword] = useState("")
const filteredSeries = useMemo(() => {
const q = keyword.trim().toLowerCase()
if (!q) return series
return series.filter((item) => {
return (
item.name.toLowerCase().includes(q) ||
item.slug.toLowerCase().includes(q) ||
(item.description || "").toLowerCase().includes(q)
)
})
}, [keyword, series])
const fetchSeries = async () => {
try {
const res = await fetch("/api/mod/series")
if (!res.ok) throw new Error("Không thể tải danh sách series")
const data = await res.json()
setSeries(data)
} catch (error: any) {
toast.error(error.message || "Không thể tải danh sách series")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSeries()
}, [])
const resetForm = () => {
setEditingId(null)
setName("")
setDescription("")
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toast.error("Vui lòng nhập tên series")
return
}
setSubmitting(true)
try {
const payload = {
id: editingId,
name: name.trim(),
description: description.trim(),
}
const res = await fetch("/api/mod/series", {
method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Không thể lưu series")
}
toast.success(editingId ? "Đã cập nhật series" : "Đã tạo series")
resetForm()
fetchSeries()
} catch (error: any) {
toast.error(error.message || "Không thể lưu series")
} finally {
setSubmitting(false)
}
}
const handleEdit = (item: SeriesItem) => {
setEditingId(item.id)
setName(item.name)
setDescription(item.description || "")
}
const handleDelete = async (id: string) => {
if (!confirm("Bạn chắc chắn muốn xóa series này?")) return
setSubmitting(true)
try {
const res = await fetch(`/api/mod/series?id=${id}`, { method: "DELETE" })
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Không thể xóa series")
}
toast.success("Đã xóa series")
if (editingId === id) resetForm()
fetchSeries()
} catch (error: any) {
toast.error(error.message || "Không thể xóa series")
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div className="bg-card rounded-xl border shadow-sm p-4 flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Layers className="h-6 w-6 text-primary" /> Quản series
</h1>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm series..."
className="max-w-xs"
/>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="xl:col-span-1 rounded-xl border bg-card p-4 shadow-sm">
<h2 className="text-base font-semibold mb-3">{editingId ? "Chỉnh sửa series" : "Tạo series mới"}</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium">Tên series</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ví dụ: Overlord" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> tả</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Mô tả ngắn về series"
rows={4}
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={submitting} className="gap-2">
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{editingId ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{editingId ? "Lưu" : "Tạo"}
</Button>
{editingId && (
<Button type="button" variant="outline" onClick={resetForm} className="gap-2">
<X className="h-4 w-4" /> Hủy
</Button>
)}
</div>
</form>
</div>
<div className="xl:col-span-2 rounded-xl border bg-card shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr>
<th className="px-4 py-3 font-semibold">Tên</th>
<th className="px-4 py-3 font-semibold">Slug</th>
<th className="px-4 py-3 font-semibold">Số truyện</th>
<th className="px-4 py-3 font-semibold"> tả</th>
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
</td>
</tr>
) : filteredSeries.length === 0 ? (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
Chưa series nào
</td>
</tr>
) : (
filteredSeries.map((item) => (
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{item.name}</td>
<td className="px-4 py-3 text-muted-foreground">{item.slug}</td>
<td className="px-4 py-3">{item._count?.novels ?? 0}</td>
<td className="px-4 py-3 text-muted-foreground max-w-sm truncate">{item.description || "-"}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Button size="icon" variant="outline" className="h-8 w-8" onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+68 -6
View File
@@ -1,6 +1,7 @@
import Link from "next/link"
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
import { NovelCard } from "@/components/novel-card"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
import { prisma } from "@/lib/prisma"
@@ -19,6 +20,24 @@ const iconMap: Record<string, React.ReactNode> = {
export const dynamic = "force-dynamic"
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
const pickedSeries = new Set<string>()
const output: T[] = []
for (const row of rows) {
if (!row.seriesId) {
output.push(row)
continue
}
if (pickedSeries.has(row.seriesId)) continue
pickedSeries.add(row.seriesId)
output.push(row)
}
return output
}
export default async function HomePage() {
let popularNovels: any[] = []
let latestNovels: any[] = []
@@ -28,19 +47,60 @@ export default async function HomePage() {
try {
popularNovels = await prisma.novel.findMany({
take: 20,
take: 100,
select: {
id: true,
slug: true,
title: true,
authorName: true,
coverColor: true,
coverUrl: true,
rating: true,
views: true,
totalChapters: true,
status: true,
description: true,
seriesId: true,
},
orderBy: { views: "desc" },
})
popularNovels = collapseSeriesRows(popularNovels).slice(0, 20)
latestNovels = await prisma.novel.findMany({
take: 20,
take: 100,
select: {
id: true,
slug: true,
title: true,
authorName: true,
coverColor: true,
coverUrl: true,
rating: true,
views: true,
totalChapters: true,
status: true,
description: true,
seriesId: true,
},
orderBy: { updatedAt: "desc" },
})
latestNovels = collapseSeriesRows(latestNovels).slice(0, 20)
topRated = await prisma.novel.findMany({
take: 4,
take: 20,
select: {
id: true,
slug: true,
title: true,
authorName: true,
coverUrl: true,
rating: true,
totalChapters: true,
seriesId: true,
},
orderBy: { rating: "desc" },
})
topRated = collapseSeriesRows(topRated).slice(0, 4)
genres = await prisma.genre.findMany({
take: 8,
@@ -60,7 +120,7 @@ export default async function HomePage() {
href={`/truyen/${featured.slug}`}
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row"
>
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full object-cover md:h-auto md:w-72" />
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full bg-muted object-contain md:h-auto md:w-72" />
<div className="flex flex-1 flex-col justify-center gap-3 p-6">
<span className="text-xs font-semibold uppercase tracking-wider text-primary">Truyện Nổi Bật</span>
<h1 title={featured.title} className="text-2xl font-bold text-foreground group-hover:text-primary transition-colors text-balance md:text-3xl">
@@ -72,7 +132,9 @@ export default async function HomePage() {
</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{featured.totalChapters} chương</span>
<span>{featured.status}</span>
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getNovelStatusBadgeClass(featured.status)}`}>
{featured.status}
</span>
<span className="flex items-center gap-1 text-primary">
<Sparkles className="h-3.5 w-3.5" />
{featured.rating}
@@ -128,7 +190,7 @@ export default async function HomePage() {
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
{idx + 1}
</span>
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded object-cover" />
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded bg-muted object-contain" />
<div className="min-w-0 flex-1">
<h3 title={novel.title} className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{novel.title}</h3>
<p className="text-xs text-muted-foreground">{novel.authorName} - Ch. {novel.totalChapters}</p>
+35 -2
View File
@@ -6,6 +6,24 @@ import { notFound } from "next/navigation"
export const dynamic = "force-dynamic"
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
const pickedSeries = new Set<string>()
const output: T[] = []
for (const row of rows) {
if (!row.seriesId) {
output.push(row)
continue
}
if (pickedSeries.has(row.seriesId)) continue
pickedSeries.add(row.seriesId)
output.push(row)
}
return output
}
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
@@ -17,7 +35,7 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
notFound()
}
const allNovels = await prisma.novel.findMany({
const allNovelsRaw = await prisma.novel.findMany({
where: {
genres: {
some: {
@@ -25,12 +43,27 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
}
}
},
select: {
id: true,
slug: true,
title: true,
authorName: true,
coverColor: true,
coverUrl: true,
rating: true,
views: true,
totalChapters: true,
status: true,
seriesId: true,
},
orderBy: {
updatedAt: "desc"
},
take: 20
take: 80
})
const allNovels = collapseSeriesRows(allNovelsRaw).slice(0, 20)
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
return (
<div className="mx-auto max-w-6xl px-4 py-6">
+32 -3
View File
@@ -1,12 +1,29 @@
// Server component instead of client component
import { Search } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { NovelCard } from "@/components/novel-card"
import { prisma } from "@/lib/prisma"
export const dynamic = "force-dynamic"
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
const pickedSeries = new Set<string>()
const output: T[] = []
for (const row of rows) {
if (!row.seriesId) {
output.push(row)
continue
}
if (pickedSeries.has(row.seriesId)) continue
pickedSeries.add(row.seriesId)
output.push(row)
}
return output
}
export default async function SearchPage({
searchParams,
}: {
@@ -25,6 +42,7 @@ export default async function SearchPage({
where.OR = [
{ title: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
{ series: { name: { contains: q, mode: "insensitive" } } },
]
}
@@ -59,12 +77,23 @@ export default async function SearchPage({
orderBy = { updatedAt: "desc" }
}
const filteredNovels = await prisma.novel.findMany({
const filteredNovelsRaw = await prisma.novel.findMany({
where,
orderBy,
take: 20,
include: {
series: {
select: {
id: true,
name: true,
slug: true,
},
},
},
take: 80,
})
const filteredNovels = q ? filteredNovelsRaw.slice(0, 20) : collapseSeriesRows(filteredNovelsRaw).slice(0, 20)
const genres = await prisma.genre.findMany()
return (
+40 -14
View File
@@ -62,9 +62,11 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
// Extract paragraphs for TTS
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
const chapterLabel = (chapter as any).volumeChapterNumber ? `Chương ${(chapter as any).volumeChapterNumber}` : `Chương ${chapter.number}`
const volumeLabel = (chapter as any).volumeTitle || ((chapter as any).volumeNumber ? `Quyển ${(chapter as any).volumeNumber}` : null)
return (
<div className="mx-auto max-w-4xl lg:max-w-screen-lg px-4 py-6 md:px-8">
<div className="mx-auto max-w-4xl px-3 py-4 md:px-8 md:py-6 lg:max-w-screen-lg">
{/* Top navigation */}
<div className="mb-6 flex flex-col gap-3">
<Link href={`/truyen/${slug}`} className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
@@ -73,20 +75,26 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
<div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-bold text-foreground md:text-xl lg:text-2xl">
Chương {chapter.number}: {chapter.title}
{volumeLabel ? `${volumeLabel} - ` : ""}{chapterLabel}: {chapter.title}
</h1>
</div>
</div>
{/* Chapter navigation top */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex items-center justify-between gap-2">
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
{hasPrev ? (
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
<ChevronLeft className="mr-1 h-4 w-4" /> Ch. trước
<ChevronLeft className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Ch. trước</span>
<span className="sm:hidden">Trước</span>
</Link>
) : (
<span><ChevronLeft className="mr-1 h-4 w-4" /> Ch. trước</span>
<span>
<ChevronLeft className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Ch. trước</span>
<span className="sm:hidden">Trước</span>
</span>
)}
</Button>
<Button variant="outline" size="sm" asChild>
@@ -97,16 +105,22 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
{hasNext ? (
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
Ch. sau <ChevronRight className="ml-1 h-4 w-4" />
<span className="hidden sm:inline">Ch. sau</span>
<span className="sm:hidden">Sau</span>
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
) : (
<span>Ch. sau <ChevronRight className="ml-1 h-4 w-4" /></span>
<span>
<span className="hidden sm:inline">Ch. sau</span>
<span className="sm:hidden">Sau</span>
<ChevronRight className="ml-1 h-4 w-4" />
</span>
)}
</Button>
</div>
{/* Chapter content */}
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-6 font-serif text-foreground/90 md:p-8 lg:p-12 text-justify">
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-4 font-serif text-foreground/90 text-justify md:p-8 lg:p-12">
{paragraphs.map((text: string, idx: number) => (
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
{text}
@@ -115,23 +129,35 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
</article>
{/* Chapter navigation bottom */}
<div className="mb-8 flex items-center justify-between">
<div className="mb-8 flex items-center justify-between gap-2">
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
{hasPrev ? (
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
<ChevronLeft className="mr-1 h-4 w-4" /> Chương trước
<ChevronLeft className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Chương trước</span>
<span className="sm:hidden">Trước</span>
</Link>
) : (
<span><ChevronLeft className="mr-1 h-4 w-4" /> Chương trước</span>
<span>
<ChevronLeft className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Chương trước</span>
<span className="sm:hidden">Trước</span>
</span>
)}
</Button>
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
{hasNext ? (
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
Chương sau <ChevronRight className="ml-1 h-4 w-4" />
<span className="hidden sm:inline">Chương sau</span>
<span className="sm:hidden">Sau</span>
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
) : (
<span>Chương sau <ChevronRight className="ml-1 h-4 w-4" /></span>
<span>
<span className="hidden sm:inline">Chương sau</span>
<span className="sm:hidden">Sau</span>
<ChevronRight className="ml-1 h-4 w-4" />
</span>
)}
</Button>
</div>
@@ -151,7 +177,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
paragraphs={paragraphs}
currentChapter={chapterNumber}
maxChapter={maxChapter}
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
chapterTitle={`${volumeLabel ? `${volumeLabel} - ` : ""}${chapterLabel}: ${chapter.title}`}
/>
</div>
)
+108 -42
View File
@@ -10,6 +10,7 @@ import { NovelDetailActions } from "./novel-detail-actions"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
export const dynamic = "force-dynamic"
@@ -39,32 +40,72 @@ export default async function NovelDetailPage({
notFound()
}
// Fetch chapters from MongoDB
let formattedChapters: any[] = []
let totalChapters = 0
let totalPages = 1
let firstChapterNumber: number | undefined
let seriesVolumes: Array<{
id: string
slug: string
title: string
status: string
totalChapters: number
coverUrl: string | null
updatedAt: Date
}> = []
await connectToMongoDB()
const skip = (currentPage - 1) * limit
const [chapters, totalChapters] = await Promise.all([
Chapter.find({ novelId: novel.id })
.sort({ number: 1 })
.skip(skip)
.limit(limit)
.select("id novelId number title createdAt views")
.lean(),
Chapter.countDocuments({ novelId: novel.id })
])
if (novel.seriesId) {
const [firstChapter, volumes] = await Promise.all([
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
prisma.novel.findMany({
where: { seriesId: novel.seriesId },
select: {
id: true,
slug: true,
title: true,
status: true,
totalChapters: true,
coverUrl: true,
updatedAt: true,
},
orderBy: { createdAt: "asc" },
}),
])
const totalPages = Math.ceil(totalChapters / limit)
firstChapterNumber = (firstChapter as any)?.number
seriesVolumes = volumes
} else {
const skip = (currentPage - 1) * limit
const [chapters, chaptersCount, firstChapter] = await Promise.all([
Chapter.find({ novelId: novel.id })
.sort({ number: 1 })
.skip(skip)
.limit(limit)
.select("id novelId number title createdAt views volumeNumber volumeTitle volumeChapterNumber")
.lean(),
Chapter.countDocuments({ novelId: novel.id }),
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
])
// Convert Mongoose documents to plain objects for Server Component
const formattedChapters = chapters.map(c => ({
id: c._id.toString(),
novelId: c.novelId,
number: c.number,
title: c.title,
createdAt: (c.createdAt as Date).toISOString(),
views: c.views || 0,
content: "" // We don't fetch content for the list
}))
totalChapters = chaptersCount
totalPages = Math.ceil(totalChapters / limit)
firstChapterNumber = (firstChapter as any)?.number
formattedChapters = chapters.map(c => ({
id: c._id.toString(),
novelId: c.novelId,
number: c.number,
volumeNumber: (c as any).volumeNumber ?? null,
volumeTitle: (c as any).volumeTitle ?? null,
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
title: c.title,
createdAt: (c.createdAt as Date).toISOString(),
views: c.views || 0,
content: ""
}))
}
const commentsData = await prisma.comment.findMany({
where: { novelId: novel.id, chapterId: null },
@@ -107,7 +148,7 @@ export default async function NovelDetailPage({
{/* Novel Header */}
<div className="flex flex-col gap-6 md:flex-row">
{/* Cover */}
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-64 w-44 shrink-0 self-center rounded-xl object-cover shadow-lg md:self-start bg-muted" />
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-64 w-44 shrink-0 self-center rounded-xl bg-muted object-contain shadow-lg md:self-start" />
{/* Info */}
<div className="flex flex-1 flex-col gap-3">
@@ -132,11 +173,7 @@ export default async function NovelDetailPage({
<div className="flex flex-col gap-3 mt-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Trạng thái:</span>
<span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${
novel.status === "Hoàn thành" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
novel.status === "Tạm dừng" || novel.status === "Tạm ngưng" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" :
"bg-primary/10 text-primary" // Đang ra
}`}>
<span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
{novel.status}
</span>
</div>
@@ -177,7 +214,7 @@ export default async function NovelDetailPage({
</div>
<div className="mt-4">
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={firstChapterNumber} />
</div>
</div>
</div>
@@ -188,19 +225,48 @@ export default async function NovelDetailPage({
<div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description}</div>
</section>
{/* Chapter list */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2>
<div className="rounded-lg border border-border bg-card">
<ChapterList
chapters={formattedChapters as any}
novelSlug={novel.slug}
currentPage={currentPage}
totalPages={totalPages}
totalChapters={totalChapters}
/>
</div>
</section>
{/* Chapter list or series volumes */}
{novel.seriesId ? (
<section className="mt-8">
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Quyển</h2>
<div className="rounded-lg border border-border bg-card divide-y divide-border">
{seriesVolumes.map((volume, idx) => (
<Link
key={volume.id}
href={`/truyen/${volume.slug}`}
className={`flex items-center gap-4 px-4 py-3 hover:bg-muted/40 transition-colors ${volume.id === novel.id ? "bg-primary/5" : ""}`}
>
<span className="w-8 text-center text-sm font-semibold text-muted-foreground">{idx + 1}</span>
<img
src={volume.coverUrl || "/default-cover.svg"}
alt={volume.title}
className="h-14 w-10 rounded bg-muted object-contain"
/>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground truncate">{volume.title}</p>
<p className="text-xs text-muted-foreground">{volume.totalChapters} chương</p>
</div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(volume.status)}`}>
{volume.status}
</span>
</Link>
))}
</div>
</section>
) : (
<section className="mt-8">
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2>
<div className="rounded-lg border border-border bg-card">
<ChapterList
chapters={formattedChapters as any}
novelSlug={novel.slug}
currentPage={currentPage}
totalPages={totalPages}
totalChapters={totalChapters}
/>
</div>
</section>
)}
{/* Comments */}
<section className="mt-8">
+7 -1
View File
@@ -5,6 +5,7 @@ import { BookOpen, BookMarked, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
export default function BookshelfPage() {
@@ -55,13 +56,18 @@ export default function BookshelfPage() {
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/20"
>
<Link href={`/truyen/${novel.slug}`}>
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-16 w-12 shrink-0 rounded-md object-cover hover:opacity-90" />
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-16 w-12 shrink-0 rounded-md bg-muted object-contain hover:opacity-90" />
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link title={novel.title} href={`/truyen/${novel.slug}`} className="truncate text-sm font-semibold text-foreground hover:text-primary transition-colors">
{novel.title}
</Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
{novel.status}
</span>
</div>
{bookmark.lastChapterNumber && (
<p className="text-xs text-muted-foreground">
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}