diff --git a/app/api/mod/epub/route.ts b/app/api/mod/epub/route.ts index 052254b..adbae0f 100644 --- a/app/api/mod/epub/route.ts +++ b/app/api/mod/epub/route.ts @@ -9,11 +9,13 @@ 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" +import { deleteR2ObjectByUrl, uploadBufferToR2 } from "@/lib/r2" type SplitMode = "toc" | "regex" type SeriesMode = "none" | "existing" | "new" +type UserRole = "USER" | "MOD" | "ADMIN" + interface EpubSection { sourceTitle: string content: string @@ -46,6 +48,79 @@ const CHAPTER_REGEX_PRESETS: Record = { 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 +const GENERIC_SECTION_TITLE_REGEX = /^(?:m[uụ]c|section|sec\.?|part|ph[aầ]n)\s*[0-9ivxlcdm]+$/i +const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i +const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i +const GENERIC_GENRE_TOKENS = new Set([ + "book", + "books", + "ebook", + "fiction", + "literature", + "novel", + "story", + "truyen", + "tiểu thuyết", +]) + +function isWeakTOCTitle(title: string): boolean { + const normalized = title.trim() + return ( + SIMPLE_CHAPTER_TITLE_REGEX.test(normalized) || + GENERIC_SECTION_TITLE_REGEX.test(normalized) || + WEAK_CHAPTER_TOC_TITLE_REGEX.test(normalized) + ) +} + +function pickChapterHeadingFromContent(content: string): { + heading: string + chapterNumber: number | null + consumedLineIndexes: number[] +} | null { + const lines = content.split(/\r?\n/) + const nonEmptyIndexes = lines + .map((line, index) => ({ line: line.trim(), index })) + .filter((item) => item.line.length > 0) + + for (let i = 0; i < nonEmptyIndexes.length && i < 12; i++) { + const current = nonEmptyIndexes[i] + const headingLine = current.line.replace(/\s+/g, " ").trim() + + if (!headingLine || headingLine.length > 180) continue + if (NOISE_TITLE_REGEX.test(headingLine) || isVolumeHeading(headingLine)) continue + + const matched = headingLine.match(CHAPTER_HEADING_LINE_REGEX) + if (!matched) continue + + const parsed = Number(matched[1]) + const chapterNumber = Number.isInteger(parsed) && parsed > 0 && parsed <= 50000 ? parsed : null + const consumedLineIndexes = [current.index] + + let heading = headingLine + const trailingTitle = (matched[2] || "").trim() + if (!trailingTitle) { + const next = nonEmptyIndexes[i + 1] + if (next) { + const subtitle = next.line.replace(/\s+/g, " ").trim() + if ( + subtitle.length > 0 && + subtitle.length <= 120 && + !NOISE_TITLE_REGEX.test(subtitle) && + !isVolumeHeading(subtitle) && + !CHAPTER_HEADING_LINE_REGEX.test(subtitle) + ) { + heading = `${headingLine.replace(/[:\-–—\.\s]+$/g, "")}: ${subtitle}` + consumedLineIndexes.push(next.index) + } + } + } + + return { heading, chapterNumber, consumedLineIndexes } + } + + return null +} + function normalizeMetaText(value: any, fallback: string) { if (typeof value === "string" && value.trim().length > 0) return value.trim() if (Array.isArray(value)) { @@ -55,6 +130,146 @@ function normalizeMetaText(value: any, fallback: string) { return fallback } +function canReplaceNovelByRole(userRole: UserRole, userId: string, novel: { uploaderId: string | null }): boolean { + if (userRole === "ADMIN") return true + return novel.uploaderId === userId || novel.uploaderId === null +} + +function collectTextValues(input: any, bucket: string[]) { + if (input === null || input === undefined) return + + if (typeof input === "string") { + bucket.push(input) + return + } + + if (typeof input === "number" || typeof input === "boolean") { + bucket.push(String(input)) + return + } + + if (Array.isArray(input)) { + input.forEach((item) => collectTextValues(item, bucket)) + return + } + + if (typeof input === "object") { + Object.values(input).forEach((value) => collectTextValues(value, bucket)) + } +} + +function normalizeGenreCandidate(name: string): string { + return name + .replace(/\s+/g, " ") + .replace(/^[\s\-–—:;,.\/|]+|[\s\-–—:;,.\/|]+$/g, "") + .trim() +} + +function extractGenreCandidatesFromMetadata(metadata: any): string[] { + if (!metadata || typeof metadata !== "object") return [] + + const rawValues: string[] = [] + const keys = Object.keys(metadata) + const candidateKeys = keys.filter((key) => /subject|genre|tag|category/i.test(key)) + + for (const key of candidateKeys) { + collectTextValues((metadata as Record)[key], rawValues) + } + + const uniqueNames: string[] = [] + const seen = new Set() + + for (const raw of rawValues) { + const chunks = raw + .split(/[,;|/\n]+/) + .map((chunk) => normalizeGenreCandidate(chunk)) + .filter(Boolean) + + for (const name of chunks) { + const normalized = name.toLowerCase() + if (name.length < 2 || name.length > 80) continue + if (GENERIC_GENRE_TOKENS.has(normalized)) continue + if (seen.has(normalized)) continue + + seen.add(normalized) + uniqueNames.push(name) + + if (uniqueNames.length >= 12) { + return uniqueNames + } + } + } + + return uniqueNames +} + +async function resolveGenreIdsFromNames(genreNames: string[], createIfMissing: boolean): Promise { + const ids: string[] = [] + + for (const genreName of genreNames) { + const existing = await prisma.genre.findFirst({ + where: { name: { equals: genreName, mode: "insensitive" } }, + select: { id: true }, + }) + + if (existing) { + ids.push(existing.id) + continue + } + + if (!createIfMissing) continue + + const baseSlug = slugify(genreName) || `genre-${Date.now()}` + let slug = baseSlug + let counter = 1 + + while (await prisma.genre.findUnique({ where: { slug } })) { + slug = `${baseSlug}-${counter}` + counter += 1 + } + + try { + const created = await prisma.genre.create({ + data: { + name: genreName, + slug, + }, + select: { id: true }, + }) + ids.push(created.id) + } catch (error: any) { + if (error?.code === "P2002") { + const fallback = await prisma.genre.findFirst({ + where: { name: { equals: genreName, mode: "insensitive" } }, + select: { id: true }, + }) + if (fallback) { + ids.push(fallback.id) + continue + } + } + + throw error + } + } + + return Array.from(new Set(ids)) +} + +async function findNovelByTitleInsensitive(title: string) { + return prisma.novel.findFirst({ + where: { title: { equals: title, mode: "insensitive" } }, + orderBy: { updatedAt: "desc" }, + select: { + id: true, + title: true, + slug: true, + coverUrl: true, + uploaderId: true, + }, + }) +} + 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 @@ -78,20 +293,69 @@ function extractStrictChapterNumber(title: string): number | null { return number } -function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string } { +function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string; detectedChapterNumber: number | null } { const lines = content.split(/\r?\n/) const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim().length > 0) - if (firstNonEmptyLineIndex < 0) return { title, content } + const baseTitle = title.trim() + const baseDetectedChapterNumber = extractStrictChapterNumber(baseTitle) + if (firstNonEmptyLineIndex < 0) { + return { + title, + content, + detectedChapterNumber: baseDetectedChapterNumber, + } + } const firstLineRaw = lines[firstNonEmptyLineIndex] const firstLine = firstLineRaw.trim() - if (!firstLine || firstLine.length > 140) return { title, content } + if (!firstLine) { + return { + title, + content, + detectedChapterNumber: baseDetectedChapterNumber, + } + } + + const detectedHeading = pickChapterHeadingFromContent(content) + if (detectedHeading) { + const shouldUseDetectedHeading = + isWeakTOCTitle(baseTitle) || + baseDetectedChapterNumber === null || + detectedHeading.chapterNumber === baseDetectedChapterNumber + + if (shouldUseDetectedHeading) { + const nextLines = [...lines] + detectedHeading.consumedLineIndexes + .sort((a, b) => b - a) + .forEach((index) => { + nextLines.splice(index, 1) + }) + + const nextContent = nextLines.join("\n").trim() + return { + title: detectedHeading.heading, + content: nextContent.length > 0 ? nextContent : content, + detectedChapterNumber: detectedHeading.chapterNumber, + } + } + } + + if (firstLine.length > 140) { + return { + title, + content, + detectedChapterNumber: baseDetectedChapterNumber, + } + } - const baseTitle = title.trim() const isSimpleBaseTitle = SIMPLE_CHAPTER_TITLE_REGEX.test(baseTitle) if (!isSimpleBaseTitle) { - return { title, content } + return { + title, + content, + detectedChapterNumber: baseDetectedChapterNumber, + } } let nextTitle = baseTitle @@ -103,7 +367,11 @@ function enhanceChapterTitleFromContent(title: string, content: string): { title // Case 2: TOC title is only "Chương N", subtitle is on next line. nextTitle = `${baseTitle.replace(/[:\s]+$/g, "")}: ${firstLine}` } else { - return { title, content } + return { + title, + content, + detectedChapterNumber: baseDetectedChapterNumber, + } } const newLines = [...lines] @@ -113,6 +381,7 @@ function enhanceChapterTitleFromContent(title: string, content: string): { title return { title: nextTitle, content: nextContent.length > 0 ? nextContent : content, + detectedChapterNumber: extractStrictChapterNumber(nextTitle) ?? baseDetectedChapterNumber, } } @@ -330,10 +599,11 @@ function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[] } } + const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent) let volumeChapterNumber: number | null = null - const detectedChapterNumber = extractStrictChapterNumber(cleanTitle) + const detectedChapterNumber = enhanced.detectedChapterNumber if (currentVolumeNumber !== null) { - const explicitChapter = extractChapterNumber(cleanTitle) + const explicitChapter = extractChapterNumber(enhanced.title) if (explicitChapter !== null) { currentVolumeChapterCounter = explicitChapter } else { @@ -342,8 +612,6 @@ function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[] volumeChapterNumber = currentVolumeChapterCounter } - const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent) - chapters.push({ title: enhanced.title, content: enhanced.content, @@ -414,13 +682,30 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): { for (const chapter of chapters) { const detected = chapter.detectedChapterNumber - const canUseDetected = - typeof detected === "number" && - detected > currentNumber && - detected - currentNumber <= MAX_ALLOWED_GAP + const detectedNumber = typeof detected === "number" ? detected : null + let canUseDetected = + detectedNumber !== null && + detectedNumber > currentNumber && + detectedNumber - currentNumber <= MAX_ALLOWED_GAP - if (canUseDetected) { - for (let missing = currentNumber + 1; missing < detected; missing++) { + // Recover from noisy leading TOC entries such as "Mục 1" that shift numbering. + if (!canUseDetected && detectedNumber !== null && detectedNumber > 0 && detectedNumber <= currentNumber) { + while ( + normalized.length > 0 && + detectedNumber <= currentNumber && + normalized[normalized.length - 1].detectedChapterNumber === null && + !normalized[normalized.length - 1].isPlaceholder && + isWeakTOCTitle(normalized[normalized.length - 1].title) + ) { + normalized.pop() + currentNumber = Math.max(0, currentNumber - 1) + } + + canUseDetected = detectedNumber > currentNumber && detectedNumber - currentNumber <= MAX_ALLOWED_GAP + } + + if (canUseDetected && detectedNumber !== null) { + for (let missing = currentNumber + 1; missing < detectedNumber; missing++) { insertedCount += 1 normalized.push({ title: `Chương ${missing} (Thiếu)`, @@ -435,7 +720,7 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): { } detectedNumberAssignments += 1 - currentNumber = detected + currentNumber = detectedNumber normalized.push({ ...chapter, finalNumber: currentNumber, @@ -608,6 +893,7 @@ export async function POST(req: Request) { const seriesMode = normalizeSeriesMode(formData.get("seriesMode")) const seriesIdInput = readFormText(formData, "seriesId") const seriesNameInput = readFormText(formData, "seriesName") + const replaceExisting = String(formData.get("replaceExisting") || "").toLowerCase() === "true" if (!epubFile) { return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 }) @@ -687,10 +973,12 @@ export async function POST(req: Request) { 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 detectedGenreNames = extractGenreCandidatesFromMetadata(metadata) const novelTitle = normalizeMetaText(readFormText(formData, "title"), metadataTitle) const novelAuthor = normalizeMetaText(readFormText(formData, "authorName"), metadataAuthor) const novelDesc = normalizeMetaText(readFormText(formData, "description"), metadataDesc) + const importDefaultStatus = "Hoàn thành" const hasDetectedVolumes = chapters.some((ch: any) => ch.volumeNumber !== null) @@ -706,6 +994,7 @@ export async function POST(req: Request) { title: novelTitle, authorName: novelAuthor, description: novelDesc, + detectedGenres: detectedGenreNames, totalChapters: chapters.length, }, chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({ @@ -720,6 +1009,39 @@ export async function POST(req: Request) { }) } + const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle) + const canReplaceDuplicated = duplicatedNovel + ? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel) + : false + + if (duplicatedNovel && !replaceExisting) { + return NextResponse.json({ + code: "DUPLICATE_TITLE", + error: `Truyện \"${duplicatedNovel.title}\" đã tồn tại`, + canReplace: canReplaceDuplicated, + existingNovel: { + id: duplicatedNovel.id, + title: duplicatedNovel.title, + slug: duplicatedNovel.slug, + }, + }, { status: 409 }) + } + + if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) { + return NextResponse.json({ + code: "DUPLICATE_TITLE", + error: "Bạn không có quyền replace truyện đã tồn tại", + canReplace: false, + existingNovel: { + id: duplicatedNovel.id, + title: duplicatedNovel.title, + slug: duplicatedNovel.slug, + }, + }, { status: 403 }) + } + + const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true) + const selectedSeriesId = await resolveSeriesIdForEpubImport({ mode: seriesMode, seriesId: seriesIdInput, @@ -728,55 +1050,123 @@ export async function POST(req: Request) { userId: session.user.id, }) - // Generate base slug - const baseSlug = slugify(novelTitle) - - let slug = baseSlug - let slugCounter = 1 - - // Đảm bảo slug là duy nhất - while (await prisma.novel.findUnique({ where: { slug } })) { - slug = `${baseSlug}-${slugCounter}` - slugCounter++ - } - const coverUrl = await saveCoverBufferToR2(cover) - const newNovel = await prisma.novel.create({ - data: { + let targetNovelId = duplicatedNovel?.id || "" + let responseStatus = 201 + let replaced = false + + if (duplicatedNovel && replaceExisting) { + const updatedNovel = await prisma.$transaction(async (tx) => { + await tx.novel.update({ + where: { id: duplicatedNovel.id }, + data: { + title: novelTitle, + authorName: novelAuthor, + description: novelDesc, + status: importDefaultStatus, + coverUrl, + seriesId: selectedSeriesId, + totalChapters: chapters.length, + ...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}), + }, + }) + + await tx.novelGenre.deleteMany({ + where: { novelId: duplicatedNovel.id }, + }) + + if (resolvedGenreIds.length > 0) { + await tx.novelGenre.createMany({ + data: resolvedGenreIds.map((genreId) => ({ + novelId: duplicatedNovel.id, + genreId, + })), + skipDuplicates: true, + }) + } + + return tx.novel.findUnique({ where: { id: duplicatedNovel.id } }) + }) + + if (!updatedNovel) { + throw new Error("Không thể replace truyện đã tồn tại") + } + + targetNovelId = updatedNovel.id + responseStatus = 200 + replaced = true + + if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) { + await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { }) + } + } else { + // Generate base slug + const baseSlug = slugify(novelTitle) + let slug = baseSlug + let slugCounter = 1 + + // Đảm bảo slug là duy nhất + while (await prisma.novel.findUnique({ where: { slug } })) { + slug = `${baseSlug}-${slugCounter}` + slugCounter++ + } + + const createData: any = { title: novelTitle, - slug: slug, + slug, authorName: novelAuthor, description: novelDesc, + status: importDefaultStatus, coverUrl, seriesId: selectedSeriesId, uploaderId: session.user.id, totalChapters: chapters.length, - }, - }) + } + + if (resolvedGenreIds.length > 0) { + createData.genres = { + create: resolvedGenreIds.map((genreId) => ({ + genre: { connect: { id: genreId } }, + })), + } + } + + const createdNovel = await prisma.novel.create({ data: createData }) + targetNovelId = createdNovel.id + } // Lưu chapters xuống MongoDB await connectToMongoDB() + await Chapter.deleteMany({ novelId: targetNovelId }) + const chapterDocs = chapters.map((ch: any, i: number) => ({ - novelId: newNovel.id, + novelId: targetNovelId, 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 + views: 0, })) if (chapterDocs.length > 0) { await Chapter.insertMany(chapterDocs) } + const novelAfterWrite = await prisma.novel.findUnique({ where: { id: targetNovelId } }) + if (!novelAfterWrite) { + throw new Error("Không thể tải lại thông tin truyện sau khi import") + } + return NextResponse.json({ - ...newNovel, + ...novelAfterWrite, parserInfo, hasCoverFromEpub: !!coverUrl, - }, { status: 201 }) + detectedGenres: detectedGenreNames, + replaced, + }, { status: responseStatus }) } 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/truyen/missing/route.ts b/app/api/mod/truyen/missing/route.ts new file mode 100644 index 0000000..84da5a7 --- /dev/null +++ b/app/api/mod/truyen/missing/route.ts @@ -0,0 +1,289 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +type MissingKey = "author" | "cover" | "description" | "genres" + +const ALL_MISSING_KEYS: MissingKey[] = ["author", "cover", "description", "genres"] + +function getScopeWhere(session: { user: { role: string; id: string } }) { + if (session.user.role === "ADMIN") { + return {} + } + + return { + OR: [ + { uploaderId: session.user.id }, + { uploaderId: null }, + ], + } +} + +function parseMissingKeys(raw: string | null): MissingKey[] { + if (!raw || !raw.trim()) return ALL_MISSING_KEYS + + const parsed = raw + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter((item): item is MissingKey => ALL_MISSING_KEYS.includes(item as MissingKey)) + + if (parsed.length === 0) return ALL_MISSING_KEYS + return Array.from(new Set(parsed)) +} + +function buildMissingWhereForKey(key: MissingKey) { + switch (key) { + case "author": + return { authorName: { equals: "" } } + case "cover": + return { + OR: [ + { coverUrl: null }, + { coverUrl: { equals: "" } }, + ], + } + case "description": + return { description: { equals: "" } } + case "genres": + return { genres: { none: {} } } + default: + return {} + } +} + +function computeMissingStatus(novel: { + authorName: string + coverUrl: string | null + description: string + genres: Array<{ genre: { id: string; name: string } }> +}) { + const authorMissing = novel.authorName.trim().length === 0 + const coverMissing = !novel.coverUrl || novel.coverUrl.trim().length === 0 + const descriptionMissing = novel.description.trim().length === 0 + const genresMissing = novel.genres.length === 0 + + return { + author: authorMissing, + cover: coverMissing, + description: descriptionMissing, + genres: genresMissing, + } +} + +function hasSelectedMissing(missingStatus: Record, selected: MissingKey[]) { + return selected.some((key) => missingStatus[key]) +} + +export async function GET(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 q = (url.searchParams.get("q") || "").trim() + const selectedMissing = parseMissingKeys(url.searchParams.get("missing")) + + const andWhere: any[] = [getScopeWhere(session)] + + if (q) { + andWhere.push({ + OR: [ + { title: { contains: q, mode: "insensitive" } }, + { slug: { contains: q, mode: "insensitive" } }, + { authorName: { contains: q, mode: "insensitive" } }, + { series: { name: { contains: q, mode: "insensitive" } } }, + ], + }) + } + + if (selectedMissing.length > 0) { + andWhere.push({ + OR: selectedMissing.map((key) => buildMissingWhereForKey(key)), + }) + } + + const novels = await (prisma as any).novel.findMany({ + where: { AND: andWhere }, + orderBy: [{ updatedAt: "desc" }], + take: 600, + select: { + id: true, + title: true, + slug: true, + authorName: true, + coverUrl: true, + description: true, + totalChapters: true, + updatedAt: true, + series: { + select: { + id: true, + name: true, + slug: true, + }, + }, + genres: { + select: { + genre: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }) + + const items = novels + .map((novel: any) => { + const missing = computeMissingStatus(novel) + + return { + id: novel.id, + title: novel.title, + slug: novel.slug, + authorName: novel.authorName, + coverUrl: novel.coverUrl, + description: novel.description, + totalChapters: novel.totalChapters, + updatedAt: novel.updatedAt, + series: novel.series, + genres: novel.genres.map((item: any) => item.genre), + missing, + } + }) + .filter((item: any) => hasSelectedMissing(item.missing, selectedMissing)) + + return NextResponse.json({ + items, + total: items.length, + }) + } catch (error) { + console.error("Failed to fetch novels with missing fields", error) + return NextResponse.json({ error: "Failed to fetch missing-field novels" }, { status: 500 }) + } +} + +export async function PATCH(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 updates = Array.isArray(body?.updates) ? body.updates : [] + + if (updates.length === 0) { + return NextResponse.json({ error: "Thiếu danh sách cập nhật" }, { status: 400 }) + } + + if (updates.length > 200) { + return NextResponse.json({ error: "Chỉ hỗ trợ tối đa 200 bản ghi mỗi lần" }, { status: 400 }) + } + + const ids = updates + .map((item: any) => (typeof item?.id === "string" ? item.id : "")) + .filter(Boolean) + + if (ids.length === 0) { + return NextResponse.json({ error: "Danh sách ID không hợp lệ" }, { status: 400 }) + } + + const allowedRows = await (prisma as any).novel.findMany({ + where: { + AND: [ + getScopeWhere(session), + { id: { in: ids } }, + ], + }, + select: { id: true }, + }) + + const allowedSet = new Set(allowedRows.map((row: any) => row.id)) + + let updatedCount = 0 + let skippedCount = 0 + const failures: Array<{ id: string; error: string }> = [] + + for (const raw of updates) { + const id = typeof raw?.id === "string" ? raw.id : "" + if (!id) { + skippedCount += 1 + continue + } + + if (!allowedSet.has(id)) { + failures.push({ id, error: "Không có quyền cập nhật truyện này" }) + continue + } + + const data: Record = {} + + if (typeof raw.authorName === "string") { + data.authorName = raw.authorName.trim() + } + + if (typeof raw.coverUrl === "string") { + const normalizedCover = raw.coverUrl.trim() + data.coverUrl = normalizedCover.length > 0 ? normalizedCover : null + } else if (raw.coverUrl === null) { + data.coverUrl = null + } + + if (typeof raw.description === "string") { + data.description = raw.description.trim() + } + + const hasGenreUpdate = Array.isArray(raw.genreIds) + const genreIds: string[] = hasGenreUpdate + ? Array.from(new Set((raw.genreIds as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0))) + : [] + + if (Object.keys(data).length === 0 && !hasGenreUpdate) { + skippedCount += 1 + continue + } + + try { + await prisma.$transaction(async (tx) => { + if (Object.keys(data).length > 0) { + await (tx as any).novel.update({ + where: { id }, + data, + }) + } + + if (hasGenreUpdate) { + await (tx as any).novelGenre.deleteMany({ where: { novelId: id } }) + + if (genreIds.length > 0) { + await (tx as any).novelGenre.createMany({ + data: genreIds.map((genreId) => ({ novelId: id, genreId })), + skipDuplicates: true, + }) + } + } + }) + + updatedCount += 1 + } catch (error: any) { + failures.push({ id, error: error?.message || "Cập nhật thất bại" }) + } + } + + return NextResponse.json({ + updatedCount, + skippedCount, + failureCount: failures.length, + failures, + }) + } catch (error) { + console.error("Failed to patch missing-field novels", error) + return NextResponse.json({ error: "Failed to update novels" }, { status: 500 }) + } +} diff --git a/app/api/user/bookmarks/route.ts b/app/api/user/bookmarks/route.ts index 0c8b899..db8ea5b 100644 --- a/app/api/user/bookmarks/route.ts +++ b/app/api/user/bookmarks/route.ts @@ -3,6 +3,10 @@ import { getServerSession } from "next-auth/next" import { authOptions } from "@/lib/auth" import { prisma } from "@/lib/prisma" +function toUTCDateOnly(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())) +} + // Lấy danh sách bookmark export async function GET(req: Request) { try { @@ -135,10 +139,29 @@ export async function POST(req: Request) { }) if (shouldIncrementNovelView) { - await prisma.novel.update({ - where: { id: novelId }, - data: { views: { increment: 1 } } - }) + const day = toUTCDateOnly(new Date()) + await prisma.$transaction([ + prisma.novel.update({ + where: { id: novelId }, + data: { views: { increment: 1 } } + }), + prisma.novelViewDaily.upsert({ + where: { + novelId_day: { + novelId, + day, + }, + }, + update: { + views: { increment: 1 }, + }, + create: { + novelId, + day, + views: 1, + }, + }), + ]) } return NextResponse.json({ status: "updated", bookmark }) diff --git a/app/mod/layout.tsx b/app/mod/layout.tsx index 07a52f5..8c701f0 100644 --- a/app/mod/layout.tsx +++ b/app/mod/layout.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation" import { getServerSession } from "next-auth/next" import { authOptions } from "@/lib/auth" import Link from "next/link" -import { BookOpen, Home } from "lucide-react" +import { AlertTriangle, BookOpen, Home } from "lucide-react" export default async function ModLayout({ children, @@ -28,6 +28,9 @@ export default async function ModLayout({ Quản lý truyện + + Truyện thiếu dữ liệu + diff --git a/app/mod/thieu-thong-tin/missing-fields-client.tsx b/app/mod/thieu-thong-tin/missing-fields-client.tsx new file mode 100644 index 0000000..1091e43 --- /dev/null +++ b/app/mod/thieu-thong-tin/missing-fields-client.tsx @@ -0,0 +1,700 @@ +"use client" + +import Link from "next/link" +import { useEffect, useMemo, useState } from "react" +import { Check, Loader2, RefreshCw, Save, X } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +type MissingKey = "author" | "cover" | "description" | "genres" + +type Genre = { + id: string + name: string +} + +type MissingNovel = { + id: string + title: string + slug: string + authorName: string + coverUrl: string | null + description: string + totalChapters: number + updatedAt: string + series: { + id: string + name: string + slug: string + } | null + genres: Genre[] + missing: Record +} + +type RowDraft = { + authorName: string + coverUrl: string + description: string + genreIds: string[] +} + +const missingKeyLabel: Record = { + author: "Thiếu tác giả", + cover: "Thiếu ảnh bìa", + description: "Thiếu giới thiệu", + genres: "Thiếu thể loại", +} + +const allMissingKeys: MissingKey[] = ["author", "cover", "description", "genres"] + +function toDraft(novel: MissingNovel): RowDraft { + return { + authorName: novel.authorName || "", + coverUrl: novel.coverUrl || "", + description: novel.description || "", + genreIds: novel.genres.map((genre) => genre.id), + } +} + +function normalizeGenreName(value: string): string { + return value.trim().replace(/\s+/g, " ") +} + +type GenreTagSelectorProps = { + genres: Genre[] + selectedIds: string[] + onChange: (next: string[]) => void + onEnsureGenre: (name: string) => Promise + placeholder?: string +} + +function GenreTagSelector({ + genres, + selectedIds, + onChange, + onEnsureGenre, + placeholder = "Nhập để tìm thể loại...", +}: GenreTagSelectorProps) { + const [query, setQuery] = useState("") + const [saving, setSaving] = useState(false) + + const normalizedQuery = query.trim().toLowerCase() + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + const selectedItems = useMemo(() => { + const byId = new Map(genres.map((genre) => [genre.id, genre])) + return selectedIds + .map((id) => byId.get(id)) + .filter((genre): genre is Genre => Boolean(genre)) + }, [genres, selectedIds]) + + const matchedGenres = useMemo(() => { + if (!normalizedQuery) return [] + return genres + .filter((genre) => genre.name.toLowerCase().includes(normalizedQuery)) + .slice(0, 8) + }, [genres, normalizedQuery]) + + const exactMatchedGenre = useMemo(() => { + if (!normalizedQuery) return null + return genres.find((genre) => genre.name.trim().toLowerCase() === normalizedQuery) || null + }, [genres, normalizedQuery]) + + const toggleGenre = (id: string) => { + onChange(selectedSet.has(id) ? selectedIds.filter((item) => item !== id) : [...selectedIds, id]) + } + + const handleAddOrPick = async () => { + const name = normalizeGenreName(query) + if (!name) return + + if (exactMatchedGenre) { + if (!selectedSet.has(exactMatchedGenre.id)) { + onChange([...selectedIds, exactMatchedGenre.id]) + } + setQuery("") + return + } + + setSaving(true) + try { + const createdId = await onEnsureGenre(name) + if (createdId && !selectedSet.has(createdId)) { + onChange([...selectedIds, createdId]) + } + if (createdId) { + setQuery("") + } + } finally { + setSaving(false) + } + } + + const actionLabel = exactMatchedGenre + ? (selectedSet.has(exactMatchedGenre.id) ? "Đã chọn" : "Chọn") + : "Tạo" + + return ( +
+
+ setQuery(e.target.value)} + placeholder={placeholder} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddOrPick() + } + }} + /> + +
+ +
+
+ {selectedItems.length === 0 && ( + Chưa chọn thể loại + )} + {selectedItems.map((genre) => ( + + ))} +
+ + {query.trim().length > 0 && ( +
+

Gợi ý

+
+ {matchedGenres.length === 0 && ( + Không có thể loại phù hợp, bấm Tạo để thêm mới. + )} + {matchedGenres.map((genre) => { + const selected = selectedSet.has(genre.id) + return ( + + ) + })} +
+
+ )} +
+
+ ) +} + +export function MissingFieldsClient() { + const [loading, setLoading] = useState(true) + const [reloading, setReloading] = useState(false) + const [items, setItems] = useState([]) + const [genres, setGenres] = useState([]) + const [drafts, setDrafts] = useState>({}) + const [savingIds, setSavingIds] = useState([]) + + const [queryInput, setQueryInput] = useState("") + const [searchKeyword, setSearchKeyword] = useState("") + const [selectedMissing, setSelectedMissing] = useState>({ + author: true, + cover: true, + description: true, + genres: true, + }) + + const [selectedNovelIds, setSelectedNovelIds] = useState([]) + const [bulkSaving, setBulkSaving] = useState(false) + + const [bulkApplyAuthor, setBulkApplyAuthor] = useState(false) + const [bulkApplyCover, setBulkApplyCover] = useState(false) + const [bulkApplyDescription, setBulkApplyDescription] = useState(false) + const [bulkApplyGenres, setBulkApplyGenres] = useState(false) + + const [bulkAuthorName, setBulkAuthorName] = useState("") + const [bulkCoverUrl, setBulkCoverUrl] = useState("") + const [bulkDescription, setBulkDescription] = useState("") + const [bulkGenreIds, setBulkGenreIds] = useState([]) + + const activeMissingKeys = useMemo(() => { + return allMissingKeys.filter((key) => selectedMissing[key]) + }, [selectedMissing]) + + const selectedNovelSet = useMemo(() => new Set(selectedNovelIds), [selectedNovelIds]) + + const pendingCount = items.length + + const fetchGenres = async (): Promise => { + try { + const res = await fetch("/api/mod/the-loai") + if (!res.ok) return [] + + const data = await res.json() + const rows = Array.isArray(data) ? data : [] + setGenres(rows) + return rows + } catch { + // Ignore genre preload errors for now. + return [] + } + } + + const ensureGenre = async (rawName: string): Promise => { + const name = normalizeGenreName(rawName) + if (!name) return null + + const existed = genres.find((genre) => genre.name.trim().toLowerCase() === name.toLowerCase()) + if (existed) return existed.id + + try { + const res = await fetch("/api/mod/the-loai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description: "" }), + }) + + const data = await res.json() + if (!res.ok) { + if (res.status === 400) { + const refreshed = await fetchGenres() + const maybeExisted = refreshed.find((genre) => genre.name.trim().toLowerCase() === name.toLowerCase()) + if (maybeExisted) return maybeExisted.id + } + throw new Error(data?.error || "Không thể tạo thể loại") + } + + const created = data as Genre + setGenres((prev) => { + if (prev.some((genre) => genre.id === created.id)) return prev + return [...prev, created].sort((a, b) => a.name.localeCompare(b.name, "vi")) + }) + toast.success(`Đã tạo thể loại ${created.name}`) + return created.id + } catch (error: any) { + toast.error(error?.message || "Không thể tạo thể loại") + return null + } + } + + const fetchMissingNovels = async (isReload = false) => { + try { + if (isReload) setReloading(true) + else setLoading(true) + + const params = new URLSearchParams() + params.set("missing", activeMissingKeys.join(",")) + if (searchKeyword.trim()) { + params.set("q", searchKeyword.trim()) + } + + const res = await fetch(`/api/mod/truyen/missing?${params.toString()}`) + if (!res.ok) { + throw new Error("Không thể tải danh sách truyện thiếu thông tin") + } + + const data = await res.json() + const rows: MissingNovel[] = Array.isArray(data?.items) ? data.items : [] + + setItems(rows) + setSelectedNovelIds((prev) => prev.filter((id) => rows.some((row) => row.id === id))) + setDrafts((prev) => { + const next: Record = {} + for (const row of rows) { + next[row.id] = prev[row.id] || toDraft(row) + } + return next + }) + } catch (error: any) { + toast.error(error?.message || "Không thể tải dữ liệu") + } finally { + setLoading(false) + setReloading(false) + } + } + + useEffect(() => { + fetchGenres() + }, []) + + useEffect(() => { + fetchMissingNovels() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchKeyword, activeMissingKeys.join(",")]) + + useEffect(() => { + if (!selectedMissing.author) setBulkApplyAuthor(false) + if (!selectedMissing.cover) setBulkApplyCover(false) + if (!selectedMissing.description) setBulkApplyDescription(false) + if (!selectedMissing.genres) setBulkApplyGenres(false) + }, [selectedMissing]) + + const toggleMissingFilter = (key: MissingKey) => { + setSelectedMissing((prev) => { + const next = { ...prev, [key]: !prev[key] } + const selectedCount = allMissingKeys.filter((item) => next[item]).length + if (selectedCount === 0) { + return prev + } + return next + }) + } + + const toggleSelectNovel = (id: string) => { + setSelectedNovelIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])) + } + + const toggleSelectAll = () => { + if (selectedNovelIds.length === items.length) { + setSelectedNovelIds([]) + return + } + setSelectedNovelIds(items.map((item) => item.id)) + } + + const updateDraft = (id: string, patch: Partial) => { + setDrafts((prev) => ({ + ...prev, + [id]: { + ...(prev[id] || { authorName: "", coverUrl: "", description: "", genreIds: [] }), + ...patch, + }, + })) + } + + const saveOne = async (id: string) => { + const draft = drafts[id] + if (!draft) return + + const update: Record = { id } + if (selectedMissing.author) update.authorName = draft.authorName + if (selectedMissing.cover) update.coverUrl = draft.coverUrl + if (selectedMissing.description) update.description = draft.description + if (selectedMissing.genres) update.genreIds = draft.genreIds + + setSavingIds((prev) => [...prev, id]) + + try { + const res = await fetch("/api/mod/truyen/missing", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + updates: [update], + }), + }) + + const data = await res.json() + if (!res.ok) { + throw new Error(data?.error || "Cập nhật thất bại") + } + + if (data.failureCount > 0) { + throw new Error(data.failures?.[0]?.error || "Có lỗi khi cập nhật") + } + + toast.success("Đã cập nhật truyện") + await fetchMissingNovels(true) + } catch (error: any) { + toast.error(error?.message || "Không thể cập nhật") + } finally { + setSavingIds((prev) => prev.filter((item) => item !== id)) + } + } + + const applyBulkUpdate = async () => { + if (selectedNovelIds.length === 0) { + toast.error("Chưa chọn truyện để cập nhật") + return + } + + const hasAnyVisibleBulkApply = + (selectedMissing.author && bulkApplyAuthor) || + (selectedMissing.cover && bulkApplyCover) || + (selectedMissing.description && bulkApplyDescription) || + (selectedMissing.genres && bulkApplyGenres) + + if (!hasAnyVisibleBulkApply) { + toast.error("Chọn ít nhất một trường để áp dụng hàng loạt") + return + } + + const updates = selectedNovelIds.map((id) => { + const next: Record = { id } + + if (selectedMissing.author && bulkApplyAuthor) { + next.authorName = bulkAuthorName + } + + if (selectedMissing.cover && bulkApplyCover) { + next.coverUrl = bulkCoverUrl + } + + if (selectedMissing.description && bulkApplyDescription) { + next.description = bulkDescription + } + + if (selectedMissing.genres && bulkApplyGenres) { + next.genreIds = bulkGenreIds + } + + return next + }) + + setBulkSaving(true) + try { + const res = await fetch("/api/mod/truyen/missing", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }) + + const data = await res.json() + if (!res.ok) { + throw new Error(data?.error || "Cập nhật hàng loạt thất bại") + } + + if (data.failureCount > 0) { + toast.warning(`Đã cập nhật ${data.updatedCount} truyện, lỗi ${data.failureCount} truyện`) + } else { + toast.success(`Đã cập nhật ${data.updatedCount} truyện`) + } + + setSelectedNovelIds([]) + await fetchMissingNovels(true) + } catch (error: any) { + toast.error(error?.message || "Không thể cập nhật hàng loạt") + } finally { + setBulkSaving(false) + } + } + + return ( +
+
+
+
+

Bổ sung dữ liệu truyện còn thiếu

+

Lọc nhanh truyện thiếu tác giả, ảnh bìa, giới thiệu hoặc thể loại và sửa trực tiếp ngay tại bảng.

+
+ +
+ +
+
+ setQueryInput(e.target.value)} + placeholder="Tìm theo tên truyện, slug, tác giả, series..." + onKeyDown={(e) => { + if (e.key === "Enter") { + setSearchKeyword(queryInput) + } + }} + /> + +
+ +
+ Đang hiển thị {pendingCount} truyện cần bổ sung. +
+
+ +
+ {allMissingKeys.map((key) => ( + + ))} +
+
+ + {selectedNovelIds.length > 0 && ( +
+

Cập nhật hàng loạt cho {selectedNovelIds.length} truyện đã chọn

+ +
+ {selectedMissing.author && ( + + )} + + {selectedMissing.cover && ( + + )} +
+ + {selectedMissing.description && ( +