Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -44,6 +44,13 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
# Cấu hình Google Login
|
||||
GOOGLE_CLIENT_ID="your_google_client_id"
|
||||
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||
|
||||
# Cloudflare R2 (lưu ảnh bìa)
|
||||
R2_ACCOUNT_ID="your_cloudflare_account_id"
|
||||
R2_ACCESS_KEY_ID="your_r2_access_key_id"
|
||||
R2_SECRET_ACCESS_KEY="your_r2_secret_access_key"
|
||||
R2_BUCKET_NAME="your_r2_bucket_name"
|
||||
R2_PUBLIC_BASE_URL="https://your-public-r2-domain"
|
||||
```
|
||||
|
||||
### 3. Cài đặt dependencies và khởi tạo DB
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const session = await getServerSession(authOptions)
|
||||
@@ -18,10 +19,34 @@ export async function PUT(req: Request) {
|
||||
return NextResponse.json({ error: "Tham số không hợp lệ" }, { status: 400 })
|
||||
}
|
||||
|
||||
const novel = await prisma.novel.findUnique({
|
||||
where: { id: novelId },
|
||||
select: { id: true, uploaderId: true }
|
||||
})
|
||||
|
||||
if (!novel) {
|
||||
return NextResponse.json({ error: "Không tìm thấy truyện" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const validUpdates = updates.filter((update: any) =>
|
||||
update &&
|
||||
typeof update.id === "string" &&
|
||||
typeof update.number === "number" &&
|
||||
typeof update.title === "string"
|
||||
)
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
return NextResponse.json({ message: "Không có thay đổi nào" }, { status: 200 })
|
||||
}
|
||||
|
||||
await connectToMongoDB()
|
||||
|
||||
// Prepare bulk operations for mongoose
|
||||
const bulkOps = updates.map((update: any) => ({
|
||||
const bulkOps = validUpdates.map((update: any) => ({
|
||||
updateOne: {
|
||||
filter: { _id: update.id, novelId: novelId },
|
||||
update: {
|
||||
@@ -41,6 +66,7 @@ export async function PUT(req: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Cập nhật thành công",
|
||||
matchedCount: result.matchedCount,
|
||||
modifiedCount: result.modifiedCount
|
||||
}, { status: 200 })
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
function toNullableNumber(value: any): number | null {
|
||||
if (value === null || value === undefined || value === "") return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const novelId = searchParams.get("novelId")
|
||||
@@ -48,7 +54,7 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const data = await req.json()
|
||||
const { novelId, number, title, content } = data
|
||||
const { novelId, number, title, content, volumeNumber, volumeTitle, volumeChapterNumber } = data
|
||||
|
||||
// Xác minh truyện thuộc về Mod này
|
||||
const novel = await prisma.novel.findFirst({
|
||||
@@ -70,6 +76,9 @@ export async function POST(req: Request) {
|
||||
const newChapter = await Chapter.create({
|
||||
novelId,
|
||||
number,
|
||||
volumeNumber: toNullableNumber(volumeNumber),
|
||||
volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null,
|
||||
volumeChapterNumber: toNullableNumber(volumeChapterNumber),
|
||||
title,
|
||||
content,
|
||||
})
|
||||
@@ -96,7 +105,7 @@ export async function PUT(req: Request) {
|
||||
|
||||
try {
|
||||
const data = await req.json()
|
||||
const { id, novelId, number, title, content } = data
|
||||
const { id, novelId, number, title, content, volumeNumber, volumeTitle, volumeChapterNumber } = data
|
||||
|
||||
// Xác minh truyện thuộc về Mod này
|
||||
const novel = await prisma.novel.findFirst({
|
||||
@@ -111,7 +120,14 @@ export async function PUT(req: Request) {
|
||||
|
||||
const updatedChapter = await Chapter.findOneAndUpdate(
|
||||
{ _id: id, novelId },
|
||||
{ number, title, content },
|
||||
{
|
||||
number,
|
||||
title,
|
||||
content,
|
||||
volumeNumber: toNullableNumber(volumeNumber),
|
||||
volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null,
|
||||
volumeChapterNumber: toNullableNumber(volumeChapterNumber),
|
||||
},
|
||||
{ new: true }
|
||||
)
|
||||
|
||||
|
||||
+703
-35
@@ -9,6 +9,590 @@ import os from "os"
|
||||
import { promises as fs } from "fs"
|
||||
import { convert } from "html-to-text"
|
||||
import { slugify } from "@/lib/utils"
|
||||
import { uploadBufferToR2 } from "@/lib/r2"
|
||||
|
||||
type SplitMode = "toc" | "regex"
|
||||
type SeriesMode = "none" | "existing" | "new"
|
||||
|
||||
interface EpubSection {
|
||||
sourceTitle: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ParsedChapter {
|
||||
title: string
|
||||
content: string
|
||||
detectedChapterNumber: number | null
|
||||
finalNumber?: number
|
||||
volumeNumber: number | null
|
||||
volumeTitle: string | null
|
||||
volumeChapterNumber: number | null
|
||||
isPlaceholder?: boolean
|
||||
}
|
||||
|
||||
interface EpubCoverAsset {
|
||||
buffer: Buffer | null
|
||||
mimeType: string | null
|
||||
sourceId: string | null
|
||||
}
|
||||
|
||||
const CHAPTER_REGEX_PRESETS: Record<string, string> = {
|
||||
vi_chuong: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
|
||||
en_chapter: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
|
||||
mix_chapter: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
|
||||
bracket_chapter: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$",
|
||||
}
|
||||
|
||||
const NOISE_TITLE_REGEX = /^(?:mục lục|table of contents|toc|cover|bìa|copyright)$/i
|
||||
|
||||
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*:?$/i
|
||||
function normalizeMetaText(value: any, fallback: string) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim()
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((v) => typeof v === "string" && v.trim().length > 0)
|
||||
if (first) return first.trim()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function extractVolumeNumber(title: string): number | null {
|
||||
const matched = title.match(/(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*([0-9]+)/i)
|
||||
if (!matched) return null
|
||||
const parsed = Number(matched[1])
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function extractChapterNumber(title: string): number | null {
|
||||
const matched = title.match(/(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+(?:\.[0-9]+)?)/i)
|
||||
if (!matched) return null
|
||||
const parsed = Number(matched[1])
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function extractStrictChapterNumber(title: string): number | null {
|
||||
const number = extractChapterNumber(title)
|
||||
if (number === null) return null
|
||||
if (!Number.isInteger(number)) return null
|
||||
if (number <= 0) return null
|
||||
if (number > 50000) return null
|
||||
return number
|
||||
}
|
||||
|
||||
function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string } {
|
||||
const lines = content.split(/\r?\n/)
|
||||
const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim().length > 0)
|
||||
if (firstNonEmptyLineIndex < 0) return { title, content }
|
||||
|
||||
const firstLineRaw = lines[firstNonEmptyLineIndex]
|
||||
const firstLine = firstLineRaw.trim()
|
||||
if (!firstLine || firstLine.length > 140) return { title, content }
|
||||
|
||||
const baseTitle = title.trim()
|
||||
const isSimpleBaseTitle = SIMPLE_CHAPTER_TITLE_REGEX.test(baseTitle)
|
||||
|
||||
if (!isSimpleBaseTitle) {
|
||||
return { title, content }
|
||||
}
|
||||
|
||||
let nextTitle = baseTitle
|
||||
|
||||
// Case 1: The first line already contains full chapter heading, use it directly.
|
||||
if (/^(?:ch(?:ương|apter)?|ch\.)\s*\d+/i.test(firstLine) && firstLine.length > baseTitle.length + 2) {
|
||||
nextTitle = firstLine
|
||||
} else if (!SIMPLE_CHAPTER_TITLE_REGEX.test(firstLine) && !isVolumeHeading(firstLine) && !NOISE_TITLE_REGEX.test(firstLine)) {
|
||||
// Case 2: TOC title is only "Chương N", subtitle is on next line.
|
||||
nextTitle = `${baseTitle.replace(/[:\s]+$/g, "")}: ${firstLine}`
|
||||
} else {
|
||||
return { title, content }
|
||||
}
|
||||
|
||||
const newLines = [...lines]
|
||||
newLines.splice(firstNonEmptyLineIndex, 1)
|
||||
const nextContent = newLines.join("\n").trim()
|
||||
|
||||
return {
|
||||
title: nextTitle,
|
||||
content: nextContent.length > 0 ? nextContent : content,
|
||||
}
|
||||
}
|
||||
|
||||
function isVolumeHeading(title: string): boolean {
|
||||
return /^(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*[0-9]+(?:\s*[:-].*)?$/i.test(title.trim())
|
||||
}
|
||||
|
||||
function normalizeSplitMode(value: FormDataEntryValue | null): SplitMode {
|
||||
return value === "regex" ? "regex" : "toc"
|
||||
}
|
||||
|
||||
function normalizeSeriesMode(value: FormDataEntryValue | null): SeriesMode {
|
||||
if (value === "existing") return "existing"
|
||||
if (value === "new") return "new"
|
||||
return "none"
|
||||
}
|
||||
|
||||
function readFormText(formData: FormData, key: string): string {
|
||||
const value = formData.get(key)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
async function resolveSeriesIdForEpubImport(options: {
|
||||
mode: SeriesMode
|
||||
seriesId: string
|
||||
seriesName: string
|
||||
userRole: "USER" | "MOD" | "ADMIN"
|
||||
userId: string
|
||||
}) {
|
||||
if (options.mode === "none") return null
|
||||
|
||||
if (options.mode === "existing") {
|
||||
if (!options.seriesId) {
|
||||
throw new Error("Thiếu series để thêm vào")
|
||||
}
|
||||
|
||||
const targetSeries = await prisma.series.findFirst({
|
||||
where: options.userRole === "ADMIN"
|
||||
? { id: options.seriesId }
|
||||
: {
|
||||
id: options.seriesId,
|
||||
OR: [
|
||||
{ novels: { some: { uploaderId: options.userId } } },
|
||||
{ novels: { some: { uploaderId: null } } },
|
||||
{ novels: { none: {} } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetSeries) {
|
||||
throw new Error("Series không tồn tại hoặc không đủ quyền")
|
||||
}
|
||||
|
||||
return targetSeries.id
|
||||
}
|
||||
|
||||
if (!options.seriesName) {
|
||||
throw new Error("Thiếu tên series mới")
|
||||
}
|
||||
|
||||
const existed = await prisma.series.findFirst({
|
||||
where: { name: { equals: options.seriesName, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existed) return existed.id
|
||||
|
||||
const baseSlug = slugify(options.seriesName)
|
||||
let slug = baseSlug
|
||||
let counter = 1
|
||||
|
||||
while (await prisma.series.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${counter}`
|
||||
counter += 1
|
||||
}
|
||||
|
||||
const created = await prisma.series.create({
|
||||
data: {
|
||||
name: options.seriesName,
|
||||
slug,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return created.id
|
||||
}
|
||||
|
||||
function resolveRegexPattern(formData: FormData): { regexInput: string; regexPreset: string | null } {
|
||||
const preset = readFormText(formData, "chapterRegexPreset")
|
||||
const custom = readFormText(formData, "chapterRegex")
|
||||
|
||||
if (custom) {
|
||||
return { regexInput: custom, regexPreset: preset || "custom" }
|
||||
}
|
||||
|
||||
if (preset && CHAPTER_REGEX_PRESETS[preset]) {
|
||||
return { regexInput: CHAPTER_REGEX_PRESETS[preset], regexPreset: preset }
|
||||
}
|
||||
|
||||
return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong, regexPreset: "vi_chuong" }
|
||||
}
|
||||
|
||||
function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: string } {
|
||||
if (!regexInput || regexInput.length > 300) {
|
||||
throw new Error("Regex không hợp lệ")
|
||||
}
|
||||
|
||||
let pattern = regexInput
|
||||
let flags = ""
|
||||
|
||||
const slashWrapped = regexInput.match(/^\/(.+)\/([gimsuy]*)$/)
|
||||
if (slashWrapped) {
|
||||
pattern = slashWrapped[1]
|
||||
flags = slashWrapped[2]
|
||||
}
|
||||
|
||||
const flagSet = new Set(flags.split(""))
|
||||
flagSet.add("g")
|
||||
flagSet.add("m")
|
||||
const normalizedFlags = Array.from(flagSet).join("")
|
||||
const regex = new RegExp(pattern, normalizedFlags)
|
||||
|
||||
return { regex, normalized: `/${pattern}/${normalizedFlags}` }
|
||||
}
|
||||
|
||||
function enrichVolumeMetadata(chapters: Array<{ title: string; content: string }>): ParsedChapter[] {
|
||||
let currentVolumeNumber: number | null = null
|
||||
let currentVolumeTitle: string | null = null
|
||||
let volumeChapterCounter = 0
|
||||
|
||||
return chapters.map((chapter) => {
|
||||
const title = chapter.title.trim()
|
||||
|
||||
const explicitVolumeNumber = extractVolumeNumber(title)
|
||||
if (explicitVolumeNumber !== null) {
|
||||
if (currentVolumeNumber !== explicitVolumeNumber) {
|
||||
volumeChapterCounter = 0
|
||||
}
|
||||
|
||||
currentVolumeNumber = explicitVolumeNumber
|
||||
currentVolumeTitle = isVolumeHeading(title)
|
||||
? title
|
||||
: (currentVolumeTitle || `Quyển ${explicitVolumeNumber}`)
|
||||
}
|
||||
|
||||
const explicitChapterNumber = extractChapterNumber(title)
|
||||
let volumeChapterNumber: number | null = null
|
||||
|
||||
if (currentVolumeNumber !== null) {
|
||||
if (explicitChapterNumber !== null) {
|
||||
volumeChapterCounter = explicitChapterNumber
|
||||
} else {
|
||||
volumeChapterCounter += 1
|
||||
}
|
||||
volumeChapterNumber = volumeChapterCounter
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
content: chapter.content,
|
||||
detectedChapterNumber: extractStrictChapterNumber(title),
|
||||
volumeNumber: currentVolumeNumber,
|
||||
volumeTitle: currentVolumeTitle,
|
||||
volumeChapterNumber,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[] {
|
||||
const chapters: ParsedChapter[] = []
|
||||
|
||||
let currentVolumeNumber: number | null = null
|
||||
let currentVolumeTitle: string | null = null
|
||||
let currentVolumeChapterCounter = 0
|
||||
let fallbackVolumeCounter = 0
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i]
|
||||
const rawTitle = section.sourceTitle || `Chương ${i + 1}`
|
||||
const cleanTitle = rawTitle.replace(/\s+/g, " ").trim()
|
||||
const cleanContent = section.content.trim()
|
||||
|
||||
if (!cleanContent) continue
|
||||
|
||||
if (isVolumeHeading(cleanTitle)) {
|
||||
const extracted = extractVolumeNumber(cleanTitle)
|
||||
if (extracted !== null) {
|
||||
currentVolumeNumber = extracted
|
||||
} else {
|
||||
fallbackVolumeCounter += 1
|
||||
currentVolumeNumber = fallbackVolumeCounter
|
||||
}
|
||||
|
||||
currentVolumeTitle = cleanTitle
|
||||
currentVolumeChapterCounter = 0
|
||||
|
||||
if (cleanContent.length <= 240) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (NOISE_TITLE_REGEX.test(cleanTitle) && cleanContent.length <= 240) {
|
||||
continue
|
||||
}
|
||||
|
||||
const explicitVolumeFromTitle = extractVolumeNumber(cleanTitle)
|
||||
if (explicitVolumeFromTitle !== null) {
|
||||
if (currentVolumeNumber !== explicitVolumeFromTitle) {
|
||||
currentVolumeChapterCounter = 0
|
||||
}
|
||||
currentVolumeNumber = explicitVolumeFromTitle
|
||||
if (!currentVolumeTitle || isVolumeHeading(cleanTitle)) {
|
||||
currentVolumeTitle = `Quyển ${explicitVolumeFromTitle}`
|
||||
}
|
||||
}
|
||||
|
||||
let volumeChapterNumber: number | null = null
|
||||
const detectedChapterNumber = extractStrictChapterNumber(cleanTitle)
|
||||
if (currentVolumeNumber !== null) {
|
||||
const explicitChapter = extractChapterNumber(cleanTitle)
|
||||
if (explicitChapter !== null) {
|
||||
currentVolumeChapterCounter = explicitChapter
|
||||
} else {
|
||||
currentVolumeChapterCounter += 1
|
||||
}
|
||||
volumeChapterNumber = currentVolumeChapterCounter
|
||||
}
|
||||
|
||||
const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent)
|
||||
|
||||
chapters.push({
|
||||
title: enhanced.title,
|
||||
content: enhanced.content,
|
||||
detectedChapterNumber,
|
||||
volumeNumber: currentVolumeNumber,
|
||||
volumeTitle: currentVolumeTitle,
|
||||
volumeChapterNumber,
|
||||
})
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
function buildChaptersFromRegexSections(sections: EpubSection[], regex: RegExp): ParsedChapter[] {
|
||||
const combinedText = sections
|
||||
.map((section) => section.content.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
|
||||
const matches = Array.from(combinedText.matchAll(regex))
|
||||
if (matches.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed: Array<{ title: string; content: string }> = []
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const match = matches[i]
|
||||
if (match.index === undefined) continue
|
||||
|
||||
const nextMatch = matches[i + 1]
|
||||
const headingRaw = (match[1] || match[0] || "").replace(/\s+/g, " ").trim()
|
||||
const sectionStart = match.index + match[0].length
|
||||
const sectionEnd = nextMatch?.index ?? combinedText.length
|
||||
const body = combinedText.slice(sectionStart, sectionEnd).trim()
|
||||
|
||||
if (!headingRaw || body.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const enhanced = enhanceChapterTitleFromContent(headingRaw, body)
|
||||
|
||||
parsed.push({
|
||||
title: enhanced.title,
|
||||
content: enhanced.content,
|
||||
})
|
||||
}
|
||||
|
||||
return enrichVolumeMetadata(parsed)
|
||||
}
|
||||
|
||||
function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
|
||||
chapters: ParsedChapter[]
|
||||
insertedCount: number
|
||||
detectedMax: number
|
||||
detectedNumberAssignments: number
|
||||
} {
|
||||
const detectedNumbers = chapters
|
||||
.map((chapter) => chapter.detectedChapterNumber)
|
||||
.filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n > 0)
|
||||
|
||||
let insertedCount = 0
|
||||
let detectedNumberAssignments = 0
|
||||
let currentNumber = 0
|
||||
const maxDetected = detectedNumbers.length > 0 ? Math.max(...detectedNumbers) : chapters.length
|
||||
const normalized: ParsedChapter[] = []
|
||||
const MAX_ALLOWED_GAP = 40
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const detected = chapter.detectedChapterNumber
|
||||
const canUseDetected =
|
||||
typeof detected === "number" &&
|
||||
detected > currentNumber &&
|
||||
detected - currentNumber <= MAX_ALLOWED_GAP
|
||||
|
||||
if (canUseDetected) {
|
||||
for (let missing = currentNumber + 1; missing < detected; missing++) {
|
||||
insertedCount += 1
|
||||
normalized.push({
|
||||
title: `Chương ${missing} (Thiếu)`,
|
||||
content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`,
|
||||
detectedChapterNumber: missing,
|
||||
finalNumber: missing,
|
||||
volumeNumber: null,
|
||||
volumeTitle: null,
|
||||
volumeChapterNumber: null,
|
||||
isPlaceholder: true,
|
||||
})
|
||||
}
|
||||
|
||||
detectedNumberAssignments += 1
|
||||
currentNumber = detected
|
||||
normalized.push({
|
||||
...chapter,
|
||||
finalNumber: currentNumber,
|
||||
volumeChapterNumber: chapter.volumeChapterNumber,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
currentNumber += 1
|
||||
normalized.push({
|
||||
...chapter,
|
||||
finalNumber: currentNumber,
|
||||
volumeChapterNumber: chapter.volumeChapterNumber,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
chapters: normalized,
|
||||
insertedCount,
|
||||
detectedMax: maxDetected,
|
||||
detectedNumberAssignments,
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCoverFromEpub(epub: any): Promise<EpubCoverAsset> {
|
||||
const manifest = epub.manifest || {}
|
||||
const metadataCover = epub.metadata?.cover ? String(epub.metadata.cover) : null
|
||||
|
||||
const candidateIds: string[] = []
|
||||
if (metadataCover) candidateIds.push(metadataCover)
|
||||
|
||||
for (const [key, value] of Object.entries(manifest)) {
|
||||
const item = value as any
|
||||
const id = String(item?.id || key)
|
||||
const href = String(item?.href || "")
|
||||
const mediaType = String(item?.mediaType || item?.["media-type"] || "")
|
||||
const properties = String(item?.properties || "")
|
||||
|
||||
if (
|
||||
/cover-image/i.test(properties) ||
|
||||
/cover/i.test(id) ||
|
||||
/cover/i.test(href)
|
||||
) {
|
||||
candidateIds.push(id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (/image\//i.test(mediaType) && /cover/i.test(href)) {
|
||||
candidateIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueCandidateIds = Array.from(new Set(candidateIds.filter(Boolean)))
|
||||
if (uniqueCandidateIds.length === 0) {
|
||||
return { buffer: null, mimeType: null, sourceId: null }
|
||||
}
|
||||
|
||||
for (const id of uniqueCandidateIds) {
|
||||
const fromImage = await new Promise<EpubCoverAsset>((resolve) => {
|
||||
if (typeof epub.getImage !== "function") {
|
||||
resolve({ buffer: null, mimeType: null, sourceId: null })
|
||||
return
|
||||
}
|
||||
|
||||
epub.getImage(id, (err: any, data: any, mimeType?: string) => {
|
||||
if (err || !data) {
|
||||
resolve({ buffer: null, mimeType: null, sourceId: null })
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id })
|
||||
})
|
||||
})
|
||||
|
||||
if (fromImage.buffer) {
|
||||
return fromImage
|
||||
}
|
||||
|
||||
const fromFile = await new Promise<EpubCoverAsset>((resolve) => {
|
||||
if (typeof epub.getFile !== "function") {
|
||||
resolve({ buffer: null, mimeType: null, sourceId: null })
|
||||
return
|
||||
}
|
||||
|
||||
epub.getFile(id, (err: any, data: any, mimeType?: string) => {
|
||||
if (err || !data) {
|
||||
resolve({ buffer: null, mimeType: null, sourceId: null })
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id })
|
||||
})
|
||||
})
|
||||
|
||||
if (fromFile.buffer) {
|
||||
return fromFile
|
||||
}
|
||||
}
|
||||
|
||||
return { buffer: null, mimeType: null, sourceId: null }
|
||||
}
|
||||
|
||||
async function saveCoverBufferToR2(cover: EpubCoverAsset): Promise<string | null> {
|
||||
if (!cover.buffer) return null
|
||||
|
||||
return uploadBufferToR2({
|
||||
buffer: cover.buffer,
|
||||
contentType: cover.mimeType,
|
||||
keyPrefix: "covers/epub",
|
||||
fileNameHint: cover.sourceId || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function parseEpubSections(tempFilePath: string): Promise<{ metadata: any; sections: EpubSection[]; cover: EpubCoverAsset }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const EPub = require("epub2").EPub || require("epub2")
|
||||
const epub = new EPub(tempFilePath, "", "")
|
||||
|
||||
epub.on("error", (err: any) => reject(err))
|
||||
epub.on("end", async () => {
|
||||
try {
|
||||
const metadata = epub.metadata
|
||||
const flow = epub.flow
|
||||
const sections: EpubSection[] = []
|
||||
const cover = await extractCoverFromEpub(epub)
|
||||
|
||||
for (let i = 0; i < flow.length; i++) {
|
||||
const item = flow[i]
|
||||
const text = await new Promise<string>((res) => {
|
||||
epub.getChapter(item.id, (err: any, data: string) => {
|
||||
if (err) res("")
|
||||
else res(data)
|
||||
})
|
||||
})
|
||||
|
||||
if (!text || text.trim().length === 0) continue
|
||||
|
||||
const plainText = convert(text, { wordwrap: false }).trim()
|
||||
if (!plainText) continue
|
||||
|
||||
sections.push({
|
||||
sourceTitle: item.title || `Mục ${i + 1}`,
|
||||
content: plainText,
|
||||
})
|
||||
}
|
||||
|
||||
resolve({ metadata, sections, cover })
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
epub.parse()
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions)
|
||||
@@ -19,6 +603,12 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
const formData = await req.formData()
|
||||
const epubFile = formData.get("file") as File
|
||||
const previewOnly = String(formData.get("preview") || "").toLowerCase() === "true"
|
||||
const splitMode = normalizeSplitMode(formData.get("splitMode"))
|
||||
const seriesMode = normalizeSeriesMode(formData.get("seriesMode"))
|
||||
const seriesIdInput = readFormText(formData, "seriesId")
|
||||
const seriesNameInput = readFormText(formData, "seriesName")
|
||||
|
||||
if (!epubFile) {
|
||||
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 })
|
||||
}
|
||||
@@ -27,49 +617,116 @@ export async function POST(req: Request) {
|
||||
const tempFilePath = path.join(os.tmpdir(), `upload-${Date.now()}.epub`)
|
||||
await fs.writeFile(tempFilePath, buffer)
|
||||
|
||||
// Phân tích EPUB file
|
||||
const parsedData = await new Promise<any>((resolve, reject) => {
|
||||
const EPub = require("epub2").EPub || require("epub2")
|
||||
const epub = new EPub(tempFilePath, "", "")
|
||||
let parsedData: any = null
|
||||
try {
|
||||
const { metadata, sections, cover } = await parseEpubSections(tempFilePath)
|
||||
|
||||
epub.on("error", (err: any) => reject(err))
|
||||
epub.on("end", async () => {
|
||||
const metadata = epub.metadata
|
||||
const flow = epub.flow // TOC array
|
||||
const chapters = []
|
||||
let regexNormalized: string | null = null
|
||||
let regexPreset: string | null = null
|
||||
let chapters: ParsedChapter[] = []
|
||||
|
||||
for (let i = 0; i < flow.length; i++) {
|
||||
const chapterData = flow[i]
|
||||
const text = await new Promise<string>((res) => {
|
||||
epub.getChapter(chapterData.id, (err: any, d: string) => {
|
||||
if (err) res("")
|
||||
else res(d)
|
||||
})
|
||||
})
|
||||
if (splitMode === "regex") {
|
||||
const regexResolved = resolveRegexPattern(formData)
|
||||
const compiled = buildRegexFromInput(regexResolved.regexInput)
|
||||
chapters = buildChaptersFromRegexSections(sections, compiled.regex)
|
||||
regexNormalized = compiled.normalized
|
||||
regexPreset = regexResolved.regexPreset
|
||||
|
||||
if (text && text.trim().length > 0) {
|
||||
const plainText = convert(text, { wordwrap: false })
|
||||
chapters.push({
|
||||
title: chapterData.title || `Chương ${i + 1}`,
|
||||
content: plainText
|
||||
})
|
||||
if (chapters.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Regex không tách được chương nào. Hãy thử regex khác hoặc chuyển về TOC.",
|
||||
parserInfo: {
|
||||
splitMode,
|
||||
chapterRegexUsed: regexNormalized,
|
||||
regexPreset,
|
||||
sourceSections: sections.length,
|
||||
chaptersDetected: 0,
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
chapters = buildChaptersFromTOCSections(sections)
|
||||
if (chapters.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Không tìm thấy chương hợp lệ từ TOC. Bạn có thể thử chế độ Regex." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ metadata, chapters })
|
||||
})
|
||||
|
||||
epub.parse()
|
||||
})
|
||||
const gapFilled = withMissingChapterPlaceholders(chapters)
|
||||
|
||||
parsedData = {
|
||||
metadata,
|
||||
sections,
|
||||
chapters: gapFilled.chapters,
|
||||
cover,
|
||||
parserInfo: {
|
||||
splitMode,
|
||||
chapterRegexUsed: regexNormalized,
|
||||
regexPreset,
|
||||
sourceSections: sections.length,
|
||||
chaptersDetected: chapters.length,
|
||||
chaptersFinal: gapFilled.chapters.length,
|
||||
insertedMissingChapters: gapFilled.insertedCount,
|
||||
detectedMaxChapterNumber: gapFilled.detectedMax,
|
||||
detectedNumberAssignments: gapFilled.detectedNumberAssignments,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Xóa file tạm
|
||||
await fs.unlink(tempFilePath).catch(() => { })
|
||||
}
|
||||
|
||||
const { metadata, chapters } = parsedData
|
||||
const { metadata, chapters, parserInfo, cover } = parsedData
|
||||
|
||||
let novelTitle = metadata.title || "Truyện chưa đặt tên"
|
||||
let novelAuthor = metadata.creator || "Khuyết danh"
|
||||
let novelDesc = metadata.description || "Chưa có giới thiệu"
|
||||
const metadataTitle = normalizeMetaText(metadata?.title, "Truyện chưa đặt tên")
|
||||
const metadataAuthor = normalizeMetaText(metadata?.creator, "Khuyết danh")
|
||||
const metadataDescRaw = normalizeMetaText(metadata?.description, "Chưa có giới thiệu")
|
||||
const metadataDesc = convert(metadataDescRaw, { wordwrap: false })
|
||||
|
||||
const novelTitle = normalizeMetaText(readFormText(formData, "title"), metadataTitle)
|
||||
const novelAuthor = normalizeMetaText(readFormText(formData, "authorName"), metadataAuthor)
|
||||
const novelDesc = normalizeMetaText(readFormText(formData, "description"), metadataDesc)
|
||||
|
||||
const hasDetectedVolumes = chapters.some((ch: any) => ch.volumeNumber !== null)
|
||||
|
||||
if (previewOnly) {
|
||||
return NextResponse.json({
|
||||
preview: true,
|
||||
fileName: epubFile.name,
|
||||
splitMode,
|
||||
detectedStructureType: hasDetectedVolumes ? "light_novel" : "standard",
|
||||
parserInfo,
|
||||
hasCoverFromEpub: !!cover?.buffer,
|
||||
novel: {
|
||||
title: novelTitle,
|
||||
authorName: novelAuthor,
|
||||
description: novelDesc,
|
||||
totalChapters: chapters.length,
|
||||
},
|
||||
chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({
|
||||
number: ch.finalNumber || i + 1,
|
||||
title: ch.title,
|
||||
isPlaceholder: !!ch.isPlaceholder,
|
||||
volumeNumber: ch.volumeNumber,
|
||||
volumeTitle: ch.volumeTitle,
|
||||
volumeChapterNumber: ch.volumeChapterNumber,
|
||||
excerpt: (ch.content || "").slice(0, 180),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
const selectedSeriesId = await resolveSeriesIdForEpubImport({
|
||||
mode: seriesMode,
|
||||
seriesId: seriesIdInput,
|
||||
seriesName: seriesNameInput,
|
||||
userRole: session.user.role,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
// Generate base slug
|
||||
const baseSlug = slugify(novelTitle)
|
||||
@@ -83,12 +740,16 @@ export async function POST(req: Request) {
|
||||
slugCounter++
|
||||
}
|
||||
|
||||
const coverUrl = await saveCoverBufferToR2(cover)
|
||||
|
||||
const newNovel = await prisma.novel.create({
|
||||
data: {
|
||||
title: novelTitle,
|
||||
slug: slug,
|
||||
authorName: novelAuthor,
|
||||
description: convert(novelDesc, { wordwrap: false }), // metadata metadata có thể chứa html
|
||||
description: novelDesc,
|
||||
coverUrl,
|
||||
seriesId: selectedSeriesId,
|
||||
uploaderId: session.user.id,
|
||||
totalChapters: chapters.length,
|
||||
},
|
||||
@@ -98,7 +759,10 @@ export async function POST(req: Request) {
|
||||
await connectToMongoDB()
|
||||
const chapterDocs = chapters.map((ch: any, i: number) => ({
|
||||
novelId: newNovel.id,
|
||||
number: i + 1,
|
||||
number: ch.finalNumber || (i + 1),
|
||||
volumeNumber: ch.volumeNumber ?? null,
|
||||
volumeTitle: ch.volumeTitle ?? null,
|
||||
volumeChapterNumber: ch.volumeChapterNumber ?? null,
|
||||
title: ch.title,
|
||||
content: ch.content,
|
||||
views: 0
|
||||
@@ -108,7 +772,11 @@ export async function POST(req: Request) {
|
||||
await Chapter.insertMany(chapterDocs)
|
||||
}
|
||||
|
||||
return NextResponse.json(newNovel, { status: 201 })
|
||||
return NextResponse.json({
|
||||
...newNovel,
|
||||
parserInfo,
|
||||
hasCoverFromEpub: !!coverUrl,
|
||||
}, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error("EPUB upload error:", error)
|
||||
return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 })
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { slugify } from "@/lib/utils"
|
||||
|
||||
function normalizeText(value: any): string {
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
async function resolveEditableSeries(
|
||||
id: string,
|
||||
session: { user: { role: "USER" | "MOD" | "ADMIN"; id: string } }
|
||||
) {
|
||||
return prisma.series.findFirst({
|
||||
where: session.user.role === "ADMIN"
|
||||
? { id }
|
||||
: {
|
||||
id,
|
||||
OR: [
|
||||
{ novels: { some: { uploaderId: session.user.id } } },
|
||||
{ novels: { some: { uploaderId: null } } },
|
||||
{ novels: { none: {} } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const series = await prisma.series.findMany({
|
||||
where: session.user.role === "ADMIN"
|
||||
? undefined
|
||||
: {
|
||||
OR: [
|
||||
{ novels: { some: { uploaderId: session.user.id } } },
|
||||
{ novels: { some: { uploaderId: null } } },
|
||||
{ novels: { none: {} } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
_count: { select: { novels: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(series)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to fetch series" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const name = normalizeText(body?.name)
|
||||
const description = normalizeText(body?.description)
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Tên series không được để trống" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await prisma.series.findFirst({
|
||||
where: { name: { equals: name, mode: "insensitive" } },
|
||||
select: { id: true, name: true, slug: true, description: true },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(existing)
|
||||
}
|
||||
|
||||
const baseSlug = slugify(name)
|
||||
let slug = baseSlug
|
||||
let counter = 1
|
||||
|
||||
while (await prisma.series.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${counter}`
|
||||
counter += 1
|
||||
}
|
||||
|
||||
const created = await prisma.series.create({
|
||||
data: { name, slug, description: description || null },
|
||||
select: { id: true, name: true, slug: true, description: true },
|
||||
})
|
||||
|
||||
return NextResponse.json(created, { status: 201 })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to create series" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const id = normalizeText(body?.id)
|
||||
const name = normalizeText(body?.name)
|
||||
const description = normalizeText(body?.description)
|
||||
|
||||
if (!id || !name) {
|
||||
return NextResponse.json({ error: "Thiếu thông tin series" }, { status: 400 })
|
||||
}
|
||||
|
||||
const target = await resolveEditableSeries(id, session as any)
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 })
|
||||
}
|
||||
|
||||
const duplicated = await prisma.series.findFirst({
|
||||
where: {
|
||||
id: { not: id },
|
||||
name: { equals: name, mode: "insensitive" },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (duplicated) {
|
||||
return NextResponse.json({ error: "Tên series đã tồn tại" }, { status: 409 })
|
||||
}
|
||||
|
||||
const baseSlug = slugify(name)
|
||||
let slug = baseSlug
|
||||
let counter = 1
|
||||
|
||||
while (await prisma.series.findFirst({ where: { slug, id: { not: id } }, select: { id: true } })) {
|
||||
slug = `${baseSlug}-${counter}`
|
||||
counter += 1
|
||||
}
|
||||
|
||||
const updated = await prisma.series.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
description: description || null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
_count: { select: { novels: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(updated)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to update series" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = normalizeText(url.searchParams.get("id"))
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Thiếu id series" }, { status: 400 })
|
||||
}
|
||||
|
||||
const target = await resolveEditableSeries(id, session as any)
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 })
|
||||
}
|
||||
|
||||
const usedCount = await prisma.novel.count({ where: { seriesId: id } })
|
||||
if (usedCount > 0) {
|
||||
return NextResponse.json({ error: "Series đang chứa truyện, không thể xóa" }, { status: 409 })
|
||||
}
|
||||
|
||||
await prisma.series.delete({ where: { id } })
|
||||
return NextResponse.json({ success: true })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to delete series" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,18 @@ export async function GET(
|
||||
}
|
||||
|
||||
try {
|
||||
const novel = await prisma.novel.findUnique({
|
||||
where: {
|
||||
const novel = await prisma.novel.findFirst({
|
||||
where: session.user.role === "ADMIN"
|
||||
? { id }
|
||||
: {
|
||||
id,
|
||||
uploaderId: session.user.id,
|
||||
OR: [
|
||||
{ uploaderId: session.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
series: true,
|
||||
genres: {
|
||||
include: {
|
||||
genre: true
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { deleteR2ObjectByUrl } from "@/lib/r2"
|
||||
|
||||
function normalizeIds(value: any): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const action = typeof body?.action === "string" ? body.action : ""
|
||||
const ids = normalizeIds(body?.ids)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ error: "Danh sách truyện trống" }, { status: 400 })
|
||||
}
|
||||
|
||||
const accessibleNovels = await prisma.novel.findMany({
|
||||
where: session.user.role === "ADMIN"
|
||||
? { id: { in: ids } }
|
||||
: {
|
||||
id: { in: ids },
|
||||
OR: [
|
||||
{ uploaderId: session.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
coverUrl: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (accessibleNovels.length === 0) {
|
||||
return NextResponse.json({ error: "Không có truyện hợp lệ để thao tác" }, { status: 404 })
|
||||
}
|
||||
|
||||
const accessibleIds = accessibleNovels.map((novel) => novel.id)
|
||||
|
||||
if (action === "delete") {
|
||||
await connectToMongoDB()
|
||||
await Chapter.deleteMany({ novelId: { $in: accessibleIds } })
|
||||
|
||||
await prisma.novel.deleteMany({
|
||||
where: { id: { in: accessibleIds } },
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
accessibleNovels.map((novel) => deleteR2ObjectByUrl(novel.coverUrl).catch(() => {}))
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, deletedCount: accessibleIds.length })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Chỉ hỗ trợ xóa hàng loạt" }, { status: 400 })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Bulk operation failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+190
-9
@@ -3,6 +3,75 @@ import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { slugify } from "@/lib/utils"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { deleteR2ObjectByUrl } from "@/lib/r2"
|
||||
|
||||
function normalizeOptionalText(value: any): string {
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
async function resolveSeriesIdForWrite(
|
||||
seriesIdInput: any,
|
||||
seriesNameInput: any,
|
||||
userRole: "USER" | "MOD" | "ADMIN",
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const seriesId = normalizeOptionalText(seriesIdInput)
|
||||
const seriesName = normalizeOptionalText(seriesNameInput)
|
||||
|
||||
if (seriesId) {
|
||||
const series = await prisma.series.findFirst({
|
||||
where: userRole === "ADMIN"
|
||||
? { id: seriesId }
|
||||
: {
|
||||
id: seriesId,
|
||||
OR: [
|
||||
{ novels: { some: { uploaderId: userId } } },
|
||||
{ novels: { some: { uploaderId: null } } },
|
||||
{ novels: { none: {} } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!series) {
|
||||
throw new Error("Series không tồn tại hoặc bạn không có quyền sử dụng")
|
||||
}
|
||||
|
||||
return series.id
|
||||
}
|
||||
|
||||
if (!seriesName) return null
|
||||
|
||||
const existingSeries = await prisma.series.findFirst({
|
||||
where: { name: { equals: seriesName, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existingSeries) {
|
||||
return existingSeries.id
|
||||
}
|
||||
|
||||
const baseSlug = slugify(seriesName)
|
||||
let slug = baseSlug
|
||||
let counter = 1
|
||||
|
||||
while (await prisma.series.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${counter}`
|
||||
counter += 1
|
||||
}
|
||||
|
||||
const createdSeries = await prisma.series.create({
|
||||
data: {
|
||||
name: seriesName,
|
||||
slug,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return createdSeries.id
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
@@ -12,7 +81,19 @@ export async function GET() {
|
||||
|
||||
try {
|
||||
const novels = await prisma.novel.findMany({
|
||||
where: { uploaderId: session.user.id },
|
||||
where: session.user.role === "ADMIN"
|
||||
? undefined
|
||||
: {
|
||||
OR: [
|
||||
{ uploaderId: session.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
series: {
|
||||
select: { id: true, name: true, slug: true }
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
return NextResponse.json(novels)
|
||||
@@ -30,6 +111,7 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
const data = await req.json()
|
||||
const { title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds = [] } = data
|
||||
const seriesId = await resolveSeriesIdForWrite(data?.seriesId, data?.seriesName, session.user.role, session.user.id)
|
||||
// Tạo slug từ title
|
||||
const slug = slugify(title)
|
||||
|
||||
@@ -42,6 +124,7 @@ export async function POST(req: Request) {
|
||||
originalAuthorName,
|
||||
description,
|
||||
coverUrl,
|
||||
seriesId,
|
||||
uploaderId: session.user.id,
|
||||
genres: {
|
||||
create: genreIds.map((id: string) => ({
|
||||
@@ -65,10 +148,76 @@ export async function PUT(req: Request) {
|
||||
try {
|
||||
const data = await req.json()
|
||||
const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data
|
||||
const targetNovel = await prisma.novel.findFirst({
|
||||
where: session.user.role === "ADMIN"
|
||||
? { id }
|
||||
: {
|
||||
id,
|
||||
OR: [
|
||||
{ uploaderId: session.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
},
|
||||
select: { id: true, seriesId: true },
|
||||
})
|
||||
|
||||
if (!targetNovel) {
|
||||
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 404 })
|
||||
}
|
||||
|
||||
// Disable editing series relation from novel edit form: keep current seriesId.
|
||||
const fixedSeriesId = targetNovel.seriesId
|
||||
|
||||
if (fixedSeriesId) {
|
||||
const seriesNovels = await prisma.novel.findMany({
|
||||
where: { seriesId: fixedSeriesId },
|
||||
select: { id: true },
|
||||
})
|
||||
const seriesNovelIds = seriesNovels.map((novel) => novel.id)
|
||||
|
||||
const updatedNovel = await prisma.$transaction(async (tx) => {
|
||||
// Sync shared metadata for all novels in the same series.
|
||||
await tx.novel.updateMany({
|
||||
where: { id: { in: seriesNovelIds } },
|
||||
data: {
|
||||
originalTitle,
|
||||
authorName,
|
||||
originalAuthorName,
|
||||
description,
|
||||
status,
|
||||
},
|
||||
})
|
||||
|
||||
if (genreIds !== undefined) {
|
||||
await tx.novelGenre.deleteMany({
|
||||
where: { novelId: { in: seriesNovelIds } },
|
||||
})
|
||||
|
||||
if (genreIds.length > 0) {
|
||||
await tx.novelGenre.createMany({
|
||||
data: seriesNovelIds.flatMap((novelId) =>
|
||||
genreIds.map((genreId: string) => ({ novelId, genreId }))
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only current novel keeps its own title and cover.
|
||||
return tx.novel.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
coverUrl,
|
||||
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return NextResponse.json(updatedNovel)
|
||||
}
|
||||
|
||||
// Update basic info and recreate genre relations
|
||||
const updatedNovel = await prisma.novel.update({
|
||||
where: { id: id, uploaderId: session.user.id }, // Make sure they own it
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
originalTitle,
|
||||
@@ -77,7 +226,8 @@ export async function PUT(req: Request) {
|
||||
description,
|
||||
coverUrl,
|
||||
status,
|
||||
// Replace all existing genres if genreIds is provided
|
||||
seriesId: fixedSeriesId,
|
||||
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
|
||||
...(genreIds !== undefined && {
|
||||
genres: {
|
||||
deleteMany: {},
|
||||
@@ -88,6 +238,7 @@ export async function PUT(req: Request) {
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(updatedNovel)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to update novel" }, { status: 500 })
|
||||
@@ -106,13 +257,43 @@ export async function DELETE(req: Request) {
|
||||
|
||||
if (!id) return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
|
||||
|
||||
// Xóa truyện. (Chapters trong MongoDB nên được xóa bằng một cron job hoặc API khác để tránh block UI quá lâu,
|
||||
// ở đây chúng ta chỉ xóa record của Postgres để ẩn truyện).
|
||||
await prisma.novel.delete({
|
||||
where: { id: id, uploaderId: session.user.id },
|
||||
const novel = await prisma.novel.findFirst({
|
||||
where: session.user.role === "ADMIN"
|
||||
? { id }
|
||||
: {
|
||||
id,
|
||||
OR: [
|
||||
{ uploaderId: session.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
},
|
||||
select: { id: true, coverUrl: true, seriesId: true }
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: "Đã xóa truyện thành công" })
|
||||
if (!novel) {
|
||||
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 404 })
|
||||
}
|
||||
|
||||
await connectToMongoDB()
|
||||
const chapterDeleteResult = await Chapter.deleteMany({ novelId: id })
|
||||
|
||||
await prisma.novel.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
await deleteR2ObjectByUrl(novel.coverUrl).catch(() => { })
|
||||
|
||||
if (novel.seriesId) {
|
||||
const remainingSeriesNovels = await prisma.novel.count({ where: { seriesId: novel.seriesId } })
|
||||
if (remainingSeriesNovels === 0) {
|
||||
await prisma.series.delete({ where: { id: novel.seriesId } }).catch(() => { })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Đã xóa truyện và toàn bộ chương thành công",
|
||||
deletedChapters: chapterDeleteResult.deletedCount || 0
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { writeFile } from "fs/promises"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { uploadBufferToR2 } from "@/lib/r2"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -26,21 +24,14 @@ export async function POST(req: Request) {
|
||||
const bytes = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9)
|
||||
const ext = path.extname(file.name) || ".jpg"
|
||||
const filename = `cover-${uniqueSuffix}${ext}`
|
||||
const url = await uploadBufferToR2({
|
||||
buffer,
|
||||
contentType: file.type,
|
||||
keyPrefix: "covers/manual",
|
||||
fileNameHint: file.name,
|
||||
})
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "public", "uploads", "covers")
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
|
||||
const filepath = path.join(uploadDir, filename)
|
||||
await writeFile(filepath, buffer)
|
||||
|
||||
return NextResponse.json({ url: `/uploads/covers/${filename}` })
|
||||
return NextResponse.json({ url })
|
||||
} catch (error: any) {
|
||||
console.error("Cover upload error:", error)
|
||||
return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 })
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function GET(
|
||||
.sort({ number: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select("number title createdAt") // don't return content
|
||||
.select("number title createdAt volumeNumber volumeTitle volumeChapterNumber") // don't return content
|
||||
.lean(),
|
||||
Chapter.countDocuments({ novelId })
|
||||
])
|
||||
@@ -32,6 +32,9 @@ export async function GET(
|
||||
id: c._id.toString(),
|
||||
number: c.number,
|
||||
title: c.title,
|
||||
volumeNumber: (c as any).volumeNumber ?? null,
|
||||
volumeTitle: (c as any).volumeTitle ?? null,
|
||||
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
|
||||
createdAt: (c.createdAt as Date).toISOString()
|
||||
})),
|
||||
totalChapters,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const q = url.searchParams.get("q")?.trim() || ""
|
||||
|
||||
if (q.length < 2) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
|
||||
const novels = await prisma.novel.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ authorName: { contains: q, mode: "insensitive" } },
|
||||
{ series: { name: { contains: q, mode: "insensitive" } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
authorName: true,
|
||||
coverUrl: true,
|
||||
series: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ views: "desc" }, { updatedAt: "desc" }],
|
||||
take: 8,
|
||||
})
|
||||
|
||||
return NextResponse.json(novels)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to fetch suggestions" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
|
||||
// Core states
|
||||
const [number, setNumber] = useState("")
|
||||
const [volumeNumber, setVolumeNumber] = useState("")
|
||||
const [volumeTitle, setVolumeTitle] = useState("")
|
||||
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalNovelId, setOriginalNovelId] = useState("")
|
||||
@@ -77,6 +80,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
const data = await res.json()
|
||||
|
||||
setNumber(data.number.toString())
|
||||
setVolumeNumber(data.volumeNumber ? String(data.volumeNumber) : "")
|
||||
setVolumeTitle(data.volumeTitle || "")
|
||||
setVolumeChapterNumber(data.volumeChapterNumber ? String(data.volumeChapterNumber) : "")
|
||||
setTitle(data.title)
|
||||
setContent(data.content)
|
||||
setOriginalNovelId(data.novelId)
|
||||
@@ -189,6 +195,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
id: chapterId,
|
||||
novelId: originalNovelId,
|
||||
number: parseInt(number),
|
||||
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
|
||||
volumeTitle: volumeTitle.trim() || null,
|
||||
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
|
||||
title,
|
||||
content
|
||||
})
|
||||
@@ -477,12 +486,26 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
|
||||
{/* Editor Workspace */}
|
||||
<div className="flex flex-col flex-1 pb-4 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 shrink-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4 mb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Quyển số</label>
|
||||
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} className="font-mono" placeholder="VD: 1" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương trong quyển</label>
|
||||
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} className="font-mono" placeholder="VD: 3" />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên quyển</label>
|
||||
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 mb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,9 @@ import * as mammoth from "mammoth"
|
||||
interface Chapter {
|
||||
_id: string
|
||||
number: number
|
||||
volumeNumber?: number | null
|
||||
volumeTitle?: string | null
|
||||
volumeChapterNumber?: number | null
|
||||
title: string
|
||||
views: number
|
||||
createdAt: string
|
||||
@@ -60,9 +63,11 @@ function ChapterManager() {
|
||||
const [openOptimize, setOpenOptimize] = useState(false)
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
|
||||
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
||||
const [optRenumber, setOptRenumber] = useState(true)
|
||||
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
||||
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
@@ -76,6 +81,9 @@ function ChapterManager() {
|
||||
|
||||
// Form states
|
||||
const [number, setNumber] = useState("")
|
||||
const [volumeNumber, setVolumeNumber] = useState("")
|
||||
const [volumeTitle, setVolumeTitle] = useState("")
|
||||
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
@@ -115,6 +123,29 @@ function ChapterManager() {
|
||||
}
|
||||
}, [novelId, currentPage])
|
||||
|
||||
const fetchAllChaptersForOptimize = async (): Promise<Chapter[]> => {
|
||||
if (!novelId) return []
|
||||
|
||||
const limit = 200
|
||||
let page = 1
|
||||
let total = 1
|
||||
const all: Chapter[] = []
|
||||
|
||||
while (page <= total) {
|
||||
const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${page}&limit=${limit}`)
|
||||
if (!res.ok) {
|
||||
throw new Error("Không thể tải toàn bộ chương để tối ưu")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
all.push(...(data.chapters || []))
|
||||
total = data.totalPages || 1
|
||||
page++
|
||||
}
|
||||
|
||||
return all.sort((a, b) => a.number - b.number)
|
||||
}
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!number || !title || !content || !novelId) {
|
||||
@@ -127,7 +158,15 @@ function ChapterManager() {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ novelId, number: parseInt(number), title, content }),
|
||||
body: JSON.stringify({
|
||||
novelId,
|
||||
number: parseInt(number),
|
||||
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
|
||||
volumeTitle: volumeTitle.trim() || null,
|
||||
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
|
||||
const resData = await res.json()
|
||||
@@ -137,6 +176,9 @@ function ChapterManager() {
|
||||
setOpenAdd(false)
|
||||
setTitle("")
|
||||
setContent("")
|
||||
setVolumeNumber("")
|
||||
setVolumeTitle("")
|
||||
setVolumeChapterNumber("")
|
||||
setNumber((parseInt(number) + 1).toString())
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
@@ -205,8 +247,25 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewOptimize = () => {
|
||||
let newChapters = [...chapters]
|
||||
const handlePreviewOptimize = async () => {
|
||||
if (!novelId) return
|
||||
|
||||
if (!optRemovePrefix && !optRenumber) {
|
||||
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingOptimizeSource(true)
|
||||
|
||||
try {
|
||||
const allChapters = await fetchAllChaptersForOptimize()
|
||||
if (allChapters.length === 0) {
|
||||
toast.info("Truyện này chưa có chương để tối ưu")
|
||||
return
|
||||
}
|
||||
|
||||
setOptimizeSourceChapters(allChapters)
|
||||
let newChapters = [...allChapters]
|
||||
|
||||
if (optRenumber) {
|
||||
newChapters.sort((a, b) => a.number - b.number)
|
||||
@@ -217,7 +276,7 @@ function ChapterManager() {
|
||||
}
|
||||
|
||||
if (optRemovePrefix) {
|
||||
newChapters = newChapters.map((ch, i) => {
|
||||
newChapters = newChapters.map((ch) => {
|
||||
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
||||
if (!newTitle) newTitle = `Chương ${ch.number}`
|
||||
return { ...ch, title: newTitle }
|
||||
@@ -226,18 +285,36 @@ function ChapterManager() {
|
||||
|
||||
setOptimizedChapters(newChapters)
|
||||
setPreviewMode(true)
|
||||
toast.success(`Đã tạo xem trước cho toàn bộ ${newChapters.length} chương`)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể tạo bản xem trước")
|
||||
} finally {
|
||||
setLoadingOptimizeSource(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyOptimize = async () => {
|
||||
if (optimizedChapters.length === 0) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const updates = optimizedChapters.map(ch => ({
|
||||
const sourceById = new Map(optimizeSourceChapters.map((ch) => [ch._id, ch]))
|
||||
const updates = optimizedChapters
|
||||
.filter((ch) => {
|
||||
const old = sourceById.get(ch._id)
|
||||
return !!old && (old.number !== ch.number || old.title !== ch.title)
|
||||
})
|
||||
.map((ch) => ({
|
||||
id: ch._id,
|
||||
title: ch.title,
|
||||
number: ch.number
|
||||
}))
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info("Không có thay đổi nào cần lưu")
|
||||
setOptimizing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch("/api/mod/chuong/optimize", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -247,9 +324,11 @@ function ChapterManager() {
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa")
|
||||
|
||||
toast.success(`Đã tổi ưu ${data.modifiedCount} chương!`)
|
||||
toast.success(`Đã tối ưu ${data.modifiedCount} chương trên toàn bộ truyện!`)
|
||||
setOpenOptimize(false)
|
||||
setPreviewMode(false)
|
||||
setOptimizedChapters([])
|
||||
setOptimizeSourceChapters([])
|
||||
fetchChapters(currentPage)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
@@ -298,6 +377,8 @@ function ChapterManager() {
|
||||
)
|
||||
}
|
||||
|
||||
const optimizeSourceMap = new Map(optimizeSourceChapters.map((source) => [source._id, source]))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
|
||||
@@ -310,6 +391,8 @@ function ChapterManager() {
|
||||
<Button variant="secondary" className="gap-2" onClick={() => {
|
||||
setOpenOptimize(true)
|
||||
setPreviewMode(false)
|
||||
setOptimizedChapters([])
|
||||
setOptimizeSourceChapters([])
|
||||
}} disabled={chapters.length === 0}>
|
||||
<Wand2 className="h-4 w-4" /> Tối ưu hóa
|
||||
</Button>
|
||||
@@ -341,12 +424,26 @@ function ChapterManager() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Quyển số</label>
|
||||
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương trong quyển</label>
|
||||
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} placeholder="VD: 5" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên quyển (Tuỳ chọn)</label>
|
||||
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-4">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
|
||||
</div>
|
||||
@@ -389,12 +486,23 @@ function ChapterManager() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openOptimize} onOpenChange={setOpenOptimize}>
|
||||
<Dialog
|
||||
open={openOptimize}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpenOptimize(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setPreviewMode(false)
|
||||
setOptimizedChapters([])
|
||||
setOptimizeSourceChapters([])
|
||||
setLoadingOptimizeSource(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tối Ưu Hóa Chương Hàng Loạt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Công cụ dọn dẹp tên chương và đánh lại số thứ tự tự động tiện lợi sau khi đăng ép từ tệp EPUB.
|
||||
Công cụ sẽ áp dụng trên toàn bộ chương của truyện hiện tại, không chỉ page bạn đang xem.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -425,8 +533,8 @@ function ChapterManager() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{optimizedChapters.map((newCh, i) => {
|
||||
const oldCh = chapters[i]
|
||||
{optimizedChapters.map((newCh) => {
|
||||
const oldCh = optimizeSourceMap.get(newCh._id) || newCh
|
||||
return (
|
||||
<tr key={newCh._id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-3 border-r text-muted-foreground">
|
||||
@@ -448,10 +556,13 @@ function ChapterManager() {
|
||||
<DialogFooter className="mt-auto pt-2">
|
||||
<Button variant="outline" onClick={() => setOpenOptimize(false)}>Hủy bỏ</Button>
|
||||
{!previewMode ? (
|
||||
<Button onClick={handlePreviewOptimize}>Kiểm tra trước</Button>
|
||||
<Button onClick={handlePreviewOptimize} disabled={loadingOptimizeSource}>
|
||||
{loadingOptimizeSource && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Kiểm tra toàn bộ truyện
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing}>Quay lại Option</Button>
|
||||
<Button variant="secondary" onClick={() => setPreviewMode(false)} disabled={optimizing || loadingOptimizeSource}>Quay lại Option</Button>
|
||||
<Button onClick={handleApplyOptimize} disabled={optimizing}>
|
||||
{optimizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi vào DB
|
||||
@@ -471,6 +582,7 @@ function ChapterManager() {
|
||||
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th scope="col" className="px-5 py-4 font-semibold w-24">Chương</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Quyển</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Tên chương</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold text-right">Lượt đọc</th>
|
||||
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao tác</th>
|
||||
@@ -478,13 +590,18 @@ function ChapterManager() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
|
||||
) : chapters.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground">Chưa có chương nào được đăng.</td></tr>
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa có chương nào được đăng.</td></tr>
|
||||
) : (
|
||||
chapters.map((ch) => (
|
||||
<tr key={ch._id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0">
|
||||
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">
|
||||
{ch.volumeNumber || ch.volumeTitle
|
||||
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
|
||||
<td className="px-5 py-4 text-right">{ch.views}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
|
||||
+1
-4
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Users, List, Home } from "lucide-react"
|
||||
import { BookOpen, Home } from "lucide-react"
|
||||
|
||||
export default async function ModLayout({
|
||||
children,
|
||||
@@ -28,9 +28,6 @@ export default async function ModLayout({
|
||||
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
|
||||
<BookOpen className="h-4 w-4" /> Quản lý truyện
|
||||
</Link>
|
||||
<Link href="/mod/chuong" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
|
||||
<List className="h-4 w-4" /> Quản lý chương
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
+45
-5
@@ -1,9 +1,45 @@
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export default async function ModDashboardPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const novelWhere = session?.user.role === "ADMIN"
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ uploaderId: session?.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
}
|
||||
|
||||
const [novelCount, novelViewsAgg, commentCount, seriesCount] = await Promise.all([
|
||||
prisma.novel.count({ where: novelWhere }),
|
||||
prisma.novel.aggregate({
|
||||
where: novelWhere,
|
||||
_sum: { views: true },
|
||||
}),
|
||||
prisma.comment.count({
|
||||
where: {
|
||||
novel: novelWhere,
|
||||
},
|
||||
}),
|
||||
prisma.series.count({
|
||||
where: session?.user.role === "ADMIN"
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ novels: { some: { uploaderId: session?.user.id } } },
|
||||
{ novels: { some: { uploaderId: null } } },
|
||||
{ novels: { none: {} } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const totalViews = novelViewsAgg._sum.views || 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1>
|
||||
@@ -11,18 +47,22 @@ export default async function ModDashboardPage() {
|
||||
Chào mừng bạn đến với trang quản trị dành cho Moderator.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Truyện của bạn</h3>
|
||||
<p className="text-3xl font-bold mt-2">0</p>
|
||||
<h3 className="font-semibold text-lg">Tổng truyện</h3>
|
||||
<p className="text-3xl font-bold mt-2">{novelCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Tổng lượt xem</h3>
|
||||
<p className="text-3xl font-bold mt-2">0</p>
|
||||
<p className="text-3xl font-bold mt-2">{totalViews}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Bình luận mới</h3>
|
||||
<p className="text-3xl font-bold mt-2">0</p>
|
||||
<p className="text-3xl font-bold mt-2">{commentCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||
<h3 className="font-semibold text-lg">Tổng series</h3>
|
||||
<p className="text-3xl font-bold mt-2">{seriesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { SeriesClient } from "./series-client"
|
||||
|
||||
export default async function ModSeriesPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return <SeriesClient />
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Layers, Loader2, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface SeriesItem {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description?: string | null
|
||||
_count?: {
|
||||
novels: number
|
||||
}
|
||||
}
|
||||
|
||||
export function SeriesClient() {
|
||||
const [series, setSeries] = useState<SeriesItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
|
||||
const filteredSeries = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase()
|
||||
if (!q) return series
|
||||
return series.filter((item) => {
|
||||
return (
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.slug.toLowerCase().includes(q) ||
|
||||
(item.description || "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [keyword, series])
|
||||
|
||||
const fetchSeries = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/mod/series")
|
||||
if (!res.ok) throw new Error("Không thể tải danh sách series")
|
||||
const data = await res.json()
|
||||
setSeries(data)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể tải danh sách series")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSeries()
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null)
|
||||
setName("")
|
||||
setDescription("")
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error("Vui lòng nhập tên series")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
id: editingId,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
}
|
||||
|
||||
const res = await fetch("/api/mod/series", {
|
||||
method: editingId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Không thể lưu series")
|
||||
}
|
||||
|
||||
toast.success(editingId ? "Đã cập nhật series" : "Đã tạo series")
|
||||
resetForm()
|
||||
fetchSeries()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể lưu series")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: SeriesItem) => {
|
||||
setEditingId(item.id)
|
||||
setName(item.name)
|
||||
setDescription(item.description || "")
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Bạn chắc chắn muốn xóa series này?")) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/mod/series?id=${id}`, { method: "DELETE" })
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Không thể xóa series")
|
||||
}
|
||||
|
||||
toast.success("Đã xóa series")
|
||||
if (editingId === id) resetForm()
|
||||
fetchSeries()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Không thể xóa series")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card rounded-xl border shadow-sm p-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Layers className="h-6 w-6 text-primary" /> Quản lý series
|
||||
</h1>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="Tìm series..."
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div className="xl:col-span-1 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<h2 className="text-base font-semibold mb-3">{editingId ? "Chỉnh sửa series" : "Tạo series mới"}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tên series</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ví dụ: Overlord" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Mô tả</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Mô tả ngắn về series"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={submitting} className="gap-2">
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{editingId ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{editingId ? "Lưu" : "Tạo"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button type="button" variant="outline" onClick={resetForm} className="gap-2">
|
||||
<X className="h-4 w-4" /> Hủy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Tên</th>
|
||||
<th className="px-4 py-3 font-semibold">Slug</th>
|
||||
<th className="px-4 py-3 font-semibold">Số truyện</th>
|
||||
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredSeries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
Chưa có series nào
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredSeries.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{item.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{item.slug}</td>
|
||||
<td className="px-4 py-3">{item._count?.novels ?? 0}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground max-w-sm truncate">{item.description || "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="icon" variant="outline" className="h-8 w-8" onClick={() => handleEdit(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+964
-43
File diff suppressed because it is too large
Load Diff
+68
-6
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
@@ -19,6 +20,24 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
||||
const pickedSeries = new Set<string>()
|
||||
const output: T[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.seriesId) {
|
||||
output.push(row)
|
||||
continue
|
||||
}
|
||||
|
||||
if (pickedSeries.has(row.seriesId)) continue
|
||||
pickedSeries.add(row.seriesId)
|
||||
output.push(row)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
let popularNovels: any[] = []
|
||||
let latestNovels: any[] = []
|
||||
@@ -28,19 +47,60 @@ export default async function HomePage() {
|
||||
|
||||
try {
|
||||
popularNovels = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverColor: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
views: true,
|
||||
totalChapters: true,
|
||||
status: true,
|
||||
description: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: { views: "desc" },
|
||||
})
|
||||
popularNovels = collapseSeriesRows(popularNovels).slice(0, 20)
|
||||
|
||||
latestNovels = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverColor: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
views: true,
|
||||
totalChapters: true,
|
||||
status: true,
|
||||
description: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
latestNovels = collapseSeriesRows(latestNovels).slice(0, 20)
|
||||
|
||||
topRated = await prisma.novel.findMany({
|
||||
take: 4,
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
totalChapters: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
topRated = collapseSeriesRows(topRated).slice(0, 4)
|
||||
|
||||
genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
@@ -60,7 +120,7 @@ export default async function HomePage() {
|
||||
href={`/truyen/${featured.slug}`}
|
||||
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row"
|
||||
>
|
||||
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full object-cover md:h-auto md:w-72" />
|
||||
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full bg-muted object-contain md:h-auto md:w-72" />
|
||||
<div className="flex flex-1 flex-col justify-center gap-3 p-6">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-primary">Truyện Nổi Bật</span>
|
||||
<h1 title={featured.title} className="text-2xl font-bold text-foreground group-hover:text-primary transition-colors text-balance md:text-3xl">
|
||||
@@ -72,7 +132,9 @@ export default async function HomePage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{featured.totalChapters} chương</span>
|
||||
<span>{featured.status}</span>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getNovelStatusBadgeClass(featured.status)}`}>
|
||||
{featured.status}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{featured.rating}
|
||||
@@ -128,7 +190,7 @@ export default async function HomePage() {
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded object-cover" />
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded bg-muted object-contain" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 title={novel.title} className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{novel.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName} - Ch. {novel.totalChapters}</p>
|
||||
|
||||
@@ -6,6 +6,24 @@ import { notFound } from "next/navigation"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
||||
const pickedSeries = new Set<string>()
|
||||
const output: T[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.seriesId) {
|
||||
output.push(row)
|
||||
continue
|
||||
}
|
||||
|
||||
if (pickedSeries.has(row.seriesId)) continue
|
||||
pickedSeries.add(row.seriesId)
|
||||
output.push(row)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
|
||||
@@ -17,7 +35,7 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
|
||||
notFound()
|
||||
}
|
||||
|
||||
const allNovels = await prisma.novel.findMany({
|
||||
const allNovelsRaw = await prisma.novel.findMany({
|
||||
where: {
|
||||
genres: {
|
||||
some: {
|
||||
@@ -25,12 +43,27 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
coverColor: true,
|
||||
coverUrl: true,
|
||||
rating: true,
|
||||
views: true,
|
||||
totalChapters: true,
|
||||
status: true,
|
||||
seriesId: true,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc"
|
||||
},
|
||||
take: 20
|
||||
take: 80
|
||||
})
|
||||
|
||||
const allNovels = collapseSeriesRows(allNovelsRaw).slice(0, 20)
|
||||
|
||||
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
|
||||
+32
-3
@@ -1,12 +1,29 @@
|
||||
// Server component instead of client component
|
||||
import { Search } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
||||
const pickedSeries = new Set<string>()
|
||||
const output: T[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.seriesId) {
|
||||
output.push(row)
|
||||
continue
|
||||
}
|
||||
|
||||
if (pickedSeries.has(row.seriesId)) continue
|
||||
pickedSeries.add(row.seriesId)
|
||||
output.push(row)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -25,6 +42,7 @@ export default async function SearchPage({
|
||||
where.OR = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ authorName: { contains: q, mode: "insensitive" } },
|
||||
{ series: { name: { contains: q, mode: "insensitive" } } },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,12 +77,23 @@ export default async function SearchPage({
|
||||
orderBy = { updatedAt: "desc" }
|
||||
}
|
||||
|
||||
const filteredNovels = await prisma.novel.findMany({
|
||||
const filteredNovelsRaw = await prisma.novel.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: 20,
|
||||
include: {
|
||||
series: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 80,
|
||||
})
|
||||
|
||||
const filteredNovels = q ? filteredNovelsRaw.slice(0, 20) : collapseSeriesRows(filteredNovelsRaw).slice(0, 20)
|
||||
|
||||
const genres = await prisma.genre.findMany()
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,9 +62,11 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
|
||||
// Extract paragraphs for TTS
|
||||
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
|
||||
const chapterLabel = (chapter as any).volumeChapterNumber ? `Chương ${(chapter as any).volumeChapterNumber}` : `Chương ${chapter.number}`
|
||||
const volumeLabel = (chapter as any).volumeTitle || ((chapter as any).volumeNumber ? `Quyển ${(chapter as any).volumeNumber}` : null)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl lg:max-w-screen-lg px-4 py-6 md:px-8">
|
||||
<div className="mx-auto max-w-4xl px-3 py-4 md:px-8 md:py-6 lg:max-w-screen-lg">
|
||||
{/* Top navigation */}
|
||||
<div className="mb-6 flex flex-col gap-3">
|
||||
<Link href={`/truyen/${slug}`} className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
@@ -73,20 +75,26 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-lg font-bold text-foreground md:text-xl lg:text-2xl">
|
||||
Chương {chapter.number}: {chapter.title}
|
||||
{volumeLabel ? `${volumeLabel} - ` : ""}{chapterLabel}: {chapter.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter navigation top */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="mb-6 flex items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
||||
{hasPrev ? (
|
||||
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" /> Ch. trước
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Ch. trước</span>
|
||||
<span className="sm:hidden">Trước</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span><ChevronLeft className="mr-1 h-4 w-4" /> Ch. trước</span>
|
||||
<span>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Ch. trước</span>
|
||||
<span className="sm:hidden">Trước</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
@@ -97,16 +105,22 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
||||
{hasNext ? (
|
||||
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
|
||||
Ch. sau <ChevronRight className="ml-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Ch. sau</span>
|
||||
<span className="sm:hidden">Sau</span>
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<span>Ch. sau <ChevronRight className="ml-1 h-4 w-4" /></span>
|
||||
<span>
|
||||
<span className="hidden sm:inline">Ch. sau</span>
|
||||
<span className="sm:hidden">Sau</span>
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Chapter content */}
|
||||
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-6 font-serif text-foreground/90 md:p-8 lg:p-12 text-justify">
|
||||
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-4 font-serif text-foreground/90 text-justify md:p-8 lg:p-12">
|
||||
{paragraphs.map((text: string, idx: number) => (
|
||||
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
|
||||
{text}
|
||||
@@ -115,23 +129,35 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
</article>
|
||||
|
||||
{/* Chapter navigation bottom */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="mb-8 flex items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
||||
{hasPrev ? (
|
||||
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" /> Chương trước
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Chương trước</span>
|
||||
<span className="sm:hidden">Trước</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span><ChevronLeft className="mr-1 h-4 w-4" /> Chương trước</span>
|
||||
<span>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Chương trước</span>
|
||||
<span className="sm:hidden">Trước</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
||||
{hasNext ? (
|
||||
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
|
||||
Chương sau <ChevronRight className="ml-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Chương sau</span>
|
||||
<span className="sm:hidden">Sau</span>
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<span>Chương sau <ChevronRight className="ml-1 h-4 w-4" /></span>
|
||||
<span>
|
||||
<span className="hidden sm:inline">Chương sau</span>
|
||||
<span className="sm:hidden">Sau</span>
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -151,7 +177,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
paragraphs={paragraphs}
|
||||
currentChapter={chapterNumber}
|
||||
maxChapter={maxChapter}
|
||||
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
|
||||
chapterTitle={`${volumeLabel ? `${volumeLabel} - ` : ""}${chapterLabel}: ${chapter.title}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+84
-18
@@ -10,6 +10,7 @@ import { NovelDetailActions } from "./novel-detail-actions"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -39,32 +40,72 @@ export default async function NovelDetailPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Fetch chapters from MongoDB
|
||||
await connectToMongoDB()
|
||||
const skip = (currentPage - 1) * limit
|
||||
let formattedChapters: any[] = []
|
||||
let totalChapters = 0
|
||||
let totalPages = 1
|
||||
let firstChapterNumber: number | undefined
|
||||
let seriesVolumes: Array<{
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
status: string
|
||||
totalChapters: number
|
||||
coverUrl: string | null
|
||||
updatedAt: Date
|
||||
}> = []
|
||||
|
||||
const [chapters, totalChapters] = await Promise.all([
|
||||
await connectToMongoDB()
|
||||
|
||||
if (novel.seriesId) {
|
||||
const [firstChapter, volumes] = await Promise.all([
|
||||
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
|
||||
prisma.novel.findMany({
|
||||
where: { seriesId: novel.seriesId },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
status: true,
|
||||
totalChapters: true,
|
||||
coverUrl: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
])
|
||||
|
||||
firstChapterNumber = (firstChapter as any)?.number
|
||||
seriesVolumes = volumes
|
||||
} else {
|
||||
const skip = (currentPage - 1) * limit
|
||||
const [chapters, chaptersCount, firstChapter] = await Promise.all([
|
||||
Chapter.find({ novelId: novel.id })
|
||||
.sort({ number: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select("id novelId number title createdAt views")
|
||||
.select("id novelId number title createdAt views volumeNumber volumeTitle volumeChapterNumber")
|
||||
.lean(),
|
||||
Chapter.countDocuments({ novelId: novel.id })
|
||||
Chapter.countDocuments({ novelId: novel.id }),
|
||||
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(totalChapters / limit)
|
||||
totalChapters = chaptersCount
|
||||
totalPages = Math.ceil(totalChapters / limit)
|
||||
firstChapterNumber = (firstChapter as any)?.number
|
||||
|
||||
// Convert Mongoose documents to plain objects for Server Component
|
||||
const formattedChapters = chapters.map(c => ({
|
||||
formattedChapters = chapters.map(c => ({
|
||||
id: c._id.toString(),
|
||||
novelId: c.novelId,
|
||||
number: c.number,
|
||||
volumeNumber: (c as any).volumeNumber ?? null,
|
||||
volumeTitle: (c as any).volumeTitle ?? null,
|
||||
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
|
||||
title: c.title,
|
||||
createdAt: (c.createdAt as Date).toISOString(),
|
||||
views: c.views || 0,
|
||||
content: "" // We don't fetch content for the list
|
||||
content: ""
|
||||
}))
|
||||
}
|
||||
|
||||
const commentsData = await prisma.comment.findMany({
|
||||
where: { novelId: novel.id, chapterId: null },
|
||||
@@ -107,7 +148,7 @@ export default async function NovelDetailPage({
|
||||
{/* Novel Header */}
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
{/* Cover */}
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-64 w-44 shrink-0 self-center rounded-xl object-cover shadow-lg md:self-start bg-muted" />
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-64 w-44 shrink-0 self-center rounded-xl bg-muted object-contain shadow-lg md:self-start" />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
@@ -132,11 +173,7 @@ export default async function NovelDetailPage({
|
||||
<div className="flex flex-col gap-3 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Trạng thái:</span>
|
||||
<span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${
|
||||
novel.status === "Hoàn thành" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
novel.status === "Tạm dừng" || novel.status === "Tạm ngưng" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" :
|
||||
"bg-primary/10 text-primary" // Đang ra
|
||||
}`}>
|
||||
<span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
|
||||
{novel.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -177,7 +214,7 @@ export default async function NovelDetailPage({
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
|
||||
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={firstChapterNumber} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +225,35 @@ export default async function NovelDetailPage({
|
||||
<div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description}</div>
|
||||
</section>
|
||||
|
||||
{/* Chapter list */}
|
||||
{/* Chapter list or series volumes */}
|
||||
{novel.seriesId ? (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Quyển</h2>
|
||||
<div className="rounded-lg border border-border bg-card divide-y divide-border">
|
||||
{seriesVolumes.map((volume, idx) => (
|
||||
<Link
|
||||
key={volume.id}
|
||||
href={`/truyen/${volume.slug}`}
|
||||
className={`flex items-center gap-4 px-4 py-3 hover:bg-muted/40 transition-colors ${volume.id === novel.id ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
<span className="w-8 text-center text-sm font-semibold text-muted-foreground">{idx + 1}</span>
|
||||
<img
|
||||
src={volume.coverUrl || "/default-cover.svg"}
|
||||
alt={volume.title}
|
||||
className="h-14 w-10 rounded bg-muted object-contain"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground truncate">{volume.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{volume.totalChapters} chương</p>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(volume.status)}`}>
|
||||
{volume.status}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2>
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
@@ -201,6 +266,7 @@ export default async function NovelDetailPage({
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
<section className="mt-8">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BookOpen, BookMarked, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { useBookmarks } from "@/lib/bookmark-context"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
|
||||
|
||||
export default function BookshelfPage() {
|
||||
@@ -55,13 +56,18 @@ export default function BookshelfPage() {
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/20"
|
||||
>
|
||||
<Link href={`/truyen/${novel.slug}`}>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-16 w-12 shrink-0 rounded-md object-cover hover:opacity-90" />
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-16 w-12 shrink-0 rounded-md bg-muted object-contain hover:opacity-90" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<Link title={novel.title} href={`/truyen/${novel.slug}`} className="truncate text-sm font-semibold text-foreground hover:text-primary transition-colors">
|
||||
{novel.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
<div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
|
||||
{novel.status}
|
||||
</span>
|
||||
</div>
|
||||
{bookmark.lastChapterNumber && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
|
||||
|
||||
@@ -8,6 +8,9 @@ interface ChapterListProps {
|
||||
id: string
|
||||
novelId: string
|
||||
number: number
|
||||
volumeNumber?: number | null
|
||||
volumeTitle?: string | null
|
||||
volumeChapterNumber?: number | null
|
||||
title: string
|
||||
createdAt: string
|
||||
views: number
|
||||
@@ -31,17 +34,51 @@ const generatePagination = (currentPage: number, totalPages: number) => {
|
||||
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages]
|
||||
}
|
||||
|
||||
const generateMobilePagination = (currentPage: number, totalPages: number) => {
|
||||
if (totalPages <= 3) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
if (currentPage <= 1) {
|
||||
return [1, 2, '...']
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages) {
|
||||
return ['...', totalPages - 1, totalPages]
|
||||
}
|
||||
|
||||
return [currentPage - 1, currentPage, currentPage + 1]
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters, novelSlug, currentPage, totalPages, totalChapters }: ChapterListProps) {
|
||||
let lastVolumeKey: string | null = null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{chapters.map((chapter) => (
|
||||
{chapters.map((chapter) => {
|
||||
const currentVolumeKey = chapter.volumeNumber || chapter.volumeTitle
|
||||
? `${chapter.volumeNumber ?? "no-num"}-${chapter.volumeTitle ?? "no-title"}`
|
||||
: null
|
||||
const showVolumeHeader = currentVolumeKey !== null && currentVolumeKey !== lastVolumeKey
|
||||
if (currentVolumeKey !== null) {
|
||||
lastVolumeKey = currentVolumeKey
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={chapter.id}>
|
||||
{showVolumeHeader && (
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
{chapter.volumeTitle || `Quyển ${chapter.volumeNumber}`}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
key={chapter.id}
|
||||
href={`/truyen/${novelSlug}/${chapter.number}`}
|
||||
className="flex items-center justify-between border-b border-border px-2 py-3 text-sm transition-colors hover:bg-muted/50 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0 font-medium text-muted-foreground">Ch. {chapter.number}</span>
|
||||
<span className="shrink-0 font-medium text-muted-foreground">
|
||||
{chapter.volumeChapterNumber ? `Ch. ${chapter.volumeChapterNumber}` : `Ch. ${chapter.number}`}
|
||||
</span>
|
||||
<span className="truncate text-foreground">{chapter.title}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||
@@ -52,7 +89,9 @@ export function ChapterList({ chapters, novelSlug, currentPage, totalPages, tota
|
||||
<span>{chapter.createdAt}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border p-4 bg-muted/10">
|
||||
@@ -68,6 +107,23 @@ export function ChapterList({ chapters, novelSlug, currentPage, totalPages, tota
|
||||
Trước
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
{generateMobilePagination(currentPage, totalPages).map((p, i) => (
|
||||
<div key={`mobile-${i}`}>
|
||||
{p === '...' ? (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/truyen/${novelSlug}?page=${p}`}
|
||||
className={`inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-md border text-sm font-medium shadow-sm transition-colors ${currentPage === p ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : 'bg-background border-input hover:bg-accent hover:text-accent-foreground'}`}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{generatePagination(currentPage, totalPages).map((p, i) => (
|
||||
<div key={i} className="hidden sm:block">
|
||||
{p === '...' ? (
|
||||
|
||||
+96
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { BookOpen, Menu, X, Search, User as UserIcon, LogOut, BookMarked, Shield } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { BookOpen, Menu, Search, LogOut, BookMarked, Shield } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"
|
||||
@@ -17,18 +17,76 @@ const navLinks = [
|
||||
{ label: "Danh Sách", href: "/tim-kiem" },
|
||||
]
|
||||
|
||||
type SearchSuggestion = {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
authorName: string
|
||||
coverUrl?: string | null
|
||||
series?: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
function roleLabel(role?: "USER" | "MOD" | "ADMIN") {
|
||||
if (role === "ADMIN") return "Quản trị viên"
|
||||
if (role === "MOD") return "Kiểm duyệt viên"
|
||||
return "Thành viên"
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { user, logout } = useAuth()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim()
|
||||
if (query.length < 2) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/truyen/suggest?q=${encodeURIComponent(query)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSuggestions(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
const goToSuggestion = (slug: string) => {
|
||||
router.push(`/truyen/${slug}`)
|
||||
setSearchQuery("")
|
||||
setSuggestions([])
|
||||
setIsSearchFocused(false)
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/tim-kiem?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
setSearchQuery("")
|
||||
setSuggestions([])
|
||||
setIsSearchFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +126,41 @@ export function Header() {
|
||||
className="h-9 pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setTimeout(() => setIsSearchFocused(false), 120)}
|
||||
/>
|
||||
|
||||
{isSearchFocused && searchQuery.trim().length >= 2 && (
|
||||
<div className="absolute top-[calc(100%+6px)] z-50 w-full overflow-hidden rounded-md border border-border bg-popover shadow-lg">
|
||||
{suggestions.length > 0 ? (
|
||||
suggestions.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => goToSuggestion(item.slug)}
|
||||
className="flex w-full items-center gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-muted/40"
|
||||
>
|
||||
<img
|
||||
src={item.coverUrl || "/default-cover.svg"}
|
||||
alt={item.title}
|
||||
className="h-12 w-9 rounded-sm bg-muted object-contain"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{item.authorName}</p>
|
||||
</div>
|
||||
{item.series?.name && (
|
||||
<span className="max-w-[120px] truncate text-[11px] font-medium text-primary">
|
||||
{item.series.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Không tìm thấy kết quả phù hợp.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -93,6 +185,7 @@ export function Header() {
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-sm font-medium text-foreground">{user.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{(user.role === "MOD" || user.role === "ADMIN") && (
|
||||
@@ -208,6 +301,7 @@ export function Header() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{user.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { logout(); setOpen(false) }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Eye, Star } from "lucide-react"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
|
||||
export interface CardNovel {
|
||||
id: string
|
||||
@@ -27,14 +28,19 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group flex gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-accent/50"
|
||||
>
|
||||
<div className="relative h-16 w-12 shrink-0 rounded overflow-hidden">
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
|
||||
<div className="relative h-16 w-12 shrink-0 overflow-hidden rounded bg-muted">
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<h3 title={novel.title} className="line-clamp-2 text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{novel.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
<div className="mt-1">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
|
||||
{novel.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="h-3 w-3 fill-primary text-primary" />
|
||||
@@ -52,14 +58,11 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="relative h-44 w-full">
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
{novel.status === "Đang ra" && (
|
||||
<span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
Đang ra
|
||||
<div className="relative h-44 w-full bg-muted">
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-contain" />
|
||||
<span className={`absolute right-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
|
||||
{novel.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 title={novel.title} className="line-clamp-2 h-10 text-sm leading-tight font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
|
||||
+45
-18
@@ -1,11 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { List, Settings2, Headphones, X, Settings, Menu, ArrowUp } from "lucide-react"
|
||||
import { Settings2, Headphones, X, Menu, ArrowUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { ReadingSettingsContent } from "./reading-settings"
|
||||
import { TTSPlayer } from "./tts-player"
|
||||
import { ReaderTOC } from "./reader-toc"
|
||||
@@ -25,14 +24,41 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
|
||||
const [isTTSOpen, setIsTTSOpen] = useState(false)
|
||||
const [isTTSExpanded, setIsTTSExpanded] = useState(false)
|
||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||
const [isMobileControlsVisible, setIsMobileControlsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let lastScrollY = window.scrollY
|
||||
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 400)
|
||||
const currentY = window.scrollY
|
||||
setShowScrollTop(currentY > 400)
|
||||
|
||||
const isMobile = window.innerWidth < 768
|
||||
if (!isMobile) {
|
||||
setIsMobileControlsVisible(true)
|
||||
lastScrollY = currentY
|
||||
return
|
||||
}
|
||||
|
||||
const delta = currentY - lastScrollY
|
||||
if (Math.abs(delta) < 12) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentY < 120) {
|
||||
setIsMobileControlsVisible(true)
|
||||
} else if (delta > 0 && !isOpen && !isTTSOpen) {
|
||||
setIsMobileControlsVisible(false)
|
||||
} else if (delta < 0) {
|
||||
setIsMobileControlsVisible(true)
|
||||
}
|
||||
|
||||
lastScrollY = currentY
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => window.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
}, [isOpen, isTTSOpen])
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
@@ -46,16 +72,17 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
"fixed right-6 z-50 flex flex-col items-center gap-3 transition-all duration-300",
|
||||
isTTSOpen ? (isTTSExpanded ? "bottom-[12rem]" : "bottom-24") : "bottom-6"
|
||||
"fixed right-3 z-50 flex flex-col items-center gap-2.5 transition-all duration-300 md:right-6 md:gap-3",
|
||||
isTTSOpen ? (isTTSExpanded ? "bottom-[10.5rem] md:bottom-[12rem]" : "bottom-[4.75rem] md:bottom-24") : "bottom-3 md:bottom-6",
|
||||
isMobileControlsVisible ? "max-md:translate-y-0 max-md:opacity-100" : "max-md:translate-y-20 max-md:opacity-0 max-md:pointer-events-none"
|
||||
)}>
|
||||
{/* Main FAB Toggle (Mobile mostly, but works as container) */}
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full shadow-lg md:hidden"
|
||||
className="h-11 w-11 rounded-full shadow-lg md:hidden"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Action Items */}
|
||||
@@ -69,14 +96,14 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
|
||||
<Button
|
||||
variant={isTTSOpen ? "default" : "secondary"}
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-md relative group"
|
||||
className="h-10 w-10 rounded-full shadow-md relative group md:h-12 md:w-12"
|
||||
onClick={() => {
|
||||
setIsTTSOpen(!isTTSOpen)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<Headphones className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Headphones className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
|
||||
{isTTSOpen ? "Đóng Audio" : "Nghe Audio"}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -94,15 +121,15 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-md relative group"
|
||||
className="h-10 w-10 rounded-full shadow-md relative group md:h-12 md:w-12"
|
||||
>
|
||||
<Settings2 className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Settings2 className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
|
||||
Tùy chỉnh
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 mb-2 mr-4 flex" align="end" side="left">
|
||||
<PopoverContent className="mb-2 mr-2 flex w-64 md:mr-4" align="end" side="left">
|
||||
<ReadingSettingsContent
|
||||
fontSize={fontSize} setFontSize={setFontSize}
|
||||
lineHeight={lineHeight} setLineHeight={setLineHeight}
|
||||
@@ -116,13 +143,13 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-full shadow-md relative group transition-all duration-300",
|
||||
"h-10 w-10 rounded-full shadow-md relative group transition-all duration-300 md:h-12 md:w-12",
|
||||
showScrollTop ? "opacity-100 scale-100" : "opacity-0 scale-0 pointer-events-none"
|
||||
)}
|
||||
onClick={handleScrollToTop}
|
||||
>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<ArrowUp className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
|
||||
Lên đầu trang
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,9 @@ interface TOCChapter {
|
||||
id: string
|
||||
number: number
|
||||
title: string
|
||||
volumeNumber?: number | null
|
||||
volumeTitle?: string | null
|
||||
volumeChapterNumber?: number | null
|
||||
}
|
||||
|
||||
export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTOCProps) {
|
||||
@@ -76,10 +79,10 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-md relative group"
|
||||
className="relative h-10 w-10 rounded-full shadow-md group md:h-12 md:w-12"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<List className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
|
||||
Mục lục
|
||||
</span>
|
||||
</Button>
|
||||
@@ -97,11 +100,26 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
chapters.map((chap) => {
|
||||
(() => {
|
||||
let lastVolumeKey: string | null = null
|
||||
return chapters.map((chap) => {
|
||||
const isActive = chap.number === currentChapterNumber
|
||||
const volumeKey = chap.volumeNumber || chap.volumeTitle
|
||||
? `${chap.volumeNumber ?? "no-num"}-${chap.volumeTitle ?? "no-title"}`
|
||||
: null
|
||||
const showVolumeHeader = volumeKey !== null && volumeKey !== lastVolumeKey
|
||||
if (volumeKey !== null) {
|
||||
lastVolumeKey = volumeKey
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={chap.id}>
|
||||
{showVolumeHeader && (
|
||||
<div className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
{chap.volumeTitle || `Quyển ${chap.volumeNumber}`}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
key={chap.id}
|
||||
id={`toc-chap-${chap.number}`}
|
||||
href={`/truyen/${novelSlug}/${chap.number}`}
|
||||
onClick={() => setIsOpen(false)}
|
||||
@@ -112,12 +130,14 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
|
||||
}`}
|
||||
>
|
||||
<span className={isActive ? "text-primary-foreground/90 font-bold mr-2 lg:mr-3" : "text-muted-foreground mr-2 lg:mr-3"}>
|
||||
{chap.number}.
|
||||
{chap.volumeChapterNumber || chap.number}.
|
||||
</span>
|
||||
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -342,7 +342,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
return (
|
||||
<>
|
||||
{/* Floating TTS bar */}
|
||||
<div className={cn("fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 backdrop-blur-md shadow-[0_-4px_6px_-1px_rgb(0,0,0,0.1)] transition-transform duration-300", !isOpen && "translate-y-full")}>
|
||||
<div className={cn("fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur-md shadow-[0_-4px_6px_-1px_rgb(0,0,0,0.1)] transition-transform duration-300", !isOpen && "translate-y-full")}>
|
||||
{/* Progress bar */}
|
||||
<div className="h-0.5 w-full bg-muted">
|
||||
<div
|
||||
@@ -351,25 +351,25 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl px-4 py-2">
|
||||
<div className="mx-auto max-w-3xl px-2 py-1.5 sm:px-4 sm:py-2">
|
||||
{/* Compact bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePrevParagraph} disabled={currentParagraphIndex <= 0}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={handlePrevParagraph} disabled={currentParagraphIndex <= 0}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full"
|
||||
className="h-8 w-8 rounded-full sm:h-9 sm:w-9"
|
||||
onClick={handlePlay}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextParagraph} disabled={currentParagraphIndex >= paragraphs.length - 1}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={handleNextParagraph} disabled={currentParagraphIndex >= paragraphs.length - 1}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleStop}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={handleStop}>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -426,7 +426,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onExpandedChange?.(!isExpanded)}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={() => onExpandedChange?.(!isExpanded)}>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -498,7 +498,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
</div>
|
||||
|
||||
{/* Spacer so content isn't hidden behind the player bar - only active when open */}
|
||||
<div className={cn("transition-all duration-300", isOpen ? (isExpanded ? "h-44" : "h-16") : "h-0")} />
|
||||
<div className={cn("transition-all duration-300", isOpen ? (isExpanded ? "h-52 sm:h-44" : "h-16") : "h-0")} />
|
||||
|
||||
{/* TTS highlight styles */}
|
||||
<style>{`
|
||||
|
||||
@@ -3,6 +3,9 @@ import mongoose, { Schema, Document } from "mongoose"
|
||||
export interface IChapter extends Document {
|
||||
novelId: string // Trỏ tới ID trong PostgreSQL
|
||||
number: number
|
||||
volumeNumber?: number
|
||||
volumeTitle?: string
|
||||
volumeChapterNumber?: number
|
||||
title: string
|
||||
content: string
|
||||
views: number
|
||||
@@ -12,6 +15,9 @@ export interface IChapter extends Document {
|
||||
const ChapterSchema: Schema = new Schema({
|
||||
novelId: { type: String, required: true, index: true },
|
||||
number: { type: Number, required: true },
|
||||
volumeNumber: { type: Number, default: null },
|
||||
volumeTitle: { type: String, default: null },
|
||||
volumeChapterNumber: { type: Number, default: null },
|
||||
title: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
views: { type: Number, default: 0 },
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function getNovelStatusBadgeClass(status: string): string {
|
||||
const normalized = status.trim().toLowerCase()
|
||||
|
||||
if (normalized.includes("hoàn")) {
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
}
|
||||
|
||||
if (normalized.includes("tạm")) {
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
}
|
||||
|
||||
if (normalized.includes("drop") || normalized.includes("hủy") || normalized.includes("cancel")) {
|
||||
return "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
|
||||
}
|
||||
|
||||
return "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300"
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
||||
import path from "path"
|
||||
|
||||
type UploadToR2Options = {
|
||||
buffer: Buffer
|
||||
contentType?: string | null
|
||||
keyPrefix?: string
|
||||
fileNameHint?: string
|
||||
}
|
||||
|
||||
let cachedClient: S3Client | null = null
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalEnv(name: string): string | null {
|
||||
const value = process.env[name]
|
||||
return value ? value : null
|
||||
}
|
||||
|
||||
function getR2Config() {
|
||||
const accountId = requiredEnv("R2_ACCOUNT_ID")
|
||||
const accessKeyId = requiredEnv("R2_ACCESS_KEY_ID")
|
||||
const secretAccessKey = requiredEnv("R2_SECRET_ACCESS_KEY")
|
||||
const bucket = requiredEnv("R2_BUCKET_NAME")
|
||||
const publicBaseUrl = requiredEnv("R2_PUBLIC_BASE_URL")
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket,
|
||||
publicBaseUrl: publicBaseUrl.replace(/\/+$/, ""),
|
||||
}
|
||||
}
|
||||
|
||||
function getR2Client(): S3Client {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const { accountId, accessKeyId, secretAccessKey } = getR2Config()
|
||||
|
||||
cachedClient = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
function extensionFromMimeType(mimeType: string | null | undefined): string {
|
||||
if (!mimeType) return ".jpg"
|
||||
const normalized = mimeType.toLowerCase()
|
||||
if (normalized.includes("png")) return ".png"
|
||||
if (normalized.includes("webp")) return ".webp"
|
||||
if (normalized.includes("gif")) return ".gif"
|
||||
if (normalized.includes("avif")) return ".avif"
|
||||
if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"
|
||||
return ".jpg"
|
||||
}
|
||||
|
||||
function extensionFromHint(fileNameHint?: string): string {
|
||||
if (!fileNameHint) return ""
|
||||
const ext = path.extname(fileNameHint).toLowerCase()
|
||||
if (!ext) return ""
|
||||
if (!/^\.[a-z0-9]{1,8}$/.test(ext)) return ""
|
||||
return ext
|
||||
}
|
||||
|
||||
export async function uploadBufferToR2(options: UploadToR2Options): Promise<string> {
|
||||
const client = getR2Client()
|
||||
const { bucket, publicBaseUrl } = getR2Config()
|
||||
|
||||
const keyPrefix = (options.keyPrefix || "covers").replace(/^\/+|\/+$/g, "")
|
||||
const ext = extensionFromHint(options.fileNameHint) || extensionFromMimeType(options.contentType)
|
||||
const key = `${keyPrefix}/${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: options.buffer,
|
||||
ContentType: options.contentType || "application/octet-stream",
|
||||
})
|
||||
)
|
||||
|
||||
return `${publicBaseUrl}/${key}`
|
||||
}
|
||||
|
||||
export function getR2ObjectKeyFromUrl(url: string | null | undefined): string | null {
|
||||
if (!url) return null
|
||||
|
||||
const publicBaseUrl = optionalEnv("R2_PUBLIC_BASE_URL")?.replace(/\/+$/, "")
|
||||
if (!publicBaseUrl) return null
|
||||
|
||||
let baseUrl: URL
|
||||
let fileUrl: URL
|
||||
|
||||
try {
|
||||
baseUrl = new URL(publicBaseUrl)
|
||||
fileUrl = new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (baseUrl.origin !== fileUrl.origin) return null
|
||||
|
||||
const basePath = baseUrl.pathname.replace(/\/+$/, "")
|
||||
if (!fileUrl.pathname.startsWith(basePath)) return null
|
||||
|
||||
const relativePath = fileUrl.pathname.slice(basePath.length).replace(/^\/+/, "")
|
||||
if (!relativePath) return null
|
||||
|
||||
return decodeURIComponent(relativePath)
|
||||
}
|
||||
|
||||
export async function deleteR2ObjectByUrl(url: string | null | undefined): Promise<boolean> {
|
||||
const key = getR2ObjectKeyFromUrl(url)
|
||||
if (!key) return false
|
||||
|
||||
const client = getR2Client()
|
||||
const { bucket } = getR2Config()
|
||||
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export interface Novel {
|
||||
title: string
|
||||
slug: string
|
||||
authorName: string
|
||||
series?: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
} | null
|
||||
coverColor: string
|
||||
description: string
|
||||
genres: string[]
|
||||
@@ -28,6 +33,9 @@ export interface Chapter {
|
||||
id: string
|
||||
novelId: string
|
||||
number: number
|
||||
volumeNumber?: number
|
||||
volumeTitle?: string
|
||||
volumeChapterNumber?: number
|
||||
title: string
|
||||
content: string
|
||||
views: number
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@aws-sdk/client-s3": "^3.1006.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
|
||||
Generated
+1191
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,8 @@ model Novel {
|
||||
title String
|
||||
originalTitle String?
|
||||
slug String @unique
|
||||
seriesId String?
|
||||
series Series? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
|
||||
authorName String // Tên tác giả nguyên bản của truyện
|
||||
originalAuthorName String?
|
||||
uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload
|
||||
@@ -93,6 +95,18 @@ model Novel {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Series {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
|
||||
novels Novel[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user