Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-11 17:02:31 +07:00
parent 1139125460
commit 5686753ab7
42 changed files with 4659 additions and 309 deletions
+7
View File
@@ -44,6 +44,13 @@ NEXTAUTH_URL="http://localhost:3000"
# Cấu hình Google Login # Cấu hình Google Login
GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret" 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. Cài đặt dependencies và khởi tạo DB
+27 -1
View File
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import connectToMongoDB from "@/lib/mongoose" import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter" import { Chapter } from "@/lib/models/chapter"
import { prisma } from "@/lib/prisma"
export async function PUT(req: Request) { export async function PUT(req: Request) {
const session = await getServerSession(authOptions) 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 }) 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() await connectToMongoDB()
// Prepare bulk operations for mongoose // Prepare bulk operations for mongoose
const bulkOps = updates.map((update: any) => ({ const bulkOps = validUpdates.map((update: any) => ({
updateOne: { updateOne: {
filter: { _id: update.id, novelId: novelId }, filter: { _id: update.id, novelId: novelId },
update: { update: {
@@ -41,6 +66,7 @@ export async function PUT(req: Request) {
return NextResponse.json({ return NextResponse.json({
message: "Cập nhật thành công", message: "Cập nhật thành công",
matchedCount: result.matchedCount,
modifiedCount: result.modifiedCount modifiedCount: result.modifiedCount
}, { status: 200 }) }, { status: 200 })
+19 -3
View File
@@ -5,6 +5,12 @@ import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter" import { Chapter } from "@/lib/models/chapter"
import { prisma } from "@/lib/prisma" 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) { export async function GET(req: Request) {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId") const novelId = searchParams.get("novelId")
@@ -48,7 +54,7 @@ export async function POST(req: Request) {
try { try {
const data = await req.json() 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 // Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({ const novel = await prisma.novel.findFirst({
@@ -70,6 +76,9 @@ export async function POST(req: Request) {
const newChapter = await Chapter.create({ const newChapter = await Chapter.create({
novelId, novelId,
number, number,
volumeNumber: toNullableNumber(volumeNumber),
volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null,
volumeChapterNumber: toNullableNumber(volumeChapterNumber),
title, title,
content, content,
}) })
@@ -96,7 +105,7 @@ export async function PUT(req: Request) {
try { try {
const data = await req.json() 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 // Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({ const novel = await prisma.novel.findFirst({
@@ -111,7 +120,14 @@ export async function PUT(req: Request) {
const updatedChapter = await Chapter.findOneAndUpdate( const updatedChapter = await Chapter.findOneAndUpdate(
{ _id: id, novelId }, { _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 } { new: true }
) )
+703 -35
View File
@@ -9,6 +9,590 @@ import os from "os"
import { promises as fs } from "fs" import { promises as fs } from "fs"
import { convert } from "html-to-text" import { convert } from "html-to-text"
import { slugify } from "@/lib/utils" 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) { export async function POST(req: Request) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -19,6 +603,12 @@ export async function POST(req: Request) {
try { try {
const formData = await req.formData() const formData = await req.formData()
const epubFile = formData.get("file") as File 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) { if (!epubFile) {
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 }) 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`) const tempFilePath = path.join(os.tmpdir(), `upload-${Date.now()}.epub`)
await fs.writeFile(tempFilePath, buffer) await fs.writeFile(tempFilePath, buffer)
// Phân tích EPUB file let parsedData: any = null
const parsedData = await new Promise<any>((resolve, reject) => { try {
const EPub = require("epub2").EPub || require("epub2") const { metadata, sections, cover } = await parseEpubSections(tempFilePath)
const epub = new EPub(tempFilePath, "", "")
epub.on("error", (err: any) => reject(err)) let regexNormalized: string | null = null
epub.on("end", async () => { let regexPreset: string | null = null
const metadata = epub.metadata let chapters: ParsedChapter[] = []
const flow = epub.flow // TOC array
const chapters = []
for (let i = 0; i < flow.length; i++) { if (splitMode === "regex") {
const chapterData = flow[i] const regexResolved = resolveRegexPattern(formData)
const text = await new Promise<string>((res) => { const compiled = buildRegexFromInput(regexResolved.regexInput)
epub.getChapter(chapterData.id, (err: any, d: string) => { chapters = buildChaptersFromRegexSections(sections, compiled.regex)
if (err) res("") regexNormalized = compiled.normalized
else res(d) regexPreset = regexResolved.regexPreset
})
})
if (text && text.trim().length > 0) { if (chapters.length === 0) {
const plainText = convert(text, { wordwrap: false }) return NextResponse.json(
chapters.push({ {
title: chapterData.title || `Chương ${i + 1}`, error: "Regex không tách được chương nào. Hãy thử regex khác hoặc chuyển về TOC.",
content: plainText parserInfo: {
}) splitMode,
chapterRegexUsed: regexNormalized,
regexPreset,
sourceSections: sections.length,
chaptersDetected: 0,
}
},
{ status: 400 }
)
}
} else {
chapters = buildChaptersFromTOCSections(sections)
if (chapters.length === 0) {
return NextResponse.json(
{ error: "Không tìm thấy chương hợp lệ từ TOC. Bạn có thể thử chế độ Regex." },
{ status: 400 }
)
} }
} }
resolve({ metadata, chapters }) const gapFilled = withMissingChapterPlaceholders(chapters)
})
epub.parse()
})
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 // Xóa file tạm
await fs.unlink(tempFilePath).catch(() => { }) 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" const metadataTitle = normalizeMetaText(metadata?.title, "Truyện chưa đặt tên")
let novelAuthor = metadata.creator || "Khuyết danh" const metadataAuthor = normalizeMetaText(metadata?.creator, "Khuyết danh")
let novelDesc = metadata.description || "Chưa có giới thiệu" 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 // Generate base slug
const baseSlug = slugify(novelTitle) const baseSlug = slugify(novelTitle)
@@ -83,12 +740,16 @@ export async function POST(req: Request) {
slugCounter++ slugCounter++
} }
const coverUrl = await saveCoverBufferToR2(cover)
const newNovel = await prisma.novel.create({ const newNovel = await prisma.novel.create({
data: { data: {
title: novelTitle, title: novelTitle,
slug: slug, slug: slug,
authorName: novelAuthor, authorName: novelAuthor,
description: convert(novelDesc, { wordwrap: false }), // metadata metadata có thể chứa html description: novelDesc,
coverUrl,
seriesId: selectedSeriesId,
uploaderId: session.user.id, uploaderId: session.user.id,
totalChapters: chapters.length, totalChapters: chapters.length,
}, },
@@ -98,7 +759,10 @@ export async function POST(req: Request) {
await connectToMongoDB() await connectToMongoDB()
const chapterDocs = chapters.map((ch: any, i: number) => ({ const chapterDocs = chapters.map((ch: any, i: number) => ({
novelId: newNovel.id, 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, title: ch.title,
content: ch.content, content: ch.content,
views: 0 views: 0
@@ -108,7 +772,11 @@ export async function POST(req: Request) {
await Chapter.insertMany(chapterDocs) await Chapter.insertMany(chapterDocs)
} }
return NextResponse.json(newNovel, { status: 201 }) return NextResponse.json({
...newNovel,
parserInfo,
hasCoverFromEpub: !!coverUrl,
}, { status: 201 })
} catch (error: any) { } catch (error: any) {
console.error("EPUB upload error:", error) console.error("EPUB upload error:", error)
return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 }) return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 })
+200
View File
@@ -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 })
}
}
+9 -3
View File
@@ -14,12 +14,18 @@ export async function GET(
} }
try { try {
const novel = await prisma.novel.findUnique({ const novel = await prisma.novel.findFirst({
where: { where: session.user.role === "ADMIN"
? { id }
: {
id, id,
uploaderId: session.user.id, OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}, },
include: { include: {
series: true,
genres: { genres: {
include: { include: {
genre: true genre: true
+70
View File
@@ -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
View File
@@ -3,6 +3,75 @@ import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { slugify } from "@/lib/utils" 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() { export async function GET() {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -12,7 +81,19 @@ export async function GET() {
try { try {
const novels = await prisma.novel.findMany({ 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" }, orderBy: { updatedAt: "desc" },
}) })
return NextResponse.json(novels) return NextResponse.json(novels)
@@ -30,6 +111,7 @@ export async function POST(req: Request) {
try { try {
const data = await req.json() const data = await req.json()
const { title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds = [] } = data 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 // Tạo slug từ title
const slug = slugify(title) const slug = slugify(title)
@@ -42,6 +124,7 @@ export async function POST(req: Request) {
originalAuthorName, originalAuthorName,
description, description,
coverUrl, coverUrl,
seriesId,
uploaderId: session.user.id, uploaderId: session.user.id,
genres: { genres: {
create: genreIds.map((id: string) => ({ create: genreIds.map((id: string) => ({
@@ -65,10 +148,76 @@ export async function PUT(req: Request) {
try { try {
const data = await req.json() const data = await req.json()
const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data 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({ const updatedNovel = await prisma.novel.update({
where: { id: id, uploaderId: session.user.id }, // Make sure they own it where: { id },
data: { data: {
title, title,
originalTitle, originalTitle,
@@ -77,7 +226,8 @@ export async function PUT(req: Request) {
description, description,
coverUrl, coverUrl,
status, status,
// Replace all existing genres if genreIds is provided seriesId: fixedSeriesId,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
...(genreIds !== undefined && { ...(genreIds !== undefined && {
genres: { genres: {
deleteMany: {}, deleteMany: {},
@@ -88,6 +238,7 @@ export async function PUT(req: Request) {
}) })
}, },
}) })
return NextResponse.json(updatedNovel) return NextResponse.json(updatedNovel)
} catch (error) { } catch (error) {
return NextResponse.json({ error: "Failed to update novel" }, { status: 500 }) 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 }) 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, const novel = await prisma.novel.findFirst({
// ở đây chúng ta chỉ xóa record của Postgres để ẩn truyện). where: session.user.role === "ADMIN"
await prisma.novel.delete({ ? { id }
where: { id: id, uploaderId: session.user.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) { } catch (error) {
return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 }) return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 })
} }
+8 -17
View File
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next" import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import { writeFile } from "fs/promises" import { uploadBufferToR2 } from "@/lib/r2"
import path from "path"
import fs from "fs"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@@ -26,21 +24,14 @@ export async function POST(req: Request) {
const bytes = await file.arrayBuffer() const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes) const buffer = Buffer.from(bytes)
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9) const url = await uploadBufferToR2({
const ext = path.extname(file.name) || ".jpg" buffer,
const filename = `cover-${uniqueSuffix}${ext}` contentType: file.type,
keyPrefix: "covers/manual",
fileNameHint: file.name,
})
const uploadDir = path.join(process.cwd(), "public", "uploads", "covers") return NextResponse.json({ url })
// 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}` })
} catch (error: any) { } catch (error: any) {
console.error("Cover upload error:", error) console.error("Cover upload error:", error)
return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 }) return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 })
+4 -1
View File
@@ -22,7 +22,7 @@ export async function GET(
.sort({ number: 1 }) .sort({ number: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.select("number title createdAt") // don't return content .select("number title createdAt volumeNumber volumeTitle volumeChapterNumber") // don't return content
.lean(), .lean(),
Chapter.countDocuments({ novelId }) Chapter.countDocuments({ novelId })
]) ])
@@ -32,6 +32,9 @@ export async function GET(
id: c._id.toString(), id: c._id.toString(),
number: c.number, number: c.number,
title: c.title, 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() createdAt: (c.createdAt as Date).toISOString()
})), })),
totalChapters, totalChapters,
+42
View File
@@ -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 })
}
}
+24 -1
View File
@@ -39,6 +39,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
// Core states // Core states
const [number, setNumber] = useState("") const [number, setNumber] = useState("")
const [volumeNumber, setVolumeNumber] = useState("")
const [volumeTitle, setVolumeTitle] = useState("")
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [originalNovelId, setOriginalNovelId] = useState("") const [originalNovelId, setOriginalNovelId] = useState("")
@@ -77,6 +80,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
const data = await res.json() const data = await res.json()
setNumber(data.number.toString()) setNumber(data.number.toString())
setVolumeNumber(data.volumeNumber ? String(data.volumeNumber) : "")
setVolumeTitle(data.volumeTitle || "")
setVolumeChapterNumber(data.volumeChapterNumber ? String(data.volumeChapterNumber) : "")
setTitle(data.title) setTitle(data.title)
setContent(data.content) setContent(data.content)
setOriginalNovelId(data.novelId) setOriginalNovelId(data.novelId)
@@ -189,6 +195,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
id: chapterId, id: chapterId,
novelId: originalNovelId, novelId: originalNovelId,
number: parseInt(number), number: parseInt(number),
volumeNumber: volumeNumber ? parseInt(volumeNumber) : null,
volumeTitle: volumeTitle.trim() || null,
volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null,
title, title,
content content
}) })
@@ -477,12 +486,26 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
{/* Editor Workspace */} {/* Editor Workspace */}
<div className="flex flex-col flex-1 pb-4 min-h-0"> <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"> <div className="space-y-1">
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label> <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" /> <Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
</div> </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"> <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> <label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} /> <Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div> </div>
+132 -15
View File
@@ -24,6 +24,9 @@ import * as mammoth from "mammoth"
interface Chapter { interface Chapter {
_id: string _id: string
number: number number: number
volumeNumber?: number | null
volumeTitle?: string | null
volumeChapterNumber?: number | null
title: string title: string
views: number views: number
createdAt: string createdAt: string
@@ -60,9 +63,11 @@ function ChapterManager() {
const [openOptimize, setOpenOptimize] = useState(false) const [openOptimize, setOpenOptimize] = useState(false)
const [previewMode, setPreviewMode] = useState(false) const [previewMode, setPreviewMode] = useState(false)
const [optimizing, setOptimizing] = useState(false) const [optimizing, setOptimizing] = useState(false)
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
const [optRemovePrefix, setOptRemovePrefix] = useState(true) const [optRemovePrefix, setOptRemovePrefix] = useState(true)
const [optRenumber, setOptRenumber] = useState(true) const [optRenumber, setOptRenumber] = useState(true)
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([]) const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
// Edit states // Edit states
const [openEdit, setOpenEdit] = useState(false) const [openEdit, setOpenEdit] = useState(false)
@@ -76,6 +81,9 @@ function ChapterManager() {
// Form states // Form states
const [number, setNumber] = useState("") const [number, setNumber] = useState("")
const [volumeNumber, setVolumeNumber] = useState("")
const [volumeTitle, setVolumeTitle] = useState("")
const [volumeChapterNumber, setVolumeChapterNumber] = useState("")
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
@@ -115,6 +123,29 @@ function ChapterManager() {
} }
}, [novelId, currentPage]) }, [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) => { const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!number || !title || !content || !novelId) { if (!number || !title || !content || !novelId) {
@@ -127,7 +158,15 @@ function ChapterManager() {
const res = await fetch("/api/mod/chuong", { const res = await fetch("/api/mod/chuong", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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() const resData = await res.json()
@@ -137,6 +176,9 @@ function ChapterManager() {
setOpenAdd(false) setOpenAdd(false)
setTitle("") setTitle("")
setContent("") setContent("")
setVolumeNumber("")
setVolumeTitle("")
setVolumeChapterNumber("")
setNumber((parseInt(number) + 1).toString()) setNumber((parseInt(number) + 1).toString())
fetchChapters() fetchChapters()
} catch (error: any) { } catch (error: any) {
@@ -205,8 +247,25 @@ function ChapterManager() {
} }
} }
const handlePreviewOptimize = () => { const handlePreviewOptimize = async () => {
let newChapters = [...chapters] 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) { if (optRenumber) {
newChapters.sort((a, b) => a.number - b.number) newChapters.sort((a, b) => a.number - b.number)
@@ -217,7 +276,7 @@ function ChapterManager() {
} }
if (optRemovePrefix) { if (optRemovePrefix) {
newChapters = newChapters.map((ch, i) => { newChapters = newChapters.map((ch) => {
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "") let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
if (!newTitle) newTitle = `Chương ${ch.number}` if (!newTitle) newTitle = `Chương ${ch.number}`
return { ...ch, title: newTitle } return { ...ch, title: newTitle }
@@ -226,18 +285,36 @@ function ChapterManager() {
setOptimizedChapters(newChapters) setOptimizedChapters(newChapters)
setPreviewMode(true) 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 () => { const handleApplyOptimize = async () => {
if (optimizedChapters.length === 0) return if (optimizedChapters.length === 0) return
setOptimizing(true) setOptimizing(true)
try { 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, id: ch._id,
title: ch.title, title: ch.title,
number: ch.number 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", { const res = await fetch("/api/mod/chuong/optimize", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -247,9 +324,11 @@ function ChapterManager() {
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa") if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa")
toast.success(`Đã ti ưu ${data.modifiedCount} chương!`) toast.success(`Đã ti ưu ${data.modifiedCount} chương trên toàn bộ truyện!`)
setOpenOptimize(false) setOpenOptimize(false)
setPreviewMode(false) setPreviewMode(false)
setOptimizedChapters([])
setOptimizeSourceChapters([])
fetchChapters(currentPage) fetchChapters(currentPage)
} catch (error: any) { } catch (error: any) {
toast.error(error.message) toast.error(error.message)
@@ -298,6 +377,8 @@ function ChapterManager() {
) )
} }
const optimizeSourceMap = new Map(optimizeSourceChapters.map((source) => [source._id, source]))
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm"> <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={() => { <Button variant="secondary" className="gap-2" onClick={() => {
setOpenOptimize(true) setOpenOptimize(true)
setPreviewMode(false) setPreviewMode(false)
setOptimizedChapters([])
setOptimizeSourceChapters([])
}} disabled={chapters.length === 0}> }} disabled={chapters.length === 0}>
<Wand2 className="h-4 w-4" /> Tối ưu hóa <Wand2 className="h-4 w-4" /> Tối ưu hóa
</Button> </Button>
@@ -341,12 +424,26 @@ function ChapterManager() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden"> <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"> <div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Chương số</label> <label className="text-sm font-medium">Chương số</label>
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required /> <Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
</div> </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"> <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> <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 /> <Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
</div> </div>
@@ -389,12 +486,23 @@ function ChapterManager() {
</DialogContent> </DialogContent>
</Dialog> </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"> <DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>Tối Ưu Hóa Chương Hàng Loạt</DialogTitle> <DialogTitle>Tối Ưu Hóa Chương Hàng Loạt</DialogTitle>
<DialogDescription> <DialogDescription>
Công cụ dọn dẹp tên chương đá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 tn bộ chương của truyện hiện tại, không chỉ page bạn đang xem.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -425,8 +533,8 @@ function ChapterManager() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{optimizedChapters.map((newCh, i) => { {optimizedChapters.map((newCh) => {
const oldCh = chapters[i] const oldCh = optimizeSourceMap.get(newCh._id) || newCh
return ( return (
<tr key={newCh._id} className="hover:bg-muted/20"> <tr key={newCh._id} className="hover:bg-muted/20">
<td className="px-4 py-3 border-r text-muted-foreground"> <td className="px-4 py-3 border-r text-muted-foreground">
@@ -448,10 +556,13 @@ function ChapterManager() {
<DialogFooter className="mt-auto pt-2"> <DialogFooter className="mt-auto pt-2">
<Button variant="outline" onClick={() => setOpenOptimize(false)}>Hủy bỏ</Button> <Button variant="outline" onClick={() => setOpenOptimize(false)}>Hủy bỏ</Button>
{!previewMode ? ( {!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}> <Button onClick={handleApplyOptimize} disabled={optimizing}>
{optimizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {optimizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Lưu thay đi vào DB 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"> <thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr> <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 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">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 font-semibold text-right">Lượt đc</th>
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao 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> </thead>
<tbody> <tbody>
{loading ? ( {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 ? ( ) : chapters.length === 0 ? (
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr> <tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr>
) : ( ) : (
chapters.map((ch) => ( chapters.map((ch) => (
<tr key={ch._id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0"> <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 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-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td> <td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right"> <td className="px-5 py-4 text-right">
+1 -4
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation"
import { getServerSession } from "next-auth/next" import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import Link from "next/link" import Link from "next/link"
import { BookOpen, Users, List, Home } from "lucide-react" import { BookOpen, Home } from "lucide-react"
export default async function ModLayout({ export default async function ModLayout({
children, 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"> <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 truyện <BookOpen className="h-4 w-4" /> Quản truyện
</Link> </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 chương
</Link>
</nav> </nav>
</aside> </aside>
+45 -5
View File
@@ -1,9 +1,45 @@
import { getServerSession } from "next-auth" import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export default async function ModDashboardPage() { export default async function ModDashboardPage() {
const session = await getServerSession(authOptions) 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 ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1> <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. Chào mừng bạn đến với trang quản trị dành cho Moderator.
</p> </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"> <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> <h3 className="font-semibold text-lg">Tổng truyện</h3>
<p className="text-3xl font-bold mt-2">0</p> <p className="text-3xl font-bold mt-2">{novelCount}</p>
</div> </div>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6"> <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> <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>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6"> <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> <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> </div>
</div> </div>
+13
View File
@@ -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 />
}
+230
View File
@@ -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 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"> 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"> 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 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>
)
}
File diff suppressed because it is too large Load Diff
+68 -6
View File
@@ -1,6 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react" import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
import { NovelCard } from "@/components/novel-card" import { NovelCard } from "@/components/novel-card"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
@@ -19,6 +20,24 @@ const iconMap: Record<string, React.ReactNode> = {
export const dynamic = "force-dynamic" 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() { export default async function HomePage() {
let popularNovels: any[] = [] let popularNovels: any[] = []
let latestNovels: any[] = [] let latestNovels: any[] = []
@@ -28,19 +47,60 @@ export default async function HomePage() {
try { try {
popularNovels = await prisma.novel.findMany({ 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" }, orderBy: { views: "desc" },
}) })
popularNovels = collapseSeriesRows(popularNovels).slice(0, 20)
latestNovels = await prisma.novel.findMany({ 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" }, orderBy: { updatedAt: "desc" },
}) })
latestNovels = collapseSeriesRows(latestNovels).slice(0, 20)
topRated = await prisma.novel.findMany({ 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" }, orderBy: { rating: "desc" },
}) })
topRated = collapseSeriesRows(topRated).slice(0, 4)
genres = await prisma.genre.findMany({ genres = await prisma.genre.findMany({
take: 8, take: 8,
@@ -60,7 +120,7 @@ export default async function HomePage() {
href={`/truyen/${featured.slug}`} href={`/truyen/${featured.slug}`}
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row" 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"> <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> <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"> <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> </p>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{featured.totalChapters} chương</span> <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"> <span className="flex items-center gap-1 text-primary">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
{featured.rating} {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"> <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} {idx + 1}
</span> </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"> <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> <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> <p className="text-xs text-muted-foreground">{novel.authorName} - Ch. {novel.totalChapters}</p>
+35 -2
View File
@@ -6,6 +6,24 @@ import { notFound } from "next/navigation"
export const dynamic = "force-dynamic" 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 }> }) { export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params const { slug } = await params
@@ -17,7 +35,7 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
notFound() notFound()
} }
const allNovels = await prisma.novel.findMany({ const allNovelsRaw = await prisma.novel.findMany({
where: { where: {
genres: { genres: {
some: { 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: { orderBy: {
updatedAt: "desc" 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. // Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
+32 -3
View File
@@ -1,12 +1,29 @@
// Server component instead of client component // Server component instead of client component
import { Search } from "lucide-react" import { Search } from "lucide-react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { NovelCard } from "@/components/novel-card" import { NovelCard } from "@/components/novel-card"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
export const dynamic = "force-dynamic" 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({ export default async function SearchPage({
searchParams, searchParams,
}: { }: {
@@ -25,6 +42,7 @@ export default async function SearchPage({
where.OR = [ where.OR = [
{ title: { contains: q, mode: "insensitive" } }, { title: { contains: q, mode: "insensitive" } },
{ authorName: { 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" } orderBy = { updatedAt: "desc" }
} }
const filteredNovels = await prisma.novel.findMany({ const filteredNovelsRaw = await prisma.novel.findMany({
where, where,
orderBy, 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() const genres = await prisma.genre.findMany()
return ( return (
+40 -14
View File
@@ -62,9 +62,11 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
// Extract paragraphs for TTS // Extract paragraphs for TTS
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean) 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 ( 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 */} {/* Top navigation */}
<div className="mb-6 flex flex-col gap-3"> <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"> <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"> <div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-bold text-foreground md:text-xl lg:text-2xl"> <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> </h1>
</div> </div>
</div> </div>
{/* Chapter navigation top */} {/* 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}> <Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
{hasPrev ? ( {hasPrev ? (
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}> <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> </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>
<Button variant="outline" size="sm" asChild> <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}> <Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
{hasNext ? ( {hasNext ? (
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}> <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> </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> </Button>
</div> </div>
{/* Chapter content */} {/* 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) => ( {paragraphs.map((text: string, idx: number) => (
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0"> <p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
{text} {text}
@@ -115,23 +129,35 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
</article> </article>
{/* Chapter navigation bottom */} {/* 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}> <Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
{hasPrev ? ( {hasPrev ? (
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}> <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> </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>
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}> <Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
{hasNext ? ( {hasNext ? (
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}> <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> </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> </Button>
</div> </div>
@@ -151,7 +177,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
paragraphs={paragraphs} paragraphs={paragraphs}
currentChapter={chapterNumber} currentChapter={chapterNumber}
maxChapter={maxChapter} maxChapter={maxChapter}
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`} chapterTitle={`${volumeLabel ? `${volumeLabel} - ` : ""}${chapterLabel}: ${chapter.title}`}
/> />
</div> </div>
) )
+84 -18
View File
@@ -10,6 +10,7 @@ import { NovelDetailActions } from "./novel-detail-actions"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose" import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter" import { Chapter } from "@/lib/models/chapter"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -39,32 +40,72 @@ export default async function NovelDetailPage({
notFound() notFound()
} }
// Fetch chapters from MongoDB let formattedChapters: any[] = []
await connectToMongoDB() let totalChapters = 0
const skip = (currentPage - 1) * limit 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 }) Chapter.find({ novelId: novel.id })
.sort({ number: 1 }) .sort({ number: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.select("id novelId number title createdAt views") .select("id novelId number title createdAt views volumeNumber volumeTitle volumeChapterNumber")
.lean(), .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 formattedChapters = chapters.map(c => ({
const formattedChapters = chapters.map(c => ({
id: c._id.toString(), id: c._id.toString(),
novelId: c.novelId, novelId: c.novelId,
number: c.number, number: c.number,
volumeNumber: (c as any).volumeNumber ?? null,
volumeTitle: (c as any).volumeTitle ?? null,
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
title: c.title, title: c.title,
createdAt: (c.createdAt as Date).toISOString(), createdAt: (c.createdAt as Date).toISOString(),
views: c.views || 0, views: c.views || 0,
content: "" // We don't fetch content for the list content: ""
})) }))
}
const commentsData = await prisma.comment.findMany({ const commentsData = await prisma.comment.findMany({
where: { novelId: novel.id, chapterId: null }, where: { novelId: novel.id, chapterId: null },
@@ -107,7 +148,7 @@ export default async function NovelDetailPage({
{/* Novel Header */} {/* Novel Header */}
<div className="flex flex-col gap-6 md:flex-row"> <div className="flex flex-col gap-6 md:flex-row">
{/* Cover */} {/* 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 */} {/* Info */}
<div className="flex flex-1 flex-col gap-3"> <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 flex-col gap-3 mt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Trạng thái:</span> <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 ${ <span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
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
}`}>
{novel.status} {novel.status}
</span> </span>
</div> </div>
@@ -177,7 +214,7 @@ export default async function NovelDetailPage({
</div> </div>
<div className="mt-4"> <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> </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> <div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description}</div>
</section> </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"> <section className="mt-8">
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2> <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"> <div className="rounded-lg border border-border bg-card">
@@ -201,6 +266,7 @@ export default async function NovelDetailPage({
/> />
</div> </div>
</section> </section>
)}
{/* Comments */} {/* Comments */}
<section className="mt-8"> <section className="mt-8">
+7 -1
View File
@@ -5,6 +5,7 @@ import { BookOpen, BookMarked, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context" import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context" import { useBookmarks } from "@/lib/bookmark-context"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
export default function BookshelfPage() { 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" 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}`}> <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> </Link>
<div className="flex min-w-0 flex-1 flex-col gap-1"> <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"> <Link title={novel.title} href={`/truyen/${novel.slug}`} className="truncate text-sm font-semibold text-foreground hover:text-primary transition-colors">
{novel.title} {novel.title}
</Link> </Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p> <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 && ( {bookmark.lastChapterNumber && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters} Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
+60 -4
View File
@@ -8,6 +8,9 @@ interface ChapterListProps {
id: string id: string
novelId: string novelId: string
number: number number: number
volumeNumber?: number | null
volumeTitle?: string | null
volumeChapterNumber?: number | null
title: string title: string
createdAt: string createdAt: string
views: number views: number
@@ -31,17 +34,51 @@ const generatePagination = (currentPage: number, totalPages: number) => {
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages] 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) { export function ChapterList({ chapters, novelSlug, currentPage, totalPages, totalChapters }: ChapterListProps) {
let lastVolumeKey: string | null = null
return ( return (
<div className="flex flex-col"> <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 <Link
key={chapter.id}
href={`/truyen/${novelSlug}/${chapter.number}`} 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" 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"> <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> <span className="truncate text-foreground">{chapter.title}</span>
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground"> <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> <span>{chapter.createdAt}</span>
</div> </div>
</Link> </Link>
))} </div>
)
})}
{totalPages > 1 && ( {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"> <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 Trước
</Link> </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) => ( {generatePagination(currentPage, totalPages).map((p, i) => (
<div key={i} className="hidden sm:block"> <div key={i} className="hidden sm:block">
{p === '...' ? ( {p === '...' ? (
+96 -2
View File
@@ -2,8 +2,8 @@
import Link from "next/link" import Link from "next/link"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useState } from "react" import { useEffect, useState } from "react"
import { BookOpen, Menu, X, Search, User as UserIcon, LogOut, BookMarked, Shield } from "lucide-react" import { BookOpen, Menu, Search, LogOut, BookMarked, Shield } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"
@@ -17,18 +17,76 @@ const navLinks = [
{ label: "Danh Sách", href: "/tim-kiem" }, { 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() { export function Header() {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
const [isSearchFocused, setIsSearchFocused] = useState(false)
const [open, setOpen] = 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) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (searchQuery.trim()) { if (searchQuery.trim()) {
router.push(`/tim-kiem?q=${encodeURIComponent(searchQuery.trim())}`) router.push(`/tim-kiem?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchQuery("") setSearchQuery("")
setSuggestions([])
setIsSearchFocused(false)
} }
} }
@@ -68,7 +126,41 @@ export function Header() {
className="h-9 pl-8" className="h-9 pl-8"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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> </div>
</form> </form>
@@ -93,6 +185,7 @@ export function Header() {
<div className="px-2 py-1.5"> <div className="px-2 py-1.5">
<p className="text-sm font-medium text-foreground">{user.username}</p> <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-muted-foreground">{user.email}</p>
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{(user.role === "MOD" || user.role === "ADMIN") && ( {(user.role === "MOD" || user.role === "ADMIN") && (
@@ -208,6 +301,7 @@ export function Header() {
<div> <div>
<p className="text-sm font-medium text-foreground">{user.username}</p> <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-muted-foreground">{user.email}</p>
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { logout(); setOpen(false) }}> <Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { logout(); setOpen(false) }}>
+12 -9
View File
@@ -1,6 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { BookOpen, Eye, Star } from "lucide-react" import { BookOpen, Eye, Star } from "lucide-react"
import { formatViews } from "@/lib/utils" import { formatViews } from "@/lib/utils"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
export interface CardNovel { export interface CardNovel {
id: string id: string
@@ -27,14 +28,19 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
href={`/truyen/${novel.slug}`} 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" 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"> <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-cover" /> <img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-contain" />
</div> </div>
<div className="flex min-w-0 flex-col justify-center"> <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"> <h3 title={novel.title} className="line-clamp-2 text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
{novel.title} {novel.title}
</h3> </h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p> <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"> <div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<Star className="h-3 w-3 fill-primary text-primary" /> <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}`} 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" 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"> <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-cover" /> <img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-contain" />
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/60 to-transparent" /> <span className={`absolute right-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
{novel.status === "Đang ra" && ( {novel.status}
<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
</span> </span>
)}
</div> </div>
<div className="flex flex-1 flex-col gap-1 p-3"> <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"> <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
View File
@@ -1,11 +1,10 @@
"use client" "use client"
import { useState, useEffect } from "react" 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 { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import Link from "next/link"
import { ReadingSettingsContent } from "./reading-settings" import { ReadingSettingsContent } from "./reading-settings"
import { TTSPlayer } from "./tts-player" import { TTSPlayer } from "./tts-player"
import { ReaderTOC } from "./reader-toc" import { ReaderTOC } from "./reader-toc"
@@ -25,14 +24,41 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
const [isTTSOpen, setIsTTSOpen] = useState(false) const [isTTSOpen, setIsTTSOpen] = useState(false)
const [isTTSExpanded, setIsTTSExpanded] = useState(false) const [isTTSExpanded, setIsTTSExpanded] = useState(false)
const [showScrollTop, setShowScrollTop] = useState(false) const [showScrollTop, setShowScrollTop] = useState(false)
const [isMobileControlsVisible, setIsMobileControlsVisible] = useState(true)
useEffect(() => { useEffect(() => {
let lastScrollY = window.scrollY
const handleScroll = () => { 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 }) window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll) return () => window.removeEventListener("scroll", handleScroll)
}, []) }, [isOpen, isTTSOpen])
const handleScrollToTop = () => { const handleScrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" }) window.scrollTo({ top: 0, behavior: "smooth" })
@@ -46,16 +72,17 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
return ( return (
<> <>
<div className={cn( <div className={cn(
"fixed right-6 z-50 flex flex-col items-center gap-3 transition-all duration-300", "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-[12rem]" : "bottom-24") : "bottom-6" 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) */} {/* Main FAB Toggle (Mobile mostly, but works as container) */}
<Button <Button
size="icon" 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)} 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> </Button>
{/* Action Items */} {/* Action Items */}
@@ -69,14 +96,14 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
<Button <Button
variant={isTTSOpen ? "default" : "secondary"} variant={isTTSOpen ? "default" : "secondary"}
size="icon" 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={() => { onClick={() => {
setIsTTSOpen(!isTTSOpen) setIsTTSOpen(!isTTSOpen)
setIsOpen(false) setIsOpen(false)
}} }}
> >
<Headphones className="h-5 w-5" /> <Headphones className="h-4 w-4 md:h-5 md: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"> <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"} {isTTSOpen ? "Đóng Audio" : "Nghe Audio"}
</span> </span>
</Button> </Button>
@@ -94,15 +121,15 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
<Button <Button
variant="secondary" variant="secondary"
size="icon" 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" /> <Settings2 className="h-4 w-4 md:h-5 md: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"> <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 Tùy chỉnh
</span> </span>
</Button> </Button>
</PopoverTrigger> </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 <ReadingSettingsContent
fontSize={fontSize} setFontSize={setFontSize} fontSize={fontSize} setFontSize={setFontSize}
lineHeight={lineHeight} setLineHeight={setLineHeight} lineHeight={lineHeight} setLineHeight={setLineHeight}
@@ -116,13 +143,13 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
variant="secondary" variant="secondary"
size="icon" size="icon"
className={cn( 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" showScrollTop ? "opacity-100 scale-100" : "opacity-0 scale-0 pointer-events-none"
)} )}
onClick={handleScrollToTop} onClick={handleScrollToTop}
> >
<ArrowUp className="h-5 w-5" /> <ArrowUp className="h-4 w-4 md:h-5 md: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"> <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 Lên đu trang
</span> </span>
</Button> </Button>
+26 -6
View File
@@ -16,6 +16,9 @@ interface TOCChapter {
id: string id: string
number: number number: number
title: string title: string
volumeNumber?: number | null
volumeTitle?: string | null
volumeChapterNumber?: number | null
} }
export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTOCProps) { export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTOCProps) {
@@ -76,10 +79,10 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
<Button <Button
variant="secondary" variant="secondary"
size="icon" 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" /> <List className="h-4 w-4 md:h-5 md: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"> <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 Mục lục
</span> </span>
</Button> </Button>
@@ -97,11 +100,26 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
<Loader2 className="h-6 w-6 animate-spin text-primary" /> <Loader2 className="h-6 w-6 animate-spin text-primary" />
</div> </div>
) : ( ) : (
chapters.map((chap) => { (() => {
let lastVolumeKey: string | null = null
return chapters.map((chap) => {
const isActive = chap.number === currentChapterNumber 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 ( 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 <Link
key={chap.id}
id={`toc-chap-${chap.number}`} id={`toc-chap-${chap.number}`}
href={`/truyen/${novelSlug}/${chap.number}`} href={`/truyen/${novelSlug}/${chap.number}`}
onClick={() => setIsOpen(false)} 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"}> <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>
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span> <span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
</Link> </Link>
</div>
) )
}) })
})()
)} )}
</div> </div>
+8 -8
View File
@@ -342,7 +342,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
return ( return (
<> <>
{/* Floating TTS bar */} {/* 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 */} {/* Progress bar */}
<div className="h-0.5 w-full bg-muted"> <div className="h-0.5 w-full bg-muted">
<div <div
@@ -351,25 +351,25 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
/> />
</div> </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 */} {/* Compact bar */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Play controls */} {/* Play controls */}
<div className="flex items-center gap-1"> <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" /> <SkipBack className="h-4 w-4" />
</Button> </Button>
<Button <Button
size="icon" size="icon"
className="h-9 w-9 rounded-full" className="h-8 w-8 rounded-full sm:h-9 sm:w-9"
onClick={handlePlay} onClick={handlePlay}
> >
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />} {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</Button> </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" /> <SkipForward className="h-4 w-4" />
</Button> </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" /> <Square className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -426,7 +426,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div> </div>
{/* Expand/Collapse */} {/* 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" />} {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button> </Button>
</div> </div>
@@ -498,7 +498,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div> </div>
{/* Spacer so content isn't hidden behind the player bar - only active when open */} {/* 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 */} {/* TTS highlight styles */}
<style>{` <style>{`
+6
View File
@@ -3,6 +3,9 @@ import mongoose, { Schema, Document } from "mongoose"
export interface IChapter extends Document { export interface IChapter extends Document {
novelId: string // Trỏ tới ID trong PostgreSQL novelId: string // Trỏ tới ID trong PostgreSQL
number: number number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string title: string
content: string content: string
views: number views: number
@@ -12,6 +15,9 @@ export interface IChapter extends Document {
const ChapterSchema: Schema = new Schema({ const ChapterSchema: Schema = new Schema({
novelId: { type: String, required: true, index: true }, novelId: { type: String, required: true, index: true },
number: { type: Number, required: 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 }, title: { type: String, required: true },
content: { type: String, required: true }, content: { type: String, required: true },
views: { type: Number, default: 0 }, views: { type: Number, default: 0 },
+17
View File
@@ -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"
}
+140
View File
@@ -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
}
+8
View File
@@ -11,6 +11,11 @@ export interface Novel {
title: string title: string
slug: string slug: string
authorName: string authorName: string
series?: {
id: string
name: string
slug: string
} | null
coverColor: string coverColor: string
description: string description: string
genres: string[] genres: string[]
@@ -28,6 +33,9 @@ export interface Chapter {
id: string id: string
novelId: string novelId: string
number: number number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string title: string
content: string content: string
views: number views: number
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.1006.0",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-accordion": "1.2.12",
+1191
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -70,6 +70,8 @@ model Novel {
title String title String
originalTitle String? originalTitle String?
slug String @unique 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 authorName String // Tên tác giả nguyên bản của truyện
originalAuthorName String? originalAuthorName String?
uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload
@@ -93,6 +95,18 @@ model Novel {
updatedAt DateTime @updatedAt 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 { model Genre {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique 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

+1 -1
View File
File diff suppressed because one or more lines are too long