feat: add missing fields management for novels
- Implemented API routes for fetching and updating novels with missing fields. - Created a client-side interface for moderators to manage novels with missing information. - Added bulk update functionality for missing fields including author, cover, description, and genres. - Integrated genre management with the ability to create new genres on the fly. - Enhanced the home page with a carousel for displaying popular novels.
This commit is contained in:
+429
-39
@@ -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<string, string> = {
|
||||
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<string, any>)[key], rawValues)
|
||||
}
|
||||
|
||||
const uniqueNames: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
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<string[]> {
|
||||
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 })
|
||||
|
||||
@@ -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<MissingKey, boolean>, 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<string, any> = {}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
+4
-1
@@ -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({
|
||||
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
|
||||
<BookOpen className="h-4 w-4" /> Quản lý truyện
|
||||
</Link>
|
||||
<Link href="/mod/thieu-thong-tin" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
|
||||
<AlertTriangle className="h-4 w-4" /> Truyện thiếu dữ liệu
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -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<MissingKey, boolean>
|
||||
}
|
||||
|
||||
type RowDraft = {
|
||||
authorName: string
|
||||
coverUrl: string
|
||||
description: string
|
||||
genreIds: string[]
|
||||
}
|
||||
|
||||
const missingKeyLabel: Record<MissingKey, string> = {
|
||||
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<string | null>
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddOrPick()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={handleAddOrPick} disabled={saving || !query.trim()}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedItems.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Chưa chọn thể loại</span>
|
||||
)}
|
||||
{selectedItems.map((genre) => (
|
||||
<button
|
||||
key={genre.id}
|
||||
type="button"
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary"
|
||||
title="Bỏ chọn"
|
||||
>
|
||||
{genre.name}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{query.trim().length > 0 && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Gợi ý</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{matchedGenres.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Không có thể loại phù hợp, bấm Tạo để thêm mới.</span>
|
||||
)}
|
||||
{matchedGenres.map((genre) => {
|
||||
const selected = selectedSet.has(genre.id)
|
||||
return (
|
||||
<button
|
||||
key={genre.id}
|
||||
type="button"
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-muted-foreground"}`}
|
||||
>
|
||||
{genre.name}
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MissingFieldsClient() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [items, setItems] = useState<MissingNovel[]>([])
|
||||
const [genres, setGenres] = useState<Genre[]>([])
|
||||
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({})
|
||||
const [savingIds, setSavingIds] = useState<string[]>([])
|
||||
|
||||
const [queryInput, setQueryInput] = useState("")
|
||||
const [searchKeyword, setSearchKeyword] = useState("")
|
||||
const [selectedMissing, setSelectedMissing] = useState<Record<MissingKey, boolean>>({
|
||||
author: true,
|
||||
cover: true,
|
||||
description: true,
|
||||
genres: true,
|
||||
})
|
||||
|
||||
const [selectedNovelIds, setSelectedNovelIds] = useState<string[]>([])
|
||||
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<string[]>([])
|
||||
|
||||
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<Genre[]> => {
|
||||
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<string | null> => {
|
||||
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<string, RowDraft> = {}
|
||||
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<RowDraft>) => {
|
||||
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<string, any> = { 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<string, any> = { 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Bổ sung dữ liệu truyện còn thiếu</h1>
|
||||
<p className="text-sm text-muted-foreground">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.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => fetchMissingNovels(true)} disabled={reloading || loading}>
|
||||
{reloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
Làm mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(e) => 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" onClick={() => setSearchKeyword(queryInput)}>Lọc</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground lg:text-right">
|
||||
Đang hiển thị <span className="font-semibold text-foreground">{pendingCount}</span> truyện cần bổ sung.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{allMissingKeys.map((key) => (
|
||||
<label key={key} className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMissing[key]}
|
||||
onChange={() => toggleMissingFilter(key)}
|
||||
/>
|
||||
{missingKeyLabel[key]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNovelIds.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-5 shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Cập nhật hàng loạt cho {selectedNovelIds.length} truyện đã chọn</h2>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{selectedMissing.author && (
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2 font-medium">
|
||||
<input type="checkbox" checked={bulkApplyAuthor} onChange={(e) => setBulkApplyAuthor(e.target.checked)} />
|
||||
Áp dụng tác giả
|
||||
</span>
|
||||
<Input value={bulkAuthorName} onChange={(e) => setBulkAuthorName(e.target.value)} placeholder="Tên tác giả" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedMissing.cover && (
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2 font-medium">
|
||||
<input type="checkbox" checked={bulkApplyCover} onChange={(e) => setBulkApplyCover(e.target.checked)} />
|
||||
Áp dụng ảnh bìa
|
||||
</span>
|
||||
<Input value={bulkCoverUrl} onChange={(e) => setBulkCoverUrl(e.target.value)} placeholder="URL ảnh bìa (để trống để xóa)" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedMissing.description && (
|
||||
<label className="space-y-2 text-sm block">
|
||||
<span className="inline-flex items-center gap-2 font-medium">
|
||||
<input type="checkbox" checked={bulkApplyDescription} onChange={(e) => setBulkApplyDescription(e.target.checked)} />
|
||||
Áp dụng giới thiệu ngắn
|
||||
</span>
|
||||
<Textarea value={bulkDescription} onChange={(e) => setBulkDescription(e.target.value)} rows={3} placeholder="Giới thiệu ngắn" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedMissing.genres && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2 font-medium">
|
||||
<input type="checkbox" checked={bulkApplyGenres} onChange={(e) => setBulkApplyGenres(e.target.checked)} />
|
||||
Áp dụng thể loại
|
||||
</span>
|
||||
<GenreTagSelector
|
||||
genres={genres}
|
||||
selectedIds={bulkGenreIds}
|
||||
onChange={setBulkGenreIds}
|
||||
onEnsureGenre={ensureGenre}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={applyBulkUpdate} disabled={bulkSaving}>
|
||||
{bulkSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Cập nhật hàng loạt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-10 text-center text-muted-foreground">Không tìm thấy truyện phù hợp bộ lọc hiện tại.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead className="bg-muted/40 border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-3 text-left">
|
||||
<input type="checkbox" checked={selectedNovelIds.length === items.length} onChange={toggleSelectAll} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left font-semibold">Truyện</th>
|
||||
{selectedMissing.author && <th className="px-3 py-3 text-left font-semibold">Tác giả</th>}
|
||||
{selectedMissing.cover && <th className="px-3 py-3 text-left font-semibold">Ảnh bìa</th>}
|
||||
{selectedMissing.description && <th className="px-3 py-3 text-left font-semibold">Giới thiệu</th>}
|
||||
{selectedMissing.genres && <th className="px-3 py-3 text-left font-semibold">Thể loại</th>}
|
||||
<th className="px-3 py-3 text-right font-semibold">Lưu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const draft = drafts[item.id] || toDraft(item)
|
||||
const rowSaving = savingIds.includes(item.id)
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="border-b align-top last:border-b-0">
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNovelSet.has(item.id)}
|
||||
onChange={() => toggleSelectNovel(item.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 space-y-2 min-w-[220px]">
|
||||
<Link href={`/truyen/${item.slug}`} className="font-semibold text-primary hover:underline" target="_blank">
|
||||
{item.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{item.series?.name || "Độc lập"} - {item.totalChapters} chương</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allMissingKeys.filter((key) => item.missing[key]).map((key) => (
|
||||
<span key={key} className="rounded-full border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||
{missingKeyLabel[key]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
{selectedMissing.author && (
|
||||
<td className="px-3 py-3 min-w-[170px]">
|
||||
<Input
|
||||
value={draft.authorName}
|
||||
onChange={(e) => updateDraft(item.id, { authorName: e.target.value })}
|
||||
placeholder="Tác giả"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{selectedMissing.cover && (
|
||||
<td className="px-3 py-3 min-w-[220px]">
|
||||
<Input
|
||||
value={draft.coverUrl}
|
||||
onChange={(e) => updateDraft(item.id, { coverUrl: e.target.value })}
|
||||
placeholder="URL ảnh bìa"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{selectedMissing.description && (
|
||||
<td className="px-3 py-3 min-w-[340px]">
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
onChange={(e) => updateDraft(item.id, { description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Giới thiệu ngắn"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{selectedMissing.genres && (
|
||||
<td className="px-3 py-3 min-w-[300px]">
|
||||
<GenreTagSelector
|
||||
genres={genres}
|
||||
selectedIds={draft.genreIds}
|
||||
onChange={(next) => updateDraft(item.id, { genreIds: next })}
|
||||
onEnsureGenre={ensureGenre}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Button type="button" size="sm" onClick={() => saveOne(item.id)} disabled={rowSaving}>
|
||||
{rowSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { MissingFieldsClient } from "./missing-fields-client"
|
||||
|
||||
export default async function ModMissingFieldsPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return <MissingFieldsClient />
|
||||
}
|
||||
+623
-115
@@ -13,10 +13,11 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon, Search, FileText } from "lucide-react"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon, Search, FileText, X, Check, FolderOpen, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
interface Novel {
|
||||
id: string
|
||||
@@ -66,6 +67,7 @@ interface EpubPreviewData {
|
||||
title: string
|
||||
authorName: string
|
||||
description: string
|
||||
detectedGenres?: string[]
|
||||
totalChapters: number
|
||||
}
|
||||
chaptersPreview: {
|
||||
@@ -79,6 +81,29 @@ interface EpubPreviewData {
|
||||
}[]
|
||||
}
|
||||
|
||||
type BulkUploadStatus = "pending" | "uploading" | "success" | "failed" | "skipped"
|
||||
type BulkDuplicateHandling = "ask" | "replace-all" | "skip-all"
|
||||
|
||||
interface BulkUploadProgressItem {
|
||||
fileKey: string
|
||||
displayName: string
|
||||
progress: number
|
||||
status: BulkUploadStatus
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface EpubUploadResponseData {
|
||||
error?: string
|
||||
code?: string
|
||||
canReplace?: boolean
|
||||
existingNovel?: {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
replaced?: boolean
|
||||
}
|
||||
|
||||
const CHAPTER_REGEX_PRESETS = [
|
||||
{
|
||||
id: "vi_chuong",
|
||||
@@ -124,6 +149,7 @@ export function NovelClient() {
|
||||
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong")
|
||||
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
||||
const epubInputRef = useRef<HTMLInputElement>(null)
|
||||
const epubFolderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Form states
|
||||
const [title, setTitle] = useState("")
|
||||
@@ -150,7 +176,7 @@ export function NovelClient() {
|
||||
const [genres, setGenres] = useState<Genre[]>([])
|
||||
const [seriesList, setSeriesList] = useState<SeriesOption[]>([])
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
|
||||
const [newGenreName, setNewGenreName] = useState("")
|
||||
const [genreQuery, setGenreQuery] = useState("")
|
||||
const [addingGenre, setAddingGenre] = useState(false)
|
||||
|
||||
// Delete states
|
||||
@@ -161,6 +187,10 @@ export function NovelClient() {
|
||||
const [searchKeyword, setSearchKeyword] = useState("")
|
||||
const [selectedNovelIds, setSelectedNovelIds] = useState<string[]>([])
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [bulkProgress, setBulkProgress] = useState<Record<string, BulkUploadProgressItem>>({})
|
||||
const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState<BulkDuplicateHandling>("ask")
|
||||
|
||||
const getSelectedChapterRegex = () => {
|
||||
if (epubRegexPreset === "custom") {
|
||||
@@ -170,6 +200,118 @@ export function NovelClient() {
|
||||
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
||||
}
|
||||
|
||||
const normalizeEpubFiles = (files: File[]) => {
|
||||
return files.filter((file) => file.name.toLowerCase().endsWith(".epub"))
|
||||
}
|
||||
|
||||
const buildEpubFileKey = (file: File) => {
|
||||
const relativePath = file.webkitRelativePath || file.name
|
||||
return `${relativePath}::${file.size}::${file.lastModified}`
|
||||
}
|
||||
|
||||
const cloneFormData = (source: FormData): FormData => {
|
||||
const next = new FormData()
|
||||
for (const [key, value] of source.entries()) {
|
||||
next.append(key, value)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const uploadEpubRequest = async (
|
||||
formData: FormData,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<{ status: number; ok: boolean; data: EpubUploadResponseData }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("POST", "/api/mod/epub")
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!onProgress || !event.lengthComputable) return
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
onProgress(Math.min(99, Math.max(0, percent)))
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error("Không thể kết nối tới server"))
|
||||
|
||||
xhr.onload = () => {
|
||||
let data: EpubUploadResponseData = {}
|
||||
try {
|
||||
data = xhr.responseText ? JSON.parse(xhr.responseText) : {}
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: xhr.status,
|
||||
ok: xhr.status >= 200 && xhr.status < 300,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
const setBulkProgressItem = (fileKey: string, patch: Partial<BulkUploadProgressItem>) => {
|
||||
setBulkProgress((prev) => {
|
||||
const current = prev[fileKey]
|
||||
if (!current) return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[fileKey]: {
|
||||
...current,
|
||||
...patch,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const initializeBulkProgress = (files: File[]) => {
|
||||
const initial: Record<string, BulkUploadProgressItem> = {}
|
||||
for (const file of files) {
|
||||
const fileKey = buildEpubFileKey(file)
|
||||
initial[fileKey] = {
|
||||
fileKey,
|
||||
displayName: file.webkitRelativePath || file.name,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
}
|
||||
}
|
||||
setBulkProgress(initial)
|
||||
}
|
||||
|
||||
const mergeUniqueEpubFiles = (base: File[], incoming: File[]) => {
|
||||
const merged: File[] = [...base]
|
||||
const picked = new Set(base.map((file) => buildEpubFileKey(file)))
|
||||
|
||||
for (const file of incoming) {
|
||||
const key = buildEpubFileKey(file)
|
||||
if (picked.has(key)) continue
|
||||
picked.add(key)
|
||||
merged.push(file)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const appendPendingBulkEpubFiles = (incomingFiles: File[]) => {
|
||||
const merged = mergeUniqueEpubFiles(pendingEpubFiles, incomingFiles)
|
||||
const addedCount = merged.length - pendingEpubFiles.length
|
||||
|
||||
if (addedCount <= 0) {
|
||||
toast.info("Các file EPUB đã có sẵn trong hàng đợi")
|
||||
} else {
|
||||
toast.success(`Đã thêm ${addedCount} file EPUB vào hàng đợi import`)
|
||||
}
|
||||
|
||||
setPendingEpubFile(null)
|
||||
setOpenEpubPreview(false)
|
||||
setPendingEpubFiles(merged)
|
||||
initializeBulkProgress(merged)
|
||||
setOpenBulkEpubImport(true)
|
||||
}
|
||||
|
||||
const fetchNovels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/mod/truyen")
|
||||
@@ -224,9 +366,62 @@ export function NovelClient() {
|
||||
})
|
||||
}, [novels, searchKeyword])
|
||||
|
||||
const visibleNovelIds = useMemo(() => filteredNovels.map((novel) => novel.id), [filteredNovels])
|
||||
const totalPages = Math.max(1, Math.ceil(filteredNovels.length / pageSize))
|
||||
|
||||
const pagedNovels = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return filteredNovels.slice(start, start + pageSize)
|
||||
}, [filteredNovels, currentPage, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages)
|
||||
}
|
||||
}, [currentPage, totalPages])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [searchKeyword])
|
||||
|
||||
const normalizedGenreQuery = genreQuery.trim().toLowerCase()
|
||||
const matchedGenres = useMemo(() => {
|
||||
if (!normalizedGenreQuery) return []
|
||||
return genres
|
||||
.filter((genre) => genre.name.toLowerCase().includes(normalizedGenreQuery))
|
||||
.slice(0, 8)
|
||||
}, [genres, normalizedGenreQuery])
|
||||
|
||||
const exactMatchedGenre = useMemo(() => {
|
||||
if (!normalizedGenreQuery) return null
|
||||
return genres.find((genre) => genre.name.trim().toLowerCase() === normalizedGenreQuery) || null
|
||||
}, [genres, normalizedGenreQuery])
|
||||
|
||||
const selectedGenreItems = useMemo(() => {
|
||||
const byId = new Map(genres.map((genre) => [genre.id, genre]))
|
||||
return selectedGenres
|
||||
.map((id) => byId.get(id))
|
||||
.filter((genre): genre is Genre => Boolean(genre))
|
||||
}, [genres, selectedGenres])
|
||||
|
||||
const visibleNovelIds = useMemo(() => pagedNovels.map((novel) => novel.id), [pagedNovels])
|
||||
const allVisibleSelected = visibleNovelIds.length > 0 && visibleNovelIds.every((id) => selectedNovelIds.includes(id))
|
||||
|
||||
const bulkProgressItems = useMemo(() => {
|
||||
return pendingEpubFiles
|
||||
.map((file) => bulkProgress[buildEpubFileKey(file)])
|
||||
.filter((item): item is BulkUploadProgressItem => Boolean(item))
|
||||
}, [pendingEpubFiles, bulkProgress])
|
||||
|
||||
const processedBulkCount = useMemo(
|
||||
() => bulkProgressItems.filter((item) => ["success", "failed", "skipped"].includes(item.status)).length,
|
||||
[bulkProgressItems]
|
||||
)
|
||||
|
||||
const overallBulkProgress = useMemo(() => {
|
||||
if (bulkProgressItems.length === 0) return 0
|
||||
return Math.round((processedBulkCount / bulkProgressItems.length) * 100)
|
||||
}, [bulkProgressItems, processedBulkCount])
|
||||
|
||||
const toggleNovelSelection = (id: string) => {
|
||||
setSelectedNovelIds((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])
|
||||
}
|
||||
@@ -279,21 +474,30 @@ export function NovelClient() {
|
||||
}
|
||||
|
||||
const handleAddGenre = async () => {
|
||||
if (!newGenreName.trim()) return
|
||||
const inputName = genreQuery.trim()
|
||||
if (!inputName) return
|
||||
|
||||
const existed = genres.find((genre) => genre.name.trim().toLowerCase() === inputName.toLowerCase())
|
||||
if (existed) {
|
||||
setSelectedGenres((prev) => prev.includes(existed.id) ? prev : [...prev, existed.id])
|
||||
setGenreQuery("")
|
||||
return
|
||||
}
|
||||
|
||||
setAddingGenre(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/the-loai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newGenreName, description: "" })
|
||||
body: JSON.stringify({ name: inputName, description: "" })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Thêm lỗi")
|
||||
|
||||
toast.success("Thêm thể loại thành công")
|
||||
setNewGenreName("")
|
||||
setGenreQuery("")
|
||||
fetchGenres()
|
||||
setSelectedGenres(prev => [...prev, data.id])
|
||||
setSelectedGenres(prev => prev.includes(data.id) ? prev : [...prev, data.id])
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
@@ -316,11 +520,112 @@ export function NovelClient() {
|
||||
fetchGenres()
|
||||
// Clean up from selected lists
|
||||
setSelectedGenres(prev => prev.filter(gId => gId !== id))
|
||||
if (genreQuery.trim() && genreQuery.trim().toLowerCase() === name.trim().toLowerCase()) {
|
||||
setGenreQuery("")
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const renderGenreSelector = (label: string) => {
|
||||
const actionLabel = exactMatchedGenre
|
||||
? (selectedGenres.includes(exactMatchedGenre.id) ? "Đã chọn" : "Chọn")
|
||||
: "Tạo"
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Nhập để tìm thể loại..."
|
||||
value={genreQuery}
|
||||
onChange={(e) => setGenreQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddGenre()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !genreQuery.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nhập tên thể loại để tìm trong hệ thống. Nếu chưa có, bấm Tạo để thêm mới.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border bg-card p-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedGenreItems.map((genre) => (
|
||||
<div key={genre.id} className="flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/15 px-3 py-1 text-xs text-primary">
|
||||
<span className="font-medium">{genre.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-muted hover:text-foreground"
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
title="Bỏ chọn"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => handleDeleteGenre(genre.id, genre.name)}
|
||||
title="Xóa khỏi hệ thống"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectedGenreItems.length === 0 && (
|
||||
<span className="p-1 text-xs text-muted-foreground">Chưa chọn thể loại nào</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genreQuery.trim().length > 0 && (
|
||||
<div className="border-t pt-2">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Kết quả phù hợp</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{matchedGenres.map((genre) => {
|
||||
const isSelected = selectedGenres.includes(genre.id)
|
||||
return (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs ${isSelected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-muted/50 text-muted-foreground"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1"
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
>
|
||||
{genre.name}
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => handleDeleteGenre(genre.id, genre.name)}
|
||||
title="Xóa khỏi hệ thống"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{matchedGenres.length === 0 && (
|
||||
<span className="p-1 text-xs text-muted-foreground">Không có thể loại phù hợp. Bấm Tạo để thêm mới.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title || !authorName || !description) {
|
||||
@@ -373,6 +678,7 @@ export function NovelClient() {
|
||||
setNewSeriesName("")
|
||||
setStatus("Đang ra")
|
||||
setSelectedGenres([])
|
||||
setGenreQuery("")
|
||||
fetchNovels()
|
||||
fetchSeries()
|
||||
} catch {
|
||||
@@ -388,6 +694,8 @@ export function NovelClient() {
|
||||
setEpubPreviewData(null)
|
||||
setPendingEpubFile(null)
|
||||
setPendingEpubFiles([])
|
||||
setBulkProgress({})
|
||||
setBulkDuplicateHandling("ask")
|
||||
setEpubTitle("")
|
||||
setEpubAuthorName("")
|
||||
setEpubDescription("")
|
||||
@@ -400,6 +708,9 @@ export function NovelClient() {
|
||||
if (epubInputRef.current) {
|
||||
epubInputRef.current.value = ""
|
||||
}
|
||||
if (epubFolderInputRef.current) {
|
||||
epubFolderInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const requestEpubPreview = async (
|
||||
@@ -457,23 +768,27 @@ export function NovelClient() {
|
||||
}
|
||||
|
||||
const handleEpubSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) return
|
||||
const selectedFiles = Array.from(e.target.files || [])
|
||||
if (selectedFiles.length === 0) return
|
||||
|
||||
if (files.some((file) => !file.name.endsWith('.epub'))) {
|
||||
toast.error("Vui lòng chọn file định dạng .epub")
|
||||
e.target.value = "" // Reset input
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length > 1) {
|
||||
setPendingEpubFiles(files)
|
||||
setOpenBulkEpubImport(true)
|
||||
const epubFiles = normalizeEpubFiles(selectedFiles)
|
||||
if (epubFiles.length === 0) {
|
||||
toast.error("Không tìm thấy file .epub trong lựa chọn")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
const file = files[0]
|
||||
if (epubFiles.length !== selectedFiles.length) {
|
||||
toast.info(`Đã bỏ qua ${selectedFiles.length - epubFiles.length} file không phải EPUB`)
|
||||
}
|
||||
|
||||
if (epubFiles.length > 1 || openBulkEpubImport) {
|
||||
appendPendingBulkEpubFiles(epubFiles)
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
const file = epubFiles[0]
|
||||
|
||||
setPendingEpubFile(file)
|
||||
|
||||
@@ -493,6 +808,21 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEpubFolderSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || [])
|
||||
if (selectedFiles.length === 0) return
|
||||
|
||||
const epubFiles = normalizeEpubFiles(selectedFiles)
|
||||
if (epubFiles.length === 0) {
|
||||
toast.error("Không tìm thấy file .epub trong thư mục đã chọn")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
appendPendingBulkEpubFiles(epubFiles)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleReparseEpub = async () => {
|
||||
if (!pendingEpubFile) {
|
||||
toast.error("Không tìm thấy file EPUB để phân tích lại")
|
||||
@@ -552,17 +882,34 @@ export function NovelClient() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mod/epub", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
let upload = await uploadEpubRequest(formData)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Lỗi khi tải lên EPUB")
|
||||
if (upload.status === 409 && upload.data?.code === "DUPLICATE_TITLE") {
|
||||
const duplicateTitle = upload.data.existingNovel?.title || epubTitle
|
||||
if (upload.data.canReplace === false) {
|
||||
throw new Error(upload.data.error || `Truyện ${duplicateTitle} đã tồn tại và bạn không có quyền ghi đè`)
|
||||
}
|
||||
|
||||
const shouldReplace = window.confirm(`Truyện "${duplicateTitle}" đã tồn tại. Bạn có muốn replace truyện này không?`)
|
||||
if (!shouldReplace) {
|
||||
toast.info("Đã hủy upload vì trùng tên truyện")
|
||||
return
|
||||
}
|
||||
|
||||
const retryFormData = cloneFormData(formData)
|
||||
retryFormData.set("replaceExisting", "true")
|
||||
upload = await uploadEpubRequest(retryFormData)
|
||||
}
|
||||
|
||||
toast.success("Đã tải lên EPUB thành công")
|
||||
if (!upload.ok) {
|
||||
throw new Error(upload.data?.error || "Lỗi khi tải lên EPUB")
|
||||
}
|
||||
|
||||
if (upload.data?.replaced) {
|
||||
toast.success("Đã replace truyện từ EPUB thành công")
|
||||
} else {
|
||||
toast.success("Đã tải lên EPUB thành công")
|
||||
}
|
||||
resetEpubPreviewState()
|
||||
fetchNovels()
|
||||
fetchSeries()
|
||||
@@ -590,11 +937,16 @@ export function NovelClient() {
|
||||
}
|
||||
|
||||
setUploadingEpub(true)
|
||||
initializeBulkProgress(pendingEpubFiles)
|
||||
|
||||
let success = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let replaced = 0
|
||||
|
||||
try {
|
||||
for (const file of pendingEpubFiles) {
|
||||
const fileKey = buildEpubFileKey(file)
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("seriesMode", epubSeriesMode)
|
||||
@@ -602,19 +954,79 @@ export function NovelClient() {
|
||||
if (epubSeriesMode === "new") formData.append("seriesName", epubSeriesName.trim())
|
||||
formData.append("splitMode", "toc")
|
||||
|
||||
const res = await fetch("/api/mod/epub", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
setBulkProgressItem(fileKey, {
|
||||
status: "uploading",
|
||||
progress: 1,
|
||||
message: "Đang upload...",
|
||||
})
|
||||
|
||||
if (res.ok) success += 1
|
||||
else failed += 1
|
||||
let upload = await uploadEpubRequest(formData, (progress) => {
|
||||
setBulkProgressItem(fileKey, { progress, status: "uploading" })
|
||||
})
|
||||
|
||||
if (upload.status === 409 && upload.data?.code === "DUPLICATE_TITLE") {
|
||||
const duplicateTitle = upload.data.existingNovel?.title || file.name
|
||||
|
||||
if (upload.data.canReplace === false) {
|
||||
failed += 1
|
||||
setBulkProgressItem(fileKey, {
|
||||
status: "failed",
|
||||
progress: 100,
|
||||
message: upload.data.error || `Trùng tên ${duplicateTitle} nhưng không đủ quyền replace`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let shouldReplace = false
|
||||
if (bulkDuplicateHandling === "replace-all") {
|
||||
shouldReplace = true
|
||||
} else if (bulkDuplicateHandling === "skip-all") {
|
||||
shouldReplace = false
|
||||
} else {
|
||||
shouldReplace = window.confirm(`File ${file.name} trùng với truyện "${duplicateTitle}". Bạn có muốn replace không?`)
|
||||
}
|
||||
|
||||
if (!shouldReplace) {
|
||||
skipped += 1
|
||||
setBulkProgressItem(fileKey, {
|
||||
status: "skipped",
|
||||
progress: 100,
|
||||
message: bulkDuplicateHandling === "skip-all" ? "Bỏ qua theo cấu hình" : "Đã bỏ qua do trùng tên",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const retryFormData = cloneFormData(formData)
|
||||
retryFormData.set("replaceExisting", "true")
|
||||
upload = await uploadEpubRequest(retryFormData, (progress) => {
|
||||
setBulkProgressItem(fileKey, { progress, status: "uploading" })
|
||||
})
|
||||
}
|
||||
|
||||
if (upload.ok) {
|
||||
success += 1
|
||||
if (upload.data?.replaced) {
|
||||
replaced += 1
|
||||
}
|
||||
setBulkProgressItem(fileKey, {
|
||||
status: "success",
|
||||
progress: 100,
|
||||
message: upload.data?.replaced ? "Đã replace thành công" : "Upload thành công",
|
||||
})
|
||||
} else {
|
||||
failed += 1
|
||||
setBulkProgressItem(fileKey, {
|
||||
status: "failed",
|
||||
progress: 100,
|
||||
message: upload.data?.error || "Upload thất bại",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (success > 0 && failed === 0) {
|
||||
toast.success(`Đã import ${success} file EPUB vào series thành công`)
|
||||
} else if (success > 0) {
|
||||
toast.warning(`Import thành công ${success} file, thất bại ${failed} file`)
|
||||
if (success > 0 && failed === 0 && skipped === 0) {
|
||||
toast.success(`Đã import ${success} file EPUB thành công${replaced > 0 ? ` (${replaced} file replace)` : ""}`)
|
||||
} else if (success > 0 || skipped > 0) {
|
||||
toast.warning(`Import: thành công ${success}${replaced > 0 ? ` (${replaced} replace)` : ""}, thất bại ${failed}, bỏ qua ${skipped}`)
|
||||
} else {
|
||||
toast.error("Import EPUB thất bại")
|
||||
}
|
||||
@@ -674,6 +1086,7 @@ export function NovelClient() {
|
||||
}
|
||||
setStatus(novel.status)
|
||||
setDescription("")
|
||||
setGenreQuery("")
|
||||
setOriginalTitle("")
|
||||
setOriginalAuthorName("")
|
||||
setCoverUrl(novel.coverUrl || "")
|
||||
@@ -818,6 +1231,16 @@ export function NovelClient() {
|
||||
onChange={handleEpubSelect}
|
||||
disabled={previewingEpub || uploadingEpub}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id="epub-folder-upload"
|
||||
ref={epubFolderInputRef}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleEpubFolderSelect}
|
||||
disabled={previewingEpub || uploadingEpub}
|
||||
{...({ webkitdirectory: "", directory: "" } as any)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
@@ -825,7 +1248,16 @@ export function NovelClient() {
|
||||
onClick={() => document.getElementById('epub-upload')?.click()}
|
||||
>
|
||||
{previewingEpub || uploadingEpub ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{previewingEpub ? "Đang phân tích EPUB..." : uploadingEpub ? "Đang xuất bản..." : "Tải lên EPUB"}
|
||||
{previewingEpub ? "Đang phân tích EPUB..." : uploadingEpub ? "Đang xuất bản..." : "Chọn file EPUB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={previewingEpub || uploadingEpub}
|
||||
onClick={() => document.getElementById('epub-folder-upload')?.click()}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Chọn thư mục EPUB
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
@@ -838,7 +1270,7 @@ export function NovelClient() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className="w-[96vw] max-w-[96vw] sm:!max-w-[960px] max-h-[85vh] overflow-y-auto overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Xem trước truyện từ EPUB</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -851,6 +1283,12 @@ export function NovelClient() {
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
<p><span className="font-semibold">File:</span> {epubPreviewData.fileName}</p>
|
||||
<p><span className="font-semibold">Số chương:</span> {epubPreviewData.novel.totalChapters}</p>
|
||||
{Array.isArray(epubPreviewData.novel.detectedGenres) && epubPreviewData.novel.detectedGenres.length > 0 && (
|
||||
<p>
|
||||
<span className="font-semibold">Thể loại nhận diện:</span>{" "}
|
||||
{epubPreviewData.novel.detectedGenres.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-semibold">Cover từ EPUB:</span>{" "}
|
||||
{epubPreviewData.hasCoverFromEpub ? "Có (sẽ tự gán làm ảnh bìa)" : "Không tìm thấy cover"}
|
||||
@@ -1031,12 +1469,18 @@ export function NovelClient() {
|
||||
{chapter.volumeTitle || `Quyển ${chapter.volumeNumber}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm font-medium">
|
||||
{chapter.volumeChapterNumber
|
||||
{(() => {
|
||||
const titleHasHeading = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+/i.test(chapter.title)
|
||||
const chapterLabel = chapter.volumeChapterNumber
|
||||
? `Chương ${chapter.volumeChapterNumber}`
|
||||
: `Chương ${chapter.number}`}
|
||||
: {chapter.title}
|
||||
</p>
|
||||
: `Chương ${chapter.number}`
|
||||
|
||||
return (
|
||||
<p className="text-sm font-medium">
|
||||
{titleHasHeading ? chapter.title : `${chapterLabel}: ${chapter.title}`}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
{chapter.isPlaceholder && (
|
||||
<p className="mt-1 text-[11px] font-semibold text-amber-600">
|
||||
Placeholder chương thiếu - cần bổ sung nội dung sau
|
||||
@@ -1088,15 +1532,97 @@ export function NovelClient() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogContent className="w-[96vw] max-w-[96vw] sm:!max-w-[920px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import nhiều EPUB vào series</DialogTitle>
|
||||
<DialogDescription>
|
||||
Đã chọn {pendingEpubFiles.length} file EPUB. Mỗi file sẽ tạo thành một truyện và được gán vào cùng series.
|
||||
Đã chọn {pendingEpubFiles.length} file EPUB (từ file lẻ hoặc thư mục con). Mỗi file sẽ tạo thành một truyện và được gán vào cùng series.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => epubInputRef.current?.click()} disabled={uploadingEpub}>
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Thêm file EPUB
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => epubFolderInputRef.current?.click()} disabled={uploadingEpub}>
|
||||
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
|
||||
Thêm thư mục
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 rounded-md border bg-muted/20 p-3">
|
||||
<label className="text-sm font-medium">Khi trùng tên truyện</label>
|
||||
<select
|
||||
value={bulkDuplicateHandling}
|
||||
onChange={(e) => setBulkDuplicateHandling(e.target.value as BulkDuplicateHandling)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={uploadingEpub}
|
||||
>
|
||||
<option value="ask">Hỏi từng file</option>
|
||||
<option value="replace-all">Replace tất cả file trùng tên</option>
|
||||
<option value="skip-all">Bỏ qua tất cả file trùng tên</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gợi ý: dùng "Replace tất cả" khi import lại bộ truyện cũ theo lô để không bị popup lặp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/20 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Tiến trình import từng file</p>
|
||||
<span className="text-xs text-muted-foreground">{processedBulkCount}/{bulkProgressItems.length}</span>
|
||||
</div>
|
||||
|
||||
<Progress value={overallBulkProgress} className="mb-2 bg-muted/70" />
|
||||
|
||||
<div className="max-h-44 overflow-auto pr-1 custom-scrollbar">
|
||||
<div className="min-w-[760px] space-y-2">
|
||||
{bulkProgressItems.map((item) => (
|
||||
<div key={item.fileKey} className="space-y-1 rounded border bg-background/70 p-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs font-medium whitespace-nowrap" title={item.displayName}>{item.displayName}</p>
|
||||
<span className={`shrink-0 text-[11px] font-medium ${item.status === "success"
|
||||
? "text-emerald-600"
|
||||
: item.status === "failed"
|
||||
? "text-red-600"
|
||||
: item.status === "skipped"
|
||||
? "text-amber-600"
|
||||
: item.status === "uploading"
|
||||
? "text-blue-600"
|
||||
: "text-muted-foreground"}`}>
|
||||
{item.status === "success"
|
||||
? "Thành công"
|
||||
: item.status === "failed"
|
||||
? "Thất bại"
|
||||
: item.status === "skipped"
|
||||
? "Bỏ qua"
|
||||
: item.status === "uploading"
|
||||
? "Đang upload"
|
||||
: "Chờ"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={item.progress}
|
||||
className={item.status === "success"
|
||||
? "bg-emerald-100/50 [&>[data-slot=progress-indicator]]:bg-emerald-500"
|
||||
: item.status === "failed"
|
||||
? "bg-red-100/50 [&>[data-slot=progress-indicator]]:bg-red-500"
|
||||
: item.status === "skipped"
|
||||
? "bg-amber-100/50 [&>[data-slot=progress-indicator]]:bg-amber-500"
|
||||
: item.status === "pending"
|
||||
? "bg-muted [&>[data-slot=progress-indicator]]:bg-muted-foreground/40"
|
||||
: "bg-blue-100/50 [&>[data-slot=progress-indicator]]:bg-blue-500"
|
||||
}
|
||||
/>
|
||||
{item.message && <p className="text-[11px] text-muted-foreground">{item.message}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Series</label>
|
||||
<select
|
||||
@@ -1147,7 +1673,7 @@ export function NovelClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="sticky bottom-0 bg-background pt-2">
|
||||
<Button variant="outline" onClick={resetEpubPreviewState} disabled={uploadingEpub}>Huỷ</Button>
|
||||
<Button
|
||||
onClick={handleBulkEpubUpload}
|
||||
@@ -1168,7 +1694,7 @@ export function NovelClient() {
|
||||
<Dialog open={openAdd} onOpenChange={(val) => {
|
||||
setOpenAdd(val);
|
||||
if (val) {
|
||||
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSeriesMode("none"); setSelectedSeriesId(""); setNewSeriesName(""); setSelectedGenres([]); setNewGenreName("");
|
||||
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSeriesMode("none"); setSelectedSeriesId(""); setNewSeriesName(""); setSelectedGenres([]); setGenreQuery("");
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -1272,38 +1798,7 @@ export function NovelClient() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thêm thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Tên thể loại mới..."
|
||||
value={newGenreName}
|
||||
onChange={(e) => setNewGenreName(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddGenre(); } }}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !newGenreName.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : "Tạo"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto p-2 border rounded-md custom-scrollbar bg-card">
|
||||
{genres.map(genre => (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border transition-colors select-none ${selectedGenres.includes(genre.id) ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
<span className="cursor-pointer" onClick={() => toggleGenre(genre.id)}>{genre.name}</span>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5 ml-1 inline-flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteGenre(genre.id, genre.name); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{genres.length === 0 && <span className="text-xs text-muted-foreground p-1">Chưa có thể loại nào</span>}
|
||||
</div>
|
||||
</div>
|
||||
{renderGenreSelector("Thể loại")}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Giới thiệu ngắn (Mô tả)</label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Tóm tắt về câu chuyện..." rows={4} />
|
||||
@@ -1374,37 +1869,7 @@ export function NovelClient() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cập nhật thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Tên thể loại mới..."
|
||||
value={newGenreName}
|
||||
onChange={(e) => setNewGenreName(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddGenre(); } }}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddGenre} disabled={addingGenre || !newGenreName.trim()}>
|
||||
{addingGenre ? <Loader2 className="w-4 h-4 animate-spin" /> : "Tạo"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto p-2 border rounded-md custom-scrollbar bg-card">
|
||||
{genres.map(genre => (
|
||||
<div
|
||||
key={genre.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border transition-colors select-none ${selectedGenres.includes(genre.id) ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
<span className="cursor-pointer" onClick={() => toggleGenre(genre.id)}>{genre.name}</span>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5 ml-1 inline-flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteGenre(genre.id, genre.name); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderGenreSelector("Cập nhật thể loại")}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Trạng thái</label>
|
||||
<select
|
||||
@@ -1474,9 +1939,52 @@ export function NovelClient() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" onClick={toggleSelectAllVisible}>
|
||||
{allVisibleSelected ? "Bỏ chọn danh sách đang lọc" : "Chọn danh sách đang lọc"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const nextSize = Number(e.target.value)
|
||||
setPageSize(nextSize)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
className="h-10 rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="10">10 / trang</option>
|
||||
<option value="20">20 / trang</option>
|
||||
<option value="30">30 / trang</option>
|
||||
<option value="50">50 / trang</option>
|
||||
</select>
|
||||
|
||||
<Button type="button" variant="outline" onClick={toggleSelectAllVisible}>
|
||||
{allVisibleSelected ? "Bỏ chọn trang hiện tại" : "Chọn trang hiện tại"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Trang {currentPage}/{totalPages} - Hiển thị {pagedNovels.length} trên {filteredNovels.length} truyện
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNovelIds.length > 0 && (
|
||||
@@ -1524,7 +2032,7 @@ export function NovelClient() {
|
||||
) : filteredNovels.length === 0 ? (
|
||||
<tr><td colSpan={7} className="p-8 text-center text-muted-foreground">Không có truyện phù hợp với từ khóa tìm kiếm.</td></tr>
|
||||
) : (
|
||||
filteredNovels.map((novel) => (
|
||||
pagedNovels.map((novel) => (
|
||||
<tr key={novel.id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0">
|
||||
<td className="px-4 py-4 text-center">
|
||||
<input
|
||||
@@ -1589,7 +2097,7 @@ export function NovelClient() {
|
||||
) : filteredNovels.length === 0 ? (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">Không có truyện phù hợp với từ khóa tìm kiếm.</div>
|
||||
) : (
|
||||
filteredNovels.map((novel) => (
|
||||
pagedNovels.map((novel) => (
|
||||
<div key={novel.id} className="group relative flex flex-col rounded-xl overflow-hidden border shadow-sm transition-all hover:-translate-y-1 hover:shadow-md bg-card">
|
||||
<div className="aspect-[2/3] w-full bg-muted relative border-b">
|
||||
<div className="absolute left-2 top-2 z-10">
|
||||
|
||||
+504
-173
@@ -1,24 +1,133 @@
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Star, Trophy } from "lucide-react"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { HomeHotCarousel, type HotCarouselItem } from "@/components/home-hot-carousel"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
Sparkles: <Sparkles className="h-5 w-5" />,
|
||||
Flame: <Flame className="h-5 w-5" />,
|
||||
Heart: <Heart className="h-5 w-5" />,
|
||||
Sword: <Swords className="h-5 w-5" />,
|
||||
Building: <Building2 className="h-5 w-5" />,
|
||||
Rocket: <Rocket className="h-5 w-5" />,
|
||||
Crown: <Crown className="h-5 w-5" />,
|
||||
Laugh: <Laugh className="h-5 w-5" />,
|
||||
Search: <Search className="h-5 w-5" />,
|
||||
Shield: <Shield className="h-5 w-5" />,
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type HomeNovel = {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
authorName: string
|
||||
coverColor: string | null
|
||||
coverUrl: string | null
|
||||
rating: number
|
||||
views: number
|
||||
totalChapters: number
|
||||
status: string
|
||||
description: string
|
||||
bookmarkCount: number
|
||||
seriesId: string | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
type RankingEntry = {
|
||||
id: string
|
||||
seriesId: string | null
|
||||
novel: HomeNovel
|
||||
aggregatedViews: number
|
||||
}
|
||||
|
||||
type RecentCommentItem = {
|
||||
id: string
|
||||
content: string
|
||||
createdAt: Date
|
||||
user: {
|
||||
name: string | null
|
||||
}
|
||||
novel: {
|
||||
slug: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
type LatestChapterInfo = {
|
||||
chapterNumber: number | null
|
||||
chapterTitle: string | null
|
||||
chapterCreatedAt: Date | null
|
||||
}
|
||||
|
||||
const BASE_NOVEL_SELECT = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverColor: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
views: true,
|
||||
totalChapters: true,
|
||||
status: true,
|
||||
description: true,
|
||||
bookmarkCount: true,
|
||||
seriesId: true,
|
||||
updatedAt: true,
|
||||
} as const
|
||||
|
||||
function toUTCDateOnly(value: Date): Date {
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
|
||||
}
|
||||
|
||||
function shuffleRows<T>(rows: T[]): T[] {
|
||||
const next = [...rows]
|
||||
for (let i = next.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
const tmp = next[i]
|
||||
next[i] = next[j]
|
||||
next[j] = tmp
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function fillUniqueRows<T extends { id: string }>(primary: T[], fallback: T[], target: number): T[] {
|
||||
const picked = new Set<string>()
|
||||
const output: T[] = []
|
||||
|
||||
for (const row of primary) {
|
||||
if (picked.has(row.id)) continue
|
||||
picked.add(row.id)
|
||||
output.push(row)
|
||||
if (output.length >= target) return output
|
||||
}
|
||||
|
||||
for (const row of fallback) {
|
||||
if (picked.has(row.id)) continue
|
||||
picked.add(row.id)
|
||||
output.push(row)
|
||||
if (output.length >= target) return output
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: Date | null | undefined): string {
|
||||
if (!value) return "Vừa cập nhật"
|
||||
|
||||
const now = Date.now()
|
||||
const ts = value.getTime()
|
||||
const diff = Math.max(0, now - ts)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
|
||||
if (diff < minute) return "Vừa xong"
|
||||
if (diff < hour) return `${Math.floor(diff / minute)} phút trước`
|
||||
if (diff < day) return `${Math.floor(diff / hour)} giờ trước`
|
||||
if (diff < day * 30) return `${Math.floor(diff / day)} ngày trước`
|
||||
|
||||
return value.toLocaleDateString("vi-VN")
|
||||
}
|
||||
|
||||
function compactLine(text: string, max = 140): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim()
|
||||
if (normalized.length <= max) return normalized
|
||||
return `${normalized.slice(0, max).trim()}...`
|
||||
}
|
||||
|
||||
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
||||
const pickedSeries = new Set<string>()
|
||||
@@ -38,199 +147,421 @@ function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(
|
||||
return output
|
||||
}
|
||||
|
||||
async function fetchRankingByDailyViews(options?: { since?: Date; take?: number }): Promise<RankingEntry[]> {
|
||||
const delegate = (prisma as any).novelViewDaily
|
||||
if (!delegate || typeof delegate.groupBy !== "function") {
|
||||
return []
|
||||
}
|
||||
|
||||
let grouped: Array<{ novelId: string; _sum: { views: number | null } }> = []
|
||||
try {
|
||||
grouped = await delegate.groupBy({
|
||||
by: ["novelId"],
|
||||
where: options?.since ? { day: { gte: toUTCDateOnly(options.since) } } : undefined,
|
||||
_sum: { views: true },
|
||||
orderBy: { _sum: { views: "desc" } },
|
||||
take: options?.take || 300,
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("novelViewDaily aggregate unavailable, fallback to Novel.views", error)
|
||||
return []
|
||||
}
|
||||
|
||||
if (grouped.length === 0) return []
|
||||
|
||||
const ids = grouped.map((row) => row.novelId)
|
||||
const novels = await prisma.novel.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: BASE_NOVEL_SELECT,
|
||||
})
|
||||
|
||||
const novelMap = new Map(novels.map((novel) => [novel.id, novel as HomeNovel]))
|
||||
const entries: RankingEntry[] = []
|
||||
|
||||
for (const row of grouped) {
|
||||
const novel = novelMap.get(row.novelId)
|
||||
if (!novel) continue
|
||||
|
||||
entries.push({
|
||||
id: novel.id,
|
||||
seriesId: novel.seriesId,
|
||||
novel,
|
||||
aggregatedViews: row._sum.views || 0,
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function toHotCarouselItems(rows: Array<RankingEntry & { source: HotCarouselItem["hotSource"] }>): HotCarouselItem[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.novel.id,
|
||||
slug: row.novel.slug,
|
||||
title: row.novel.title,
|
||||
authorName: row.novel.authorName,
|
||||
description: row.novel.description,
|
||||
coverUrl: row.novel.coverUrl,
|
||||
totalChapters: row.novel.totalChapters,
|
||||
rating: row.novel.rating,
|
||||
views: row.aggregatedViews,
|
||||
status: row.novel.status,
|
||||
hotSource: row.source,
|
||||
}))
|
||||
}
|
||||
|
||||
function RankingBoard({
|
||||
title,
|
||||
entries,
|
||||
emptyText,
|
||||
}: {
|
||||
title: string
|
||||
entries: RankingEntry[]
|
||||
emptyText: string
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/70 bg-card/70 p-4 backdrop-blur">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-foreground/90">{title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entries.length > 0 ? entries.map((entry, index) => (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={`/truyen/${entry.novel.slug}`}
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 p-2.5 transition hover:border-primary/40"
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<img
|
||||
src={entry.novel.coverUrl || "/default-cover.svg"}
|
||||
alt={entry.novel.title}
|
||||
className="h-12 w-9 shrink-0 rounded-md border border-border/70 object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground group-hover:text-primary">{entry.novel.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatViews(entry.aggregatedViews)} lượt đọc</p>
|
||||
</div>
|
||||
</Link>
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
let popularNovels: any[] = []
|
||||
let latestNovels: any[] = []
|
||||
let topRated: any[] = []
|
||||
let genres: any[] = []
|
||||
let featured = null
|
||||
let hotSlides: HotCarouselItem[] = []
|
||||
let randomNovels: HomeNovel[] = []
|
||||
let recommendedNovels: HomeNovel[] = []
|
||||
let latestNovels: HomeNovel[] = []
|
||||
let recentComments: RecentCommentItem[] = []
|
||||
let weeklyRanking: RankingEntry[] = []
|
||||
let monthlyRanking: RankingEntry[] = []
|
||||
let allTimeRanking: RankingEntry[] = []
|
||||
const latestChapterMap = new Map<string, LatestChapterInfo>()
|
||||
|
||||
try {
|
||||
popularNovels = await prisma.novel.findMany({
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverColor: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
views: true,
|
||||
totalChapters: true,
|
||||
status: true,
|
||||
description: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: { views: "desc" },
|
||||
})
|
||||
popularNovels = collapseSeriesRows(popularNovels).slice(0, 20)
|
||||
const now = new Date()
|
||||
const weekStart = new Date(now)
|
||||
weekStart.setDate(now.getDate() - 7)
|
||||
const monthStart = new Date(now)
|
||||
monthStart.setDate(now.getDate() - 30)
|
||||
|
||||
latestNovels = await prisma.novel.findMany({
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverColor: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
views: true,
|
||||
totalChapters: true,
|
||||
status: true,
|
||||
description: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
latestNovels = collapseSeriesRows(latestNovels).slice(0, 20)
|
||||
const [
|
||||
weeklyResult,
|
||||
monthlyResult,
|
||||
allTimeResult,
|
||||
popularFallbackResult,
|
||||
randomPoolResult,
|
||||
recommendedPoolResult,
|
||||
latestPoolResult,
|
||||
commentsResult,
|
||||
] = await Promise.allSettled([
|
||||
fetchRankingByDailyViews({ since: weekStart, take: 600 }),
|
||||
fetchRankingByDailyViews({ since: monthStart, take: 600 }),
|
||||
fetchRankingByDailyViews({ take: 800 }),
|
||||
prisma.novel.findMany({
|
||||
take: 400,
|
||||
select: BASE_NOVEL_SELECT,
|
||||
orderBy: [{ views: "desc" }, { updatedAt: "desc" }],
|
||||
}),
|
||||
prisma.novel.findMany({
|
||||
take: 420,
|
||||
select: BASE_NOVEL_SELECT,
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
}),
|
||||
prisma.novel.findMany({
|
||||
take: 220,
|
||||
select: BASE_NOVEL_SELECT,
|
||||
orderBy: [{ rating: "desc" }, { bookmarkCount: "desc" }, { views: "desc" }],
|
||||
}),
|
||||
prisma.novel.findMany({
|
||||
take: 180,
|
||||
select: BASE_NOVEL_SELECT,
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
}),
|
||||
prisma.comment.findMany({
|
||||
take: 10,
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
user: { select: { name: true } },
|
||||
novel: { select: { slug: true, title: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
topRated = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
totalChapters: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
topRated = collapseSeriesRows(topRated).slice(0, 4)
|
||||
if (weeklyResult.status === "rejected") console.warn("Homepage weekly ranking query failed", weeklyResult.reason)
|
||||
if (monthlyResult.status === "rejected") console.warn("Homepage monthly ranking query failed", monthlyResult.reason)
|
||||
if (allTimeResult.status === "rejected") console.warn("Homepage all-time ranking query failed", allTimeResult.reason)
|
||||
if (popularFallbackResult.status === "rejected") console.warn("Homepage popular fallback query failed", popularFallbackResult.reason)
|
||||
if (randomPoolResult.status === "rejected") console.warn("Homepage random pool query failed", randomPoolResult.reason)
|
||||
if (recommendedPoolResult.status === "rejected") console.warn("Homepage recommended pool query failed", recommendedPoolResult.reason)
|
||||
if (latestPoolResult.status === "rejected") console.warn("Homepage latest pool query failed", latestPoolResult.reason)
|
||||
if (commentsResult.status === "rejected") console.warn("Homepage comments query failed", commentsResult.reason)
|
||||
|
||||
genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
})
|
||||
const weeklyRaw = weeklyResult.status === "fulfilled" ? weeklyResult.value : []
|
||||
const monthlyRaw = monthlyResult.status === "fulfilled" ? monthlyResult.value : []
|
||||
const allTimeRaw = allTimeResult.status === "fulfilled" ? allTimeResult.value : []
|
||||
const popularFallbackRaw = popularFallbackResult.status === "fulfilled" ? popularFallbackResult.value : []
|
||||
const randomPoolRaw = randomPoolResult.status === "fulfilled" ? randomPoolResult.value : []
|
||||
const recommendedPoolRaw = recommendedPoolResult.status === "fulfilled" ? recommendedPoolResult.value : []
|
||||
const latestPoolRaw = latestPoolResult.status === "fulfilled" ? latestPoolResult.value : []
|
||||
const commentsPool = commentsResult.status === "fulfilled" ? commentsResult.value : []
|
||||
|
||||
featured = popularNovels.length > 0 ? popularNovels[0] : null
|
||||
const popularFallbackRows: RankingEntry[] = (popularFallbackRaw as HomeNovel[]).map((novel) => ({
|
||||
id: novel.id,
|
||||
seriesId: novel.seriesId,
|
||||
novel,
|
||||
aggregatedViews: novel.views,
|
||||
}))
|
||||
|
||||
weeklyRanking = fillUniqueRows(collapseSeriesRows(weeklyRaw), collapseSeriesRows(popularFallbackRows), 10)
|
||||
monthlyRanking = fillUniqueRows(collapseSeriesRows(monthlyRaw), collapseSeriesRows(popularFallbackRows), 10)
|
||||
allTimeRanking = fillUniqueRows(collapseSeriesRows(allTimeRaw), collapseSeriesRows(popularFallbackRows), 10)
|
||||
|
||||
const hotWeekly = weeklyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "week" as const }))
|
||||
const hotMonthly = monthlyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "month" as const }))
|
||||
|
||||
hotSlides = toHotCarouselItems([...hotWeekly, ...hotMonthly]).slice(0, 10)
|
||||
|
||||
const usedHotIds = new Set(hotSlides.map((item) => item.id))
|
||||
const randomPool = randomPoolRaw as HomeNovel[]
|
||||
const randomCandidates = collapseSeriesRows(shuffleRows(randomPool)).filter((item) => !usedHotIds.has(item.id))
|
||||
randomNovels = fillUniqueRows(randomCandidates, shuffleRows(randomPool), 12)
|
||||
|
||||
recommendedNovels = fillUniqueRows(
|
||||
collapseSeriesRows(recommendedPoolRaw as HomeNovel[]),
|
||||
collapseSeriesRows(popularFallbackRaw as HomeNovel[]),
|
||||
10,
|
||||
)
|
||||
|
||||
latestNovels = fillUniqueRows(
|
||||
collapseSeriesRows(latestPoolRaw as HomeNovel[]),
|
||||
collapseSeriesRows(popularFallbackRaw as HomeNovel[]),
|
||||
12,
|
||||
)
|
||||
recentComments = commentsPool as RecentCommentItem[]
|
||||
|
||||
const latestIds = latestNovels.map((item) => item.id)
|
||||
if (latestIds.length > 0) {
|
||||
await connectToMongoDB()
|
||||
const rows = await Chapter.aggregate([
|
||||
{ $match: { novelId: { $in: latestIds } } },
|
||||
{ $sort: { novelId: 1, createdAt: -1, number: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: "$novelId",
|
||||
chapterNumber: { $first: "$number" },
|
||||
chapterTitle: { $first: "$title" },
|
||||
chapterCreatedAt: { $first: "$createdAt" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
for (const row of rows) {
|
||||
latestChapterMap.set(String(row._id), {
|
||||
chapterNumber: typeof row.chapterNumber === "number" ? row.chapterNumber : null,
|
||||
chapterTitle: typeof row.chapterTitle === "string" ? row.chapterTitle : null,
|
||||
chapterCreatedAt: row.chapterCreatedAt ? new Date(row.chapterCreatedAt) : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data for homepage during build/runtime", error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Hero / Featured Novel */}
|
||||
{featured && (
|
||||
<section className="mb-10">
|
||||
<Link
|
||||
href={`/truyen/${featured.slug}`}
|
||||
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row"
|
||||
>
|
||||
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full bg-muted object-contain md:h-auto md:w-72" />
|
||||
<div className="flex flex-1 flex-col justify-center gap-3 p-6">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-primary">Truyện Nổi Bật</span>
|
||||
<h1 title={featured.title} className="text-2xl font-bold text-foreground group-hover:text-primary transition-colors text-balance md:text-3xl">
|
||||
{featured.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Tác giả: {featured.authorName}</p>
|
||||
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{featured.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{featured.totalChapters} chương</span>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getNovelStatusBadgeClass(featured.status)}`}>
|
||||
{featured.status}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{featured.rating}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Popular Novels */}
|
||||
<section className="mb-10">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-foreground">Truyện Hot</h2>
|
||||
<Link href="/tim-kiem?sort=popular" className="flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<div className="mx-auto max-w-7xl space-y-10 px-4 py-6">
|
||||
<section>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="inline-flex items-center gap-2 text-2xl font-bold text-foreground md:text-3xl">
|
||||
<Flame className="h-6 w-6 text-primary" />
|
||||
Truyện hot hôm nay
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Mỗi lần trượt hiển thị 1 truyện, dữ liệu lấy từ log đọc theo tuần và tháng.</p>
|
||||
</div>
|
||||
<Link href="/tim-kiem?sort=popular" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{hotSlides.length > 0 ? (
|
||||
<HomeHotCarousel items={hotSlides} />
|
||||
) : (
|
||||
<p className="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground">Chưa có dữ liệu hot để hiển thị.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="inline-flex items-center gap-2 text-xl font-bold text-foreground"><Shuffle className="h-5 w-5 text-primary" />Truyện ngẫu nhiên</h2>
|
||||
<span className="text-xs text-muted-foreground">Luôn cố gắng lấp đầy đủ 2 hàng</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{popularNovels.length > 0 ? popularNovels.map((novel) => (
|
||||
<NovelCard key={novel.id} novel={novel} />
|
||||
)) : <p className="text-sm text-muted-foreground col-span-full">Chưa có truyện nào trong hệ thống.</p>}
|
||||
{randomNovels.length > 0 ? randomNovels.map((novel) => (
|
||||
<Link
|
||||
key={novel.id}
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group overflow-hidden rounded-xl border border-border/70 bg-card transition hover:border-primary/40"
|
||||
>
|
||||
<div className="relative aspect-[3/4] w-full overflow-hidden bg-muted/50">
|
||||
<img
|
||||
src={novel.coverUrl || "/default-cover.svg"}
|
||||
alt={novel.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 p-3">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold text-foreground group-hover:text-primary">{novel.title}</h3>
|
||||
<p className="truncate text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{formatViews(novel.views)} lượt đọc</p>
|
||||
</div>
|
||||
</Link>
|
||||
)) : (
|
||||
<p className="col-span-full text-sm text-muted-foreground">Không có truyện để hiển thị.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Latest Updated */}
|
||||
<section className="mb-10">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-foreground">Mới Cập Nhật</h2>
|
||||
<Link href="/tim-kiem?sort=latest" className="flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{latestNovels.length > 0 ? latestNovels.map((novel) => (
|
||||
<NovelCard key={novel.id} novel={novel} variant="compact" />
|
||||
)) : <p className="text-sm text-muted-foreground col-span-full">Chưa có truyện nào được cập nhật.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Two columns: Top Rated + Genres */}
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
{/* Top Rated */}
|
||||
<section>
|
||||
<h2 className="mb-4 text-xl font-bold text-foreground">Đánh Giá Cao</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{topRated.length > 0 ? topRated.map((novel, idx) => (
|
||||
<section className="grid gap-6 lg:grid-cols-5">
|
||||
<div className="rounded-2xl border border-border/70 bg-card/70 p-4 lg:col-span-3">
|
||||
<h2 className="mb-4 text-xl font-bold text-foreground">Top truyện đề cử</h2>
|
||||
<div className="space-y-2">
|
||||
{recommendedNovels.length > 0 ? recommendedNovels.map((novel, index) => (
|
||||
<Link
|
||||
key={novel.id}
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group flex items-center gap-4 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-2.5 transition hover:border-primary/40"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||
{idx + 1}
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded bg-muted object-contain" />
|
||||
<img
|
||||
src={novel.coverUrl || "/default-cover.svg"}
|
||||
alt={novel.title}
|
||||
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 title={novel.title} className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{novel.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName} - Ch. {novel.totalChapters}</p>
|
||||
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{novel.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{formatViews(novel.bookmarkCount)} theo dõi</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm font-semibold text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{novel.rating}
|
||||
<div className="inline-flex items-center gap-1 text-xs font-semibold text-primary">
|
||||
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
|
||||
{novel.rating.toFixed(1)}
|
||||
</div>
|
||||
</Link>
|
||||
)) : <p className="text-sm text-muted-foreground">Chưa có đánh giá.</p>}
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">Chưa có truyện đề cử.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-foreground">Thể Loại</h2>
|
||||
<Link href="/the-loai" className="flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{genres.slice(0, 8).map((genre) => (
|
||||
<aside className="rounded-2xl border border-border/70 bg-card/70 p-4 lg:col-span-2">
|
||||
<h2 className="mb-4 inline-flex items-center gap-2 text-xl font-bold text-foreground"><MessageSquare className="h-5 w-5 text-primary" />Bình luận mới</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
{recentComments.length > 0 ? recentComments.map((comment) => (
|
||||
<Link
|
||||
key={genre.id}
|
||||
href={`/the-loai/${genre.slug}`}
|
||||
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-accent/50"
|
||||
key={comment.id}
|
||||
href={`/truyen/${comment.novel.slug}`}
|
||||
className="group block rounded-lg border border-border bg-background/80 p-3 transition hover:border-primary/40"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
{genre.icon && iconMap[genre.icon] ? iconMap[genre.icon] : <BookOpen className="h-5 w-5" />}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{genre.description}</p>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{comment.user.name || "Người dùng"}</span>
|
||||
<span>{formatRelativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p className="truncate text-xs font-medium text-primary">{comment.novel.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-foreground/90">{compactLine(comment.content, 120)}</p>
|
||||
</Link>
|
||||
))}
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">Chưa có bình luận mới.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/70 bg-card/70 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="inline-flex items-center gap-2 text-xl font-bold text-foreground"><Clock3 className="h-5 w-5 text-primary" />Truyện mới cập nhật</h2>
|
||||
<Link href="/tim-kiem?sort=latest" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{latestNovels.length > 0 ? latestNovels.map((novel) => {
|
||||
const chapter = latestChapterMap.get(novel.id)
|
||||
const chapterLabel = chapter?.chapterNumber ? `Chương ${chapter.chapterNumber}` : "Chưa có chương"
|
||||
const chapterTitle = chapter?.chapterTitle ? compactLine(chapter.chapterTitle, 100) : "Đang cập nhật nội dung chương"
|
||||
const updatedTime = formatRelativeTime(chapter?.chapterCreatedAt || novel.updatedAt)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={novel.id}
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-3 transition hover:border-primary/40"
|
||||
>
|
||||
<img
|
||||
src={novel.coverUrl || "/default-cover.svg"}
|
||||
alt={novel.title}
|
||||
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{novel.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
<p className="mt-1 text-xs text-primary">{chapterLabel}</p>
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground">{chapterTitle}</p>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-muted-foreground">{updatedTime}</div>
|
||||
</Link>
|
||||
)
|
||||
}) : (
|
||||
<p className="text-sm text-muted-foreground">Chưa có truyện mới cập nhật.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-foreground">Bảng xếp hạng độ hot</h2>
|
||||
<p className="text-sm text-muted-foreground">So sánh độ nóng theo tuần, tháng và toàn thời gian.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<RankingBoard title="Hot theo tuần" entries={weeklyRanking} emptyText="Tuần này chưa có dữ liệu nổi bật." />
|
||||
<RankingBoard title="Hot theo tháng" entries={monthlyRanking} emptyText="Tháng này chưa có dữ liệu nổi bật." />
|
||||
<RankingBoard title="Hot toàn thời gian" entries={allTimeRanking} emptyText="Chưa có dữ liệu toàn thời gian." />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user