Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -44,6 +44,13 @@ NEXTAUTH_URL="http://localhost:3000"
|
|||||||
# Cấu hình Google Login
|
# 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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+706
-38
@@ -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,50 +617,117 @@ 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)
|
||||||
|
|
||||||
|
parsedData = {
|
||||||
|
metadata,
|
||||||
|
sections,
|
||||||
|
chapters: gapFilled.chapters,
|
||||||
|
cover,
|
||||||
|
parserInfo: {
|
||||||
|
splitMode,
|
||||||
|
chapterRegexUsed: regexNormalized,
|
||||||
|
regexPreset,
|
||||||
|
sourceSections: sections.length,
|
||||||
|
chaptersDetected: chapters.length,
|
||||||
|
chaptersFinal: gapFilled.chapters.length,
|
||||||
|
insertedMissingChapters: gapFilled.insertedCount,
|
||||||
|
detectedMaxChapterNumber: gapFilled.detectedMax,
|
||||||
|
detectedNumberAssignments: gapFilled.detectedNumberAssignments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Xóa file tạm
|
||||||
|
await fs.unlink(tempFilePath).catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metadata, chapters, parserInfo, cover } = parsedData
|
||||||
|
|
||||||
|
const metadataTitle = normalizeMetaText(metadata?.title, "Truyện chưa đặt tên")
|
||||||
|
const metadataAuthor = normalizeMetaText(metadata?.creator, "Khuyết danh")
|
||||||
|
const metadataDescRaw = normalizeMetaText(metadata?.description, "Chưa có giới thiệu")
|
||||||
|
const metadataDesc = convert(metadataDescRaw, { wordwrap: false })
|
||||||
|
|
||||||
|
const novelTitle = normalizeMetaText(readFormText(formData, "title"), metadataTitle)
|
||||||
|
const novelAuthor = normalizeMetaText(readFormText(formData, "authorName"), metadataAuthor)
|
||||||
|
const novelDesc = normalizeMetaText(readFormText(formData, "description"), metadataDesc)
|
||||||
|
|
||||||
|
const hasDetectedVolumes = chapters.some((ch: any) => ch.volumeNumber !== null)
|
||||||
|
|
||||||
|
if (previewOnly) {
|
||||||
|
return NextResponse.json({
|
||||||
|
preview: true,
|
||||||
|
fileName: epubFile.name,
|
||||||
|
splitMode,
|
||||||
|
detectedStructureType: hasDetectedVolumes ? "light_novel" : "standard",
|
||||||
|
parserInfo,
|
||||||
|
hasCoverFromEpub: !!cover?.buffer,
|
||||||
|
novel: {
|
||||||
|
title: novelTitle,
|
||||||
|
authorName: novelAuthor,
|
||||||
|
description: novelDesc,
|
||||||
|
totalChapters: chapters.length,
|
||||||
|
},
|
||||||
|
chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({
|
||||||
|
number: ch.finalNumber || i + 1,
|
||||||
|
title: ch.title,
|
||||||
|
isPlaceholder: !!ch.isPlaceholder,
|
||||||
|
volumeNumber: ch.volumeNumber,
|
||||||
|
volumeTitle: ch.volumeTitle,
|
||||||
|
volumeChapterNumber: ch.volumeChapterNumber,
|
||||||
|
excerpt: (ch.content || "").slice(0, 180),
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
epub.parse()
|
const selectedSeriesId = await resolveSeriesIdForEpubImport({
|
||||||
|
mode: seriesMode,
|
||||||
|
seriesId: seriesIdInput,
|
||||||
|
seriesName: seriesNameInput,
|
||||||
|
userRole: session.user.role,
|
||||||
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Xóa file tạm
|
|
||||||
await fs.unlink(tempFilePath).catch(() => { })
|
|
||||||
|
|
||||||
const { metadata, chapters } = parsedData
|
|
||||||
|
|
||||||
let novelTitle = metadata.title || "Truyện chưa đặt tên"
|
|
||||||
let novelAuthor = metadata.creator || "Khuyết danh"
|
|
||||||
let novelDesc = metadata.description || "Chưa có giới thiệu"
|
|
||||||
|
|
||||||
// Generate base slug
|
// 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 })
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getServerSession } from "next-auth/next"
|
||||||
|
import { authOptions } from "@/lib/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { slugify } from "@/lib/utils"
|
||||||
|
|
||||||
|
function normalizeText(value: any): string {
|
||||||
|
return typeof value === "string" ? value.trim() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEditableSeries(
|
||||||
|
id: string,
|
||||||
|
session: { user: { role: "USER" | "MOD" | "ADMIN"; id: string } }
|
||||||
|
) {
|
||||||
|
return prisma.series.findFirst({
|
||||||
|
where: session.user.role === "ADMIN"
|
||||||
|
? { id }
|
||||||
|
: {
|
||||||
|
id,
|
||||||
|
OR: [
|
||||||
|
{ novels: { some: { uploaderId: session.user.id } } },
|
||||||
|
{ novels: { some: { uploaderId: null } } },
|
||||||
|
{ novels: { none: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const series = await prisma.series.findMany({
|
||||||
|
where: session.user.role === "ADMIN"
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ novels: { some: { uploaderId: session.user.id } } },
|
||||||
|
{ novels: { some: { uploaderId: null } } },
|
||||||
|
{ novels: { none: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
_count: { select: { novels: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(series)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch series" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const name = normalizeText(body?.name)
|
||||||
|
const description = normalizeText(body?.description)
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: "Tên series không được để trống" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.series.findFirst({
|
||||||
|
where: { name: { equals: name, mode: "insensitive" } },
|
||||||
|
select: { id: true, name: true, slug: true, description: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSlug = slugify(name)
|
||||||
|
let slug = baseSlug
|
||||||
|
let counter = 1
|
||||||
|
|
||||||
|
while (await prisma.series.findUnique({ where: { slug } })) {
|
||||||
|
slug = `${baseSlug}-${counter}`
|
||||||
|
counter += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.series.create({
|
||||||
|
data: { name, slug, description: description || null },
|
||||||
|
select: { id: true, name: true, slug: true, description: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(created, { status: 201 })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to create series" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const id = normalizeText(body?.id)
|
||||||
|
const name = normalizeText(body?.name)
|
||||||
|
const description = normalizeText(body?.description)
|
||||||
|
|
||||||
|
if (!id || !name) {
|
||||||
|
return NextResponse.json({ error: "Thiếu thông tin series" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await resolveEditableSeries(id, session as any)
|
||||||
|
if (!target) {
|
||||||
|
return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicated = await prisma.series.findFirst({
|
||||||
|
where: {
|
||||||
|
id: { not: id },
|
||||||
|
name: { equals: name, mode: "insensitive" },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (duplicated) {
|
||||||
|
return NextResponse.json({ error: "Tên series đã tồn tại" }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSlug = slugify(name)
|
||||||
|
let slug = baseSlug
|
||||||
|
let counter = 1
|
||||||
|
|
||||||
|
while (await prisma.series.findFirst({ where: { slug, id: { not: id } }, select: { id: true } })) {
|
||||||
|
slug = `${baseSlug}-${counter}`
|
||||||
|
counter += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.series.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: description || null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
_count: { select: { novels: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(updated)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to update series" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const id = normalizeText(url.searchParams.get("id"))
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Thiếu id series" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await resolveEditableSeries(id, session as any)
|
||||||
|
if (!target) {
|
||||||
|
return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedCount = await prisma.novel.count({ where: { seriesId: id } })
|
||||||
|
if (usedCount > 0) {
|
||||||
|
return NextResponse.json({ error: "Series đang chứa truyện, không thể xóa" }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.series.delete({ where: { id } })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to delete series" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,18 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const novel = await prisma.novel.findUnique({
|
const novel = await prisma.novel.findFirst({
|
||||||
where: {
|
where: session.user.role === "ADMIN"
|
||||||
id,
|
? { id }
|
||||||
uploaderId: session.user.id,
|
: {
|
||||||
},
|
id,
|
||||||
|
OR: [
|
||||||
|
{ uploaderId: session.user.id },
|
||||||
|
{ uploaderId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
|
series: true,
|
||||||
genres: {
|
genres: {
|
||||||
include: {
|
include: {
|
||||||
genre: true
|
genre: true
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getServerSession } from "next-auth/next"
|
||||||
|
import { authOptions } from "@/lib/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import connectToMongoDB from "@/lib/mongoose"
|
||||||
|
import { Chapter } from "@/lib/models/chapter"
|
||||||
|
import { deleteR2ObjectByUrl } from "@/lib/r2"
|
||||||
|
|
||||||
|
function normalizeIds(value: any): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const action = typeof body?.action === "string" ? body.action : ""
|
||||||
|
const ids = normalizeIds(body?.ids)
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Danh sách truyện trống" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleNovels = await prisma.novel.findMany({
|
||||||
|
where: session.user.role === "ADMIN"
|
||||||
|
? { id: { in: ids } }
|
||||||
|
: {
|
||||||
|
id: { in: ids },
|
||||||
|
OR: [
|
||||||
|
{ uploaderId: session.user.id },
|
||||||
|
{ uploaderId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
coverUrl: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (accessibleNovels.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Không có truyện hợp lệ để thao tác" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleIds = accessibleNovels.map((novel) => novel.id)
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
await connectToMongoDB()
|
||||||
|
await Chapter.deleteMany({ novelId: { $in: accessibleIds } })
|
||||||
|
|
||||||
|
await prisma.novel.deleteMany({
|
||||||
|
where: { id: { in: accessibleIds } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
accessibleNovels.map((novel) => deleteR2ObjectByUrl(novel.coverUrl).catch(() => {}))
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, deletedCount: accessibleIds.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Chỉ hỗ trợ xóa hàng loạt" }, { status: 400 })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Bulk operation failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
-9
@@ -3,6 +3,75 @@ import { getServerSession } from "next-auth/next"
|
|||||||
import { authOptions } from "@/lib/auth"
|
import { 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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const q = url.searchParams.get("q")?.trim() || ""
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
return NextResponse.json([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const novels = await prisma.novel.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: q, mode: "insensitive" } },
|
||||||
|
{ authorName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ series: { name: { contains: q, mode: "insensitive" } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
authorName: true,
|
||||||
|
coverUrl: true,
|
||||||
|
series: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ views: "desc" }, { updatedAt: "desc" }],
|
||||||
|
take: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(novels)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch suggestions" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ export function EditorClient({ chapterId }: { chapterId: string }) {
|
|||||||
|
|
||||||
// Core states
|
// 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>
|
||||||
|
|||||||
@@ -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,38 +247,73 @@ function ChapterManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreviewOptimize = () => {
|
const handlePreviewOptimize = async () => {
|
||||||
let newChapters = [...chapters]
|
if (!novelId) return
|
||||||
|
|
||||||
if (optRenumber) {
|
if (!optRemovePrefix && !optRenumber) {
|
||||||
newChapters.sort((a, b) => a.number - b.number)
|
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
|
||||||
newChapters = newChapters.map((ch, idx) => ({
|
return
|
||||||
...ch,
|
|
||||||
number: idx + 1
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (optRemovePrefix) {
|
setLoadingOptimizeSource(true)
|
||||||
newChapters = newChapters.map((ch, i) => {
|
|
||||||
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
|
||||||
if (!newTitle) newTitle = `Chương ${ch.number}`
|
|
||||||
return { ...ch, title: newTitle }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setOptimizedChapters(newChapters)
|
try {
|
||||||
setPreviewMode(true)
|
const allChapters = await fetchAllChaptersForOptimize()
|
||||||
|
if (allChapters.length === 0) {
|
||||||
|
toast.info("Truyện này chưa có chương để tối ưu")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptimizeSourceChapters(allChapters)
|
||||||
|
let newChapters = [...allChapters]
|
||||||
|
|
||||||
|
if (optRenumber) {
|
||||||
|
newChapters.sort((a, b) => a.number - b.number)
|
||||||
|
newChapters = newChapters.map((ch, idx) => ({
|
||||||
|
...ch,
|
||||||
|
number: idx + 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optRemovePrefix) {
|
||||||
|
newChapters = newChapters.map((ch) => {
|
||||||
|
let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
||||||
|
if (!newTitle) newTitle = `Chương ${ch.number}`
|
||||||
|
return { ...ch, title: newTitle }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptimizedChapters(newChapters)
|
||||||
|
setPreviewMode(true)
|
||||||
|
toast.success(`Đã tạo xem trước cho toàn bộ ${newChapters.length} chương`)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Không thể tạo bản xem trước")
|
||||||
|
} finally {
|
||||||
|
setLoadingOptimizeSource(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyOptimize = async () => {
|
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]))
|
||||||
id: ch._id,
|
const updates = optimizedChapters
|
||||||
title: ch.title,
|
.filter((ch) => {
|
||||||
number: ch.number
|
const old = sourceById.get(ch._id)
|
||||||
}))
|
return !!old && (old.number !== ch.number || old.title !== ch.title)
|
||||||
|
})
|
||||||
|
.map((ch) => ({
|
||||||
|
id: ch._id,
|
||||||
|
title: ch.title,
|
||||||
|
number: ch.number
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
toast.info("Không có thay đổi nào cần lưu")
|
||||||
|
setOptimizing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch("/api/mod/chuong/optimize", {
|
const res = await fetch("/api/mod/chuong/optimize", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -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(`Đã tổi ưu ${data.modifiedCount} chương!`)
|
toast.success(`Đã tối ưu ${data.modifiedCount} chương trên toàn bộ truyện!`)
|
||||||
setOpenOptimize(false)
|
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 và đánh lại số thứ tự tự động tiện lợi sau khi đăng ép từ tệp EPUB.
|
Công cụ sẽ áp dụng trên toàn bộ chương của truyện hiện tại, không chỉ page bạn đang xem.
|
||||||
</DialogDescription>
|
</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 có chương nào được đăng.</td></tr>
|
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa có chương nào được đăng.</td></tr>
|
||||||
) : (
|
) : (
|
||||||
chapters.map((ch) => (
|
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
@@ -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 lý truyện
|
<BookOpen className="h-4 w-4" /> Quản lý 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 lý chương
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
+45
-5
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getServerSession } from "next-auth/next"
|
||||||
|
import { authOptions } from "@/lib/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { SeriesClient } from "./series-client"
|
||||||
|
|
||||||
|
export default async function ModSeriesPage() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||||
|
redirect("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SeriesClient />
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Layers, Loader2, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
interface SeriesItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description?: string | null
|
||||||
|
_count?: {
|
||||||
|
novels: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeriesClient() {
|
||||||
|
const [series, setSeries] = useState<SeriesItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [keyword, setKeyword] = useState("")
|
||||||
|
|
||||||
|
const filteredSeries = useMemo(() => {
|
||||||
|
const q = keyword.trim().toLowerCase()
|
||||||
|
if (!q) return series
|
||||||
|
return series.filter((item) => {
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(q) ||
|
||||||
|
item.slug.toLowerCase().includes(q) ||
|
||||||
|
(item.description || "").toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [keyword, series])
|
||||||
|
|
||||||
|
const fetchSeries = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/mod/series")
|
||||||
|
if (!res.ok) throw new Error("Không thể tải danh sách series")
|
||||||
|
const data = await res.json()
|
||||||
|
setSeries(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Không thể tải danh sách series")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSeries()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setName("")
|
||||||
|
setDescription("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error("Vui lòng nhập tên series")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: editingId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/mod/series", {
|
||||||
|
method: editingId ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || "Không thể lưu series")
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(editingId ? "Đã cập nhật series" : "Đã tạo series")
|
||||||
|
resetForm()
|
||||||
|
fetchSeries()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Không thể lưu series")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (item: SeriesItem) => {
|
||||||
|
setEditingId(item.id)
|
||||||
|
setName(item.name)
|
||||||
|
setDescription(item.description || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Bạn chắc chắn muốn xóa series này?")) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mod/series?id=${id}`, { method: "DELETE" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || "Không thể xóa series")
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Đã xóa series")
|
||||||
|
if (editingId === id) resetForm()
|
||||||
|
fetchSeries()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Không thể xóa series")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-card rounded-xl border shadow-sm p-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Layers className="h-6 w-6 text-primary" /> Quản lý series
|
||||||
|
</h1>
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
placeholder="Tìm series..."
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||||
|
<div className="xl:col-span-1 rounded-xl border bg-card p-4 shadow-sm">
|
||||||
|
<h2 className="text-base font-semibold mb-3">{editingId ? "Chỉnh sửa series" : "Tạo series mới"}</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Tên series</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ví dụ: Overlord" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Mô tả</label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Mô tả ngắn về series"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={submitting} className="gap-2">
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{editingId ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{editingId ? "Lưu" : "Tạo"}
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="outline" onClick={resetForm} className="gap-2">
|
||||||
|
<X className="h-4 w-4" /> Hủy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:col-span-2 rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-semibold">Tên</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Slug</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Số truyện</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredSeries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||||
|
Chưa có series nào
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredSeries.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-3 font-medium">{item.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{item.slug}</td>
|
||||||
|
<td className="px-4 py-3">{item._count?.novels ?? 0}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground max-w-sm truncate">{item.description || "-"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button size="icon" variant="outline" className="h-8 w-8" onClick={() => handleEdit(item)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+966
-45
File diff suppressed because it is too large
Load Diff
+68
-6
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+108
-42
@@ -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[] = []
|
||||||
|
let totalChapters = 0
|
||||||
|
let totalPages = 1
|
||||||
|
let firstChapterNumber: number | undefined
|
||||||
|
let seriesVolumes: Array<{
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
totalChapters: number
|
||||||
|
coverUrl: string | null
|
||||||
|
updatedAt: Date
|
||||||
|
}> = []
|
||||||
|
|
||||||
await connectToMongoDB()
|
await connectToMongoDB()
|
||||||
const skip = (currentPage - 1) * limit
|
|
||||||
|
|
||||||
const [chapters, totalChapters] = await Promise.all([
|
if (novel.seriesId) {
|
||||||
Chapter.find({ novelId: novel.id })
|
const [firstChapter, volumes] = await Promise.all([
|
||||||
.sort({ number: 1 })
|
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
|
||||||
.skip(skip)
|
prisma.novel.findMany({
|
||||||
.limit(limit)
|
where: { seriesId: novel.seriesId },
|
||||||
.select("id novelId number title createdAt views")
|
select: {
|
||||||
.lean(),
|
id: true,
|
||||||
Chapter.countDocuments({ novelId: novel.id })
|
slug: true,
|
||||||
])
|
title: true,
|
||||||
|
status: true,
|
||||||
|
totalChapters: true,
|
||||||
|
coverUrl: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalChapters / limit)
|
firstChapterNumber = (firstChapter as any)?.number
|
||||||
|
seriesVolumes = volumes
|
||||||
|
} else {
|
||||||
|
const skip = (currentPage - 1) * limit
|
||||||
|
const [chapters, chaptersCount, firstChapter] = await Promise.all([
|
||||||
|
Chapter.find({ novelId: novel.id })
|
||||||
|
.sort({ number: 1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.select("id novelId number title createdAt views volumeNumber volumeTitle volumeChapterNumber")
|
||||||
|
.lean(),
|
||||||
|
Chapter.countDocuments({ novelId: novel.id }),
|
||||||
|
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
|
||||||
|
])
|
||||||
|
|
||||||
// Convert Mongoose documents to plain objects for Server Component
|
totalChapters = chaptersCount
|
||||||
const formattedChapters = chapters.map(c => ({
|
totalPages = Math.ceil(totalChapters / limit)
|
||||||
id: c._id.toString(),
|
firstChapterNumber = (firstChapter as any)?.number
|
||||||
novelId: c.novelId,
|
|
||||||
number: c.number,
|
formattedChapters = chapters.map(c => ({
|
||||||
title: c.title,
|
id: c._id.toString(),
|
||||||
createdAt: (c.createdAt as Date).toISOString(),
|
novelId: c.novelId,
|
||||||
views: c.views || 0,
|
number: c.number,
|
||||||
content: "" // We don't fetch content for the list
|
volumeNumber: (c as any).volumeNumber ?? null,
|
||||||
}))
|
volumeTitle: (c as any).volumeTitle ?? null,
|
||||||
|
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
|
||||||
|
title: c.title,
|
||||||
|
createdAt: (c.createdAt as Date).toISOString(),
|
||||||
|
views: c.views || 0,
|
||||||
|
content: ""
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
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,19 +225,48 @@ 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 */}
|
||||||
<section className="mt-8">
|
{novel.seriesId ? (
|
||||||
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2>
|
<section className="mt-8">
|
||||||
<div className="rounded-lg border border-border bg-card">
|
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Quyển</h2>
|
||||||
<ChapterList
|
<div className="rounded-lg border border-border bg-card divide-y divide-border">
|
||||||
chapters={formattedChapters as any}
|
{seriesVolumes.map((volume, idx) => (
|
||||||
novelSlug={novel.slug}
|
<Link
|
||||||
currentPage={currentPage}
|
key={volume.id}
|
||||||
totalPages={totalPages}
|
href={`/truyen/${volume.slug}`}
|
||||||
totalChapters={totalChapters}
|
className={`flex items-center gap-4 px-4 py-3 hover:bg-muted/40 transition-colors ${volume.id === novel.id ? "bg-primary/5" : ""}`}
|
||||||
/>
|
>
|
||||||
</div>
|
<span className="w-8 text-center text-sm font-semibold text-muted-foreground">{idx + 1}</span>
|
||||||
</section>
|
<img
|
||||||
|
src={volume.coverUrl || "/default-cover.svg"}
|
||||||
|
alt={volume.title}
|
||||||
|
className="h-14 w-10 rounded bg-muted object-contain"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-foreground truncate">{volume.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{volume.totalChapters} chương</p>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(volume.status)}`}>
|
||||||
|
{volume.status}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="mb-3 text-lg font-bold text-foreground">Danh Sách Chương</h2>
|
||||||
|
<div className="rounded-lg border border-border bg-card">
|
||||||
|
<ChapterList
|
||||||
|
chapters={formattedChapters as any}
|
||||||
|
novelSlug={novel.slug}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalChapters={totalChapters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+74
-18
@@ -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,28 +34,64 @@ 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) => {
|
||||||
<Link
|
const currentVolumeKey = chapter.volumeNumber || chapter.volumeTitle
|
||||||
key={chapter.id}
|
? `${chapter.volumeNumber ?? "no-num"}-${chapter.volumeTitle ?? "no-title"}`
|
||||||
href={`/truyen/${novelSlug}/${chapter.number}`}
|
: null
|
||||||
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"
|
const showVolumeHeader = currentVolumeKey !== null && currentVolumeKey !== lastVolumeKey
|
||||||
>
|
if (currentVolumeKey !== null) {
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
lastVolumeKey = currentVolumeKey
|
||||||
<span className="shrink-0 font-medium text-muted-foreground">Ch. {chapter.number}</span>
|
}
|
||||||
<span className="truncate text-foreground">{chapter.title}</span>
|
|
||||||
|
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
|
||||||
|
href={`/truyen/${novelSlug}/${chapter.number}`}
|
||||||
|
className="flex items-center justify-between border-b border-border px-2 py-3 text-sm transition-colors hover:bg-muted/50 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="shrink-0 font-medium text-muted-foreground">
|
||||||
|
{chapter.volumeChapterNumber ? `Ch. ${chapter.volumeChapterNumber}` : `Ch. ${chapter.number}`}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-foreground">{chapter.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="hidden items-center gap-1 sm:flex">
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
{formatViews(chapter.views)}
|
||||||
|
</span>
|
||||||
|
<span>{chapter.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
)
|
||||||
<span className="hidden items-center gap-1 sm:flex">
|
})}
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
{formatViews(chapter.views)}
|
|
||||||
</span>
|
|
||||||
<span>{chapter.createdAt}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{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
@@ -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) }}>
|
||||||
|
|||||||
+13
-10
@@ -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">
|
</span>
|
||||||
Đang ra
|
|
||||||
</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
@@ -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>
|
||||||
|
|||||||
+41
-21
@@ -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,27 +100,44 @@ 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 (
|
||||||
<Link
|
<div key={chap.id}>
|
||||||
key={chap.id}
|
{showVolumeHeader && (
|
||||||
id={`toc-chap-${chap.number}`}
|
<div className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-primary">
|
||||||
href={`/truyen/${novelSlug}/${chap.number}`}
|
{chap.volumeTitle || `Quyển ${chap.volumeNumber}`}
|
||||||
onClick={() => setIsOpen(false)}
|
</div>
|
||||||
className={`block px-3 py-2 text-sm rounded-md transition-colors ${
|
)}
|
||||||
isActive
|
<Link
|
||||||
? 'bg-primary text-primary-foreground font-medium'
|
id={`toc-chap-${chap.number}`}
|
||||||
: 'hover:bg-muted text-foreground/80'
|
href={`/truyen/${novelSlug}/${chap.number}`}
|
||||||
}`}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
className={`block px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
<span className={isActive ? "text-primary-foreground/90 font-bold mr-2 lg:mr-3" : "text-muted-foreground mr-2 lg:mr-3"}>
|
isActive
|
||||||
{chap.number}.
|
? 'bg-primary text-primary-foreground font-medium'
|
||||||
</span>
|
: 'hover:bg-muted text-foreground/80'
|
||||||
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
|
}`}
|
||||||
</Link>
|
>
|
||||||
|
<span className={isActive ? "text-primary-foreground/90 font-bold mr-2 lg:mr-3" : "text-muted-foreground mr-2 lg:mr-3"}>
|
||||||
|
{chap.volumeChapterNumber || chap.number}.
|
||||||
|
</span>
|
||||||
|
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>{`
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function getNovelStatusBadgeClass(status: string): string {
|
||||||
|
const normalized = status.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (normalized.includes("hoàn")) {
|
||||||
|
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("tạm")) {
|
||||||
|
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("drop") || normalized.includes("hủy") || normalized.includes("cancel")) {
|
||||||
|
return "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300"
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
type UploadToR2Options = {
|
||||||
|
buffer: Buffer
|
||||||
|
contentType?: string | null
|
||||||
|
keyPrefix?: string
|
||||||
|
fileNameHint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedClient: S3Client | null = null
|
||||||
|
|
||||||
|
function requiredEnv(name: string): string {
|
||||||
|
const value = process.env[name]
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalEnv(name: string): string | null {
|
||||||
|
const value = process.env[name]
|
||||||
|
return value ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getR2Config() {
|
||||||
|
const accountId = requiredEnv("R2_ACCOUNT_ID")
|
||||||
|
const accessKeyId = requiredEnv("R2_ACCESS_KEY_ID")
|
||||||
|
const secretAccessKey = requiredEnv("R2_SECRET_ACCESS_KEY")
|
||||||
|
const bucket = requiredEnv("R2_BUCKET_NAME")
|
||||||
|
const publicBaseUrl = requiredEnv("R2_PUBLIC_BASE_URL")
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
bucket,
|
||||||
|
publicBaseUrl: publicBaseUrl.replace(/\/+$/, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getR2Client(): S3Client {
|
||||||
|
if (cachedClient) return cachedClient
|
||||||
|
|
||||||
|
const { accountId, accessKeyId, secretAccessKey } = getR2Config()
|
||||||
|
|
||||||
|
cachedClient = new S3Client({
|
||||||
|
region: "auto",
|
||||||
|
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return cachedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromMimeType(mimeType: string | null | undefined): string {
|
||||||
|
if (!mimeType) return ".jpg"
|
||||||
|
const normalized = mimeType.toLowerCase()
|
||||||
|
if (normalized.includes("png")) return ".png"
|
||||||
|
if (normalized.includes("webp")) return ".webp"
|
||||||
|
if (normalized.includes("gif")) return ".gif"
|
||||||
|
if (normalized.includes("avif")) return ".avif"
|
||||||
|
if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"
|
||||||
|
return ".jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromHint(fileNameHint?: string): string {
|
||||||
|
if (!fileNameHint) return ""
|
||||||
|
const ext = path.extname(fileNameHint).toLowerCase()
|
||||||
|
if (!ext) return ""
|
||||||
|
if (!/^\.[a-z0-9]{1,8}$/.test(ext)) return ""
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadBufferToR2(options: UploadToR2Options): Promise<string> {
|
||||||
|
const client = getR2Client()
|
||||||
|
const { bucket, publicBaseUrl } = getR2Config()
|
||||||
|
|
||||||
|
const keyPrefix = (options.keyPrefix || "covers").replace(/^\/+|\/+$/g, "")
|
||||||
|
const ext = extensionFromHint(options.fileNameHint) || extensionFromMimeType(options.contentType)
|
||||||
|
const key = `${keyPrefix}/${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: options.buffer,
|
||||||
|
ContentType: options.contentType || "application/octet-stream",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${publicBaseUrl}/${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getR2ObjectKeyFromUrl(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
const publicBaseUrl = optionalEnv("R2_PUBLIC_BASE_URL")?.replace(/\/+$/, "")
|
||||||
|
if (!publicBaseUrl) return null
|
||||||
|
|
||||||
|
let baseUrl: URL
|
||||||
|
let fileUrl: URL
|
||||||
|
|
||||||
|
try {
|
||||||
|
baseUrl = new URL(publicBaseUrl)
|
||||||
|
fileUrl = new URL(url)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.origin !== fileUrl.origin) return null
|
||||||
|
|
||||||
|
const basePath = baseUrl.pathname.replace(/\/+$/, "")
|
||||||
|
if (!fileUrl.pathname.startsWith(basePath)) return null
|
||||||
|
|
||||||
|
const relativePath = fileUrl.pathname.slice(basePath.length).replace(/^\/+/, "")
|
||||||
|
if (!relativePath) return null
|
||||||
|
|
||||||
|
return decodeURIComponent(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteR2ObjectByUrl(url: string | null | undefined): Promise<boolean> {
|
||||||
|
const key = getR2ObjectKeyFromUrl(url)
|
||||||
|
if (!key) return false
|
||||||
|
|
||||||
|
const client = getR2Client()
|
||||||
|
const { bucket } = getR2Config()
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ export interface Novel {
|
|||||||
title: string
|
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
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+1191
File diff suppressed because it is too large
Load Diff
@@ -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 |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user