Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user