From 5686753ab720428187c40560285bd72eed48c7b1 Mon Sep 17 00:00:00 2001 From: virtus Date: Wed, 11 Mar 2026 17:02:31 +0700 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- README.md | 7 + app/api/mod/chuong/optimize/route.ts | 28 +- app/api/mod/chuong/route.ts | 22 +- app/api/mod/epub/route.ts | 744 +++++++++- app/api/mod/series/route.ts | 200 +++ app/api/mod/truyen/[id]/route.ts | 16 +- app/api/mod/truyen/bulk/route.ts | 70 + app/api/mod/truyen/route.ts | 199 ++- app/api/mod/upload-cover/route.ts | 25 +- app/api/truyen/[id]/chapters/route.ts | 5 +- app/api/truyen/suggest/route.ts | 42 + app/mod/chuong/[id]/editor-client.tsx | 25 +- app/mod/chuong/chapter-client.tsx | 183 ++- app/mod/layout.tsx | 5 +- app/mod/page.tsx | 50 +- app/mod/series/page.tsx | 13 + app/mod/series/series-client.tsx | 230 ++++ app/mod/truyen/novel-client.tsx | 1011 +++++++++++++- app/page.tsx | 74 +- app/the-loai/[slug]/page.tsx | 37 +- app/tim-kiem/page.tsx | 35 +- app/truyen/[slug]/[chapterId]/page.tsx | 54 +- app/truyen/[slug]/page.tsx | 150 ++- app/tu-sach/page.tsx | 8 +- components/chapter-list.tsx | 92 +- components/header.tsx | 98 +- components/novel-card.tsx | 23 +- components/reader-fab.tsx | 63 +- components/reader-toc.tsx | 62 +- components/tts-player.tsx | 16 +- lib/models/chapter.ts | 6 + lib/novel-status.ts | 17 + lib/r2.ts | 140 ++ lib/types.ts | 8 + next-env.d.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 1191 +++++++++++++++++ prisma/schema.prisma | 14 + .../cover-epub-1773196719262-213975985.jpg | Bin 0 -> 10661 bytes .../cover-epub-1773197374152-861352812.jpg | Bin 0 -> 10661 bytes .../cover-epub-1773197517775-661029329.jpg | Bin 0 -> 295474 bytes tsconfig.tsbuildinfo | 2 +- 42 files changed, 4659 insertions(+), 309 deletions(-) create mode 100644 app/api/mod/series/route.ts create mode 100644 app/api/mod/truyen/bulk/route.ts create mode 100644 app/api/truyen/suggest/route.ts create mode 100644 app/mod/series/page.tsx create mode 100644 app/mod/series/series-client.tsx create mode 100644 lib/novel-status.ts create mode 100644 lib/r2.ts create mode 100644 public/uploads/covers/cover-epub-1773196719262-213975985.jpg create mode 100644 public/uploads/covers/cover-epub-1773197374152-861352812.jpg create mode 100644 public/uploads/covers/cover-epub-1773197517775-661029329.jpg diff --git a/README.md b/README.md index f0a736b..a3449d5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ NEXTAUTH_URL="http://localhost:3000" # Cấu hình Google Login GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_SECRET="your_google_client_secret" + +# Cloudflare R2 (lưu ảnh bìa) +R2_ACCOUNT_ID="your_cloudflare_account_id" +R2_ACCESS_KEY_ID="your_r2_access_key_id" +R2_SECRET_ACCESS_KEY="your_r2_secret_access_key" +R2_BUCKET_NAME="your_r2_bucket_name" +R2_PUBLIC_BASE_URL="https://your-public-r2-domain" ``` ### 3. Cài đặt dependencies và khởi tạo DB diff --git a/app/api/mod/chuong/optimize/route.ts b/app/api/mod/chuong/optimize/route.ts index 86e01c9..09b36bd 100644 --- a/app/api/mod/chuong/optimize/route.ts +++ b/app/api/mod/chuong/optimize/route.ts @@ -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 }) diff --git a/app/api/mod/chuong/route.ts b/app/api/mod/chuong/route.ts index e95afb9..d7abff5 100644 --- a/app/api/mod/chuong/route.ts +++ b/app/api/mod/chuong/route.ts @@ -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 } ) diff --git a/app/api/mod/epub/route.ts b/app/api/mod/epub/route.ts index 972f506..052254b 100644 --- a/app/api/mod/epub/route.ts +++ b/app/api/mod/epub/route.ts @@ -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 = { + 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 { + 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((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((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 { + 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((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((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((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 }) diff --git a/app/api/mod/series/route.ts b/app/api/mod/series/route.ts new file mode 100644 index 0000000..7ec2eb4 --- /dev/null +++ b/app/api/mod/series/route.ts @@ -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 }) + } +} diff --git a/app/api/mod/truyen/[id]/route.ts b/app/api/mod/truyen/[id]/route.ts index 6584fcb..409400e 100644 --- a/app/api/mod/truyen/[id]/route.ts +++ b/app/api/mod/truyen/[id]/route.ts @@ -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 diff --git a/app/api/mod/truyen/bulk/route.ts b/app/api/mod/truyen/bulk/route.ts new file mode 100644 index 0000000..861f350 --- /dev/null +++ b/app/api/mod/truyen/bulk/route.ts @@ -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 }) + } +} diff --git a/app/api/mod/truyen/route.ts b/app/api/mod/truyen/route.ts index b2a4f17..8d52ad6 100644 --- a/app/api/mod/truyen/route.ts +++ b/app/api/mod/truyen/route.ts @@ -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 { + 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 }) } diff --git a/app/api/mod/upload-cover/route.ts b/app/api/mod/upload-cover/route.ts index 2bd362d..99e5723 100644 --- a/app/api/mod/upload-cover/route.ts +++ b/app/api/mod/upload-cover/route.ts @@ -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 }) diff --git a/app/api/truyen/[id]/chapters/route.ts b/app/api/truyen/[id]/chapters/route.ts index 70fdd10..d7901f3 100644 --- a/app/api/truyen/[id]/chapters/route.ts +++ b/app/api/truyen/[id]/chapters/route.ts @@ -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, diff --git a/app/api/truyen/suggest/route.ts b/app/api/truyen/suggest/route.ts new file mode 100644 index 0000000..6dfbbfb --- /dev/null +++ b/app/api/truyen/suggest/route.ts @@ -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 }) + } +} diff --git a/app/mod/chuong/[id]/editor-client.tsx b/app/mod/chuong/[id]/editor-client.tsx index 92fe276..9106f95 100644 --- a/app/mod/chuong/[id]/editor-client.tsx +++ b/app/mod/chuong/[id]/editor-client.tsx @@ -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 */}
-
+
setNumber(e.target.value)} className="font-mono" />
+
+ + setVolumeNumber(e.target.value)} className="font-mono" placeholder="VD: 1" /> +
+
+ + setVolumeChapterNumber(e.target.value)} className="font-mono" placeholder="VD: 3" /> +
+ + setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" /> +
+
+
+
setTitle(e.target.value)} />
diff --git a/app/mod/chuong/chapter-client.tsx b/app/mod/chuong/chapter-client.tsx index 3ba1e36..1a9ba61 100644 --- a/app/mod/chuong/chapter-client.tsx +++ b/app/mod/chuong/chapter-client.tsx @@ -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([]) + const [optimizeSourceChapters, setOptimizeSourceChapters] = useState([]) // 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 => { + 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(`Đã tổi ưu ${data.modifiedCount} chương!`) + toast.success(`Đã tối ư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 (
@@ -310,6 +391,8 @@ function ChapterManager() { @@ -341,12 +424,26 @@ function ChapterManager() {
-
+
setNumber(e.target.value)} required />
+
+ + setVolumeNumber(e.target.value)} placeholder="VD: 1" /> +
+
+ + setVolumeChapterNumber(e.target.value)} placeholder="VD: 5" /> +
+ + setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" /> +
+
+
+
setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
@@ -389,12 +486,23 @@ function ChapterManager() { - + { + setOpenOptimize(nextOpen) + if (!nextOpen) { + setPreviewMode(false) + setOptimizedChapters([]) + setOptimizeSourceChapters([]) + setLoadingOptimizeSource(false) + } + }} + > Tối Ưu Hóa Chương Hàng Loạt - Công cụ dọn dẹp tên chương và đánh lại số thứ tự tự động tiện lợi sau khi đăng ép từ tệp EPUB. + Công cụ sẽ áp dụng trên toàn bộ chương của truyện hiện tại, không chỉ page bạn đang xem. @@ -425,8 +533,8 @@ function ChapterManager() { - {optimizedChapters.map((newCh, i) => { - const oldCh = chapters[i] + {optimizedChapters.map((newCh) => { + const oldCh = optimizeSourceMap.get(newCh._id) || newCh return ( @@ -448,10 +556,13 @@ function ChapterManager() { {!previewMode ? ( - + ) : ( <> - +