Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -26,15 +26,22 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Chapter not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify the moderator owns the related novel
|
||||
// Verify the moderator owns the related novel (or is an ADMIN)
|
||||
let novelQuery: any = { id: chapter.novelId }
|
||||
if (session.user.role !== "ADMIN") {
|
||||
novelQuery.uploaderId = session.user.id
|
||||
}
|
||||
|
||||
const novel = await prisma.novel.findFirst({
|
||||
where: {
|
||||
id: chapter.novelId,
|
||||
uploaderId: session.user.id
|
||||
}
|
||||
where: novelQuery
|
||||
})
|
||||
|
||||
if (!novel) {
|
||||
console.log("Novel not found or unauthorized:", {
|
||||
chapterNovelId: chapter.novelId,
|
||||
userId: session.user.id,
|
||||
role: session.user.role
|
||||
})
|
||||
return NextResponse.json({ error: "Unauthorized access to this chapter" }, { status: 403 })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export async function 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 { novelId, action = "replace", findText, replaceText, matchCase = false, trashWords = "", preview = false } = body
|
||||
|
||||
if (!novelId) {
|
||||
return NextResponse.json({ error: "novelId is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify that the novel belongs to the uploader
|
||||
let novelQuery: any = { id: novelId }
|
||||
if (session.user.role !== "ADMIN") {
|
||||
novelQuery.uploaderId = session.user.id
|
||||
}
|
||||
|
||||
const novel = await prisma.novel.findFirst({
|
||||
where: novelQuery,
|
||||
})
|
||||
|
||||
if (!novel) {
|
||||
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 })
|
||||
}
|
||||
|
||||
await connectToMongoDB()
|
||||
|
||||
let patterns: { regex: RegExp, replaceWith: string }[] = []
|
||||
|
||||
if (action === "replace") {
|
||||
if (!findText) return NextResponse.json({ error: "findText is required for replace action" }, { status: 400 })
|
||||
const flags = matchCase ? "g" : "gi"
|
||||
const safeFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
patterns.push({ regex: new RegExp(safeFindText, flags), replaceWith: replaceText || "" })
|
||||
} else if (action === "trash") {
|
||||
let words: string[] = []
|
||||
if (Array.isArray(trashWords)) {
|
||||
words = trashWords
|
||||
} else if (typeof trashWords === "string") {
|
||||
words = trashWords.split(',').map((w: string) => w.trim()).filter((w: string) => w.length > 0)
|
||||
}
|
||||
|
||||
if (words.length === 0) return NextResponse.json({ error: "No valid words provided" }, { status: 400 })
|
||||
|
||||
words.forEach((word: string) => {
|
||||
const safeWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
patterns.push({ regex: new RegExp(safeWord, 'gi'), replaceWith: "" })
|
||||
})
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Find all chapters for the novel
|
||||
const chapters = await Chapter.find({ novelId }).sort({ number: 1 })
|
||||
let updatedCount = 0
|
||||
let previewResults: any[] = []
|
||||
|
||||
for (const chap of chapters) {
|
||||
let originalContent = chap.content || ""
|
||||
let newContent = originalContent
|
||||
let modified = false
|
||||
|
||||
patterns.forEach(({ regex, replaceWith }) => {
|
||||
if (regex.test(newContent)) {
|
||||
modified = true
|
||||
newContent = newContent.replace(regex, replaceWith)
|
||||
}
|
||||
})
|
||||
|
||||
if (modified) {
|
||||
if (preview && previewResults.length < 5) { // Limit previews to 5 chapters to save payload size
|
||||
// Capture a small text snippet from the first pattern match
|
||||
let snippet = ""
|
||||
if (patterns.length > 0) {
|
||||
const match = patterns[0].regex.exec(originalContent)
|
||||
if (match) {
|
||||
const matchIndex = match.index
|
||||
const start = Math.max(0, matchIndex - 30)
|
||||
const end = Math.min(originalContent.length, matchIndex + match[0].length + 30)
|
||||
snippet = "..." + originalContent.substring(start, end).replace(/\n/g, ' ') + "..."
|
||||
}
|
||||
}
|
||||
|
||||
previewResults.push({
|
||||
chapterId: chap._id,
|
||||
number: chap.number,
|
||||
title: chap.title,
|
||||
snippet
|
||||
})
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
chap.content = newContent
|
||||
await chap.save()
|
||||
}
|
||||
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: preview ? "Preview generated" : "Success",
|
||||
updatedChapters: updatedCount,
|
||||
previews: previewResults
|
||||
}, { status: 200 })
|
||||
|
||||
} catch (error) {
|
||||
console.error("Global Replace Error:", error)
|
||||
return NextResponse.json({ error: "Failed to perform global replacement" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: novelId } = await params
|
||||
|
||||
try {
|
||||
const novel = await prisma.novel.findUnique({
|
||||
where: { id: novelId },
|
||||
select: { trashWords: true, uploaderId: true }
|
||||
})
|
||||
|
||||
if (!novel) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ trashWords: novel.trashWords })
|
||||
} catch (error) {
|
||||
console.error("GET Trash Words Error:", error)
|
||||
return NextResponse.json({ error: "Lỗi Server" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: novelId } = await params
|
||||
|
||||
try {
|
||||
const novel = await prisma.novel.findUnique({
|
||||
where: { id: novelId },
|
||||
select: { id: true, uploaderId: true }
|
||||
})
|
||||
|
||||
if (!novel) return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||
|
||||
if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { trashWords } = body
|
||||
|
||||
if (!Array.isArray(trashWords)) {
|
||||
return NextResponse.json({ error: "Mảng từ rác không hợp lệ" }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await prisma.novel.update({
|
||||
where: { id: novelId },
|
||||
data: { trashWords }
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, trashWords: updated.trashWords })
|
||||
} catch (error) {
|
||||
console.error("PUT Trash Words Error:", error)
|
||||
return NextResponse.json({ error: "Lỗi Server" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -29,16 +29,19 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const data = await req.json()
|
||||
const { title, authorName, description, genreIds = [] } = data
|
||||
const { title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds = [] } = data
|
||||
// Tạo slug từ title
|
||||
const slug = slugify(title)
|
||||
|
||||
const newNovel = await prisma.novel.create({
|
||||
data: {
|
||||
title,
|
||||
originalTitle,
|
||||
slug: slug,
|
||||
authorName,
|
||||
originalAuthorName,
|
||||
description,
|
||||
coverUrl,
|
||||
uploaderId: session.user.id,
|
||||
genres: {
|
||||
create: genreIds.map((id: string) => ({
|
||||
@@ -61,15 +64,18 @@ export async function PUT(req: Request) {
|
||||
|
||||
try {
|
||||
const data = await req.json()
|
||||
const { id, title, authorName, description, status, genreIds } = data
|
||||
const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data
|
||||
|
||||
// Update basic info and recreate genre relations
|
||||
const updatedNovel = await prisma.novel.update({
|
||||
where: { id: id, uploaderId: session.user.id }, // Make sure they own it
|
||||
data: {
|
||||
title,
|
||||
originalTitle,
|
||||
authorName,
|
||||
originalAuthorName,
|
||||
description,
|
||||
coverUrl,
|
||||
status,
|
||||
// Replace all existing genres if genreIds is provided
|
||||
...(genreIds !== undefined && {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { writeFile } from "fs/promises"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get("file") as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 })
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9)
|
||||
const ext = path.extname(file.name) || ".jpg"
|
||||
const filename = `cover-${uniqueSuffix}${ext}`
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "public", "uploads", "covers")
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
|
||||
const filepath = path.join(uploadDir, filename)
|
||||
await writeFile(filepath, buffer)
|
||||
|
||||
return NextResponse.json({ url: `/uploads/covers/${filename}` })
|
||||
} catch (error: any) {
|
||||
console.error("Cover upload error:", error)
|
||||
return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> } // `id` is the `novel.id`
|
||||
) {
|
||||
try {
|
||||
const { id: novelId } = await params
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const page = parseInt(searchParams.get("page") || "1", 10)
|
||||
const limit = parseInt(searchParams.get("limit") || "100", 10)
|
||||
|
||||
await connectToMongoDB()
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [chapters, totalChapters] = await Promise.all([
|
||||
Chapter.find({ novelId })
|
||||
.sort({ number: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select("number title createdAt") // don't return content
|
||||
.lean(),
|
||||
Chapter.countDocuments({ novelId })
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
chapters: chapters.map(c => ({
|
||||
id: c._id.toString(),
|
||||
number: c.number,
|
||||
title: c.title,
|
||||
createdAt: (c.createdAt as Date).toISOString()
|
||||
})),
|
||||
totalChapters,
|
||||
totalPages: Math.ceil(totalChapters / limit),
|
||||
currentPage: page
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Fetch novel chapters error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Không thể lấy danh sách chương" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,39 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "Missing chapter info" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Lấy bookmark cũ (nếu có)
|
||||
const existingBookmark = await prisma.bookmark.findUnique({
|
||||
where: {
|
||||
userId_novelId: {
|
||||
userId: session.user.id,
|
||||
novelId,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let newReadChapters: number[] = []
|
||||
let newHasCountedView = false
|
||||
let shouldIncrementNovelView = false
|
||||
|
||||
if (existingBookmark) {
|
||||
newReadChapters = existingBookmark.readChapters || []
|
||||
newHasCountedView = existingBookmark.hasCountedView
|
||||
|
||||
// Nếu chương này chưa đọc, thêm vào mảng
|
||||
if (!newReadChapters.includes(lastChapterNumber)) {
|
||||
newReadChapters.push(lastChapterNumber)
|
||||
}
|
||||
|
||||
// Nếu đọc đủ 5 chương và chưa từng đếm view
|
||||
if (newReadChapters.length >= 5 && !newHasCountedView) {
|
||||
newHasCountedView = true
|
||||
shouldIncrementNovelView = true
|
||||
}
|
||||
} else {
|
||||
newReadChapters = [lastChapterNumber]
|
||||
// Chưa đủ 5 chương ngay từ lần đầu tạo
|
||||
}
|
||||
|
||||
const bookmark = await prisma.bookmark.upsert({
|
||||
where: {
|
||||
userId_novelId: {
|
||||
@@ -87,15 +120,27 @@ export async function POST(req: Request) {
|
||||
},
|
||||
update: {
|
||||
lastChapterId,
|
||||
lastChapterNumber
|
||||
lastChapterNumber,
|
||||
readChapters: newReadChapters,
|
||||
hasCountedView: newHasCountedView
|
||||
},
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
novelId,
|
||||
lastChapterId,
|
||||
lastChapterNumber
|
||||
lastChapterNumber,
|
||||
readChapters: newReadChapters,
|
||||
hasCountedView: newHasCountedView
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldIncrementNovelView) {
|
||||
await prisma.novel.update({
|
||||
where: { id: novelId },
|
||||
data: { views: { increment: 1 } }
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "updated", bookmark })
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function LoginPage() {
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center gap-2 text-primary">
|
||||
<BookOpen className="h-6 w-6" />
|
||||
<span className="text-xl font-bold text-foreground">TruyenChu</span>
|
||||
<span className="text-xl font-bold text-foreground">Virtus's Reader</span>
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold text-foreground">Chao mung ban</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
|
||||
+1
-3
@@ -1,6 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Be_Vietnam_Pro } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import { AuthProvider } from '@/lib/auth-context'
|
||||
import { BookmarkProvider } from '@/lib/bookmark-context'
|
||||
@@ -15,7 +14,7 @@ const beVietnam = Be_Vietnam_Pro({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'TruyenChu - Đọc Truyện Chữ Online',
|
||||
title: "Virtus's Reader - Đọc Truyện Chữ Online",
|
||||
description: 'Đọc truyện chữ online miễn phí - Tiên hiệp, Huyền huyễn, Ngôn tình, Kiếm hiệp và nhiều thể loại khác',
|
||||
generator: 'v0.app',
|
||||
icons: {
|
||||
@@ -63,7 +62,6 @@ export default function RootLayout({
|
||||
</BookmarkProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Loader2, ArrowLeft, Save, SplitSquareHorizontal, Search, Trash2, X, Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
||||
// Helper to convert plain text with URLs to HTML with <a> tags
|
||||
const renderWithLinks = (text: string) => {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
return text.split('\n').map((paragraph, index) => {
|
||||
if (!paragraph.trim()) return <br key={index} />
|
||||
|
||||
const parts = paragraph.split(urlRegex)
|
||||
return (
|
||||
<p key={index} className="mb-4 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.match(urlRegex)) {
|
||||
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{part}</a>
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
const router = useRouter()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [novel, setNovel] = useState<any>(null)
|
||||
|
||||
// Core states
|
||||
const [number, setNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalNovelId, setOriginalNovelId] = useState("")
|
||||
|
||||
// Tool states
|
||||
const [openToolDialog, setOpenToolDialog] = useState(false)
|
||||
const [toolAction, setToolAction] = useState<"replace" | "trash">("replace")
|
||||
const [toolScope, setToolScope] = useState<"chapter" | "novel">("chapter")
|
||||
const [toolFindText, setToolFindText] = useState("")
|
||||
const [toolReplaceText, setToolReplaceText] = useState("")
|
||||
const [toolTrashWords, setToolTrashWords] = useState("") // Just for the input box
|
||||
const [novelTrashWords, setNovelTrashWords] = useState<string[]>([]) // Persisted DB array
|
||||
const [toolMatchCase, setToolMatchCase] = useState(false)
|
||||
const [toolExecuting, setToolExecuting] = useState(false)
|
||||
const [toolPreviewing, setToolPreviewing] = useState(false)
|
||||
const [toolPreviewResults, setToolPreviewResults] = useState<any[]>([])
|
||||
|
||||
// UI Layout states
|
||||
const [splitView, setSplitView] = useState(true)
|
||||
|
||||
// Sync Scroll Refs
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const isScrolling = useRef<'textarea' | 'preview' | null>(null)
|
||||
const scrollTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChapter = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mod/chuong/${chapterId}`)
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
console.error("Fetch chapter error:", res.status, errorData)
|
||||
throw new Error(errorData.error || `Không thể tải chương (${res.status})`)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
setNumber(data.number.toString())
|
||||
setTitle(data.title)
|
||||
setContent(data.content)
|
||||
setOriginalNovelId(data.novelId)
|
||||
|
||||
// Fetch novel details to show breadcrumbs
|
||||
const novelRes = await fetch(`/api/truyen?slug=${data.novelId}`)
|
||||
if (novelRes.ok) {
|
||||
const novelData = await novelRes.json()
|
||||
setNovel(novelData)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load:", error)
|
||||
toast.error(error.message || "Lỗi khi tải dữ liệu chương")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchChapter()
|
||||
}, [chapterId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!originalNovelId) return
|
||||
const fetchTrashWords = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setNovelTrashWords(data.trashWords || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch trash words error:", e)
|
||||
}
|
||||
}
|
||||
fetchTrashWords()
|
||||
}, [originalNovelId])
|
||||
|
||||
const handleTextareaScroll = () => {
|
||||
if (!textareaRef.current || !previewRef.current) return
|
||||
if (isScrolling.current === 'preview') return
|
||||
isScrolling.current = 'textarea'
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = textareaRef.current
|
||||
const percentage = scrollTop / (scrollHeight - clientHeight) || 0
|
||||
|
||||
const maxPreviewScroll = previewRef.current.scrollHeight - previewRef.current.clientHeight
|
||||
previewRef.current.scrollTop = percentage * maxPreviewScroll
|
||||
|
||||
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
|
||||
scrollTimeout.current = setTimeout(() => { isScrolling.current = null }, 50)
|
||||
}
|
||||
|
||||
const handlePreviewScroll = () => {
|
||||
if (!textareaRef.current || !previewRef.current) return
|
||||
if (isScrolling.current === 'textarea') return
|
||||
isScrolling.current = 'preview'
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = previewRef.current
|
||||
const percentage = scrollTop / (scrollHeight - clientHeight) || 0
|
||||
|
||||
const maxTextareaScroll = textareaRef.current.scrollHeight - textareaRef.current.clientHeight
|
||||
textareaRef.current.scrollTop = percentage * maxTextareaScroll
|
||||
|
||||
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
|
||||
scrollTimeout.current = setTimeout(() => { isScrolling.current = null }, 50)
|
||||
}
|
||||
|
||||
const handleAddTrashWord = async () => {
|
||||
if (!toolTrashWords.trim()) return
|
||||
const newWords = [...novelTrashWords, toolTrashWords]
|
||||
setNovelTrashWords(newWords)
|
||||
setToolTrashWords("")
|
||||
try {
|
||||
await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trashWords: newWords })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Save trash words error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTrashWord = async (index: number) => {
|
||||
const newWords = novelTrashWords.filter((_, i) => i !== index)
|
||||
setNovelTrashWords(newWords)
|
||||
try {
|
||||
await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trashWords: newWords })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Save trash words error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title || !content || !number) {
|
||||
toast.error("Vui lòng điền đủ thông tin")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: chapterId,
|
||||
novelId: originalNovelId,
|
||||
number: parseInt(number),
|
||||
title,
|
||||
content
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Cập nhật thất bại")
|
||||
|
||||
toast.success("Đã lưu chương thành công!")
|
||||
router.push(`/mod/chuong?novelId=${originalNovelId}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToolExecute = async (isPreview: boolean = false) => {
|
||||
if (toolAction === "replace" && !toolFindText) {
|
||||
toast.error("Vui lòng nhập từ khóa cần tìm")
|
||||
return
|
||||
}
|
||||
if (toolAction === "trash" && novelTrashWords.length === 0) {
|
||||
toast.error("Danh sách từ rác trống. Vui lòng thêm từ rác trước.")
|
||||
return
|
||||
}
|
||||
|
||||
if (toolScope === "chapter") {
|
||||
if (isPreview) {
|
||||
toast.info("Xem trước chỉ áp dụng cho Toàn Truyện. Xin hãy áp dụng ngay cho chương này.")
|
||||
return
|
||||
}
|
||||
|
||||
let newContent = content
|
||||
let count = 0
|
||||
const flags = toolMatchCase ? 'g' : 'gi'
|
||||
|
||||
if (toolAction === "replace") {
|
||||
const safeFindText = toolFindText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(safeFindText, flags)
|
||||
const matches = newContent.match(regex)
|
||||
if (matches) count = matches.length
|
||||
newContent = newContent.replace(regex, toolReplaceText)
|
||||
toast.success(`Đã thay thế ${count} lần nhóm từ "${toolFindText}" thành "${toolReplaceText}"`)
|
||||
} else {
|
||||
novelTrashWords.forEach(word => {
|
||||
const safeWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(safeWord, flags)
|
||||
const matches = newContent.match(regex)
|
||||
if (matches) count += matches.length
|
||||
newContent = newContent.replace(regex, '')
|
||||
})
|
||||
toast.success(`Đã lọc bỏ ${count} từ rác`)
|
||||
}
|
||||
setContent(newContent)
|
||||
setOpenToolDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Global replace (Entire novel scope)
|
||||
if (isPreview) setToolPreviewing(true)
|
||||
else setToolExecuting(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong/global-replace", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
novelId: originalNovelId,
|
||||
action: toolAction,
|
||||
findText: toolFindText,
|
||||
replaceText: toolReplaceText,
|
||||
trashWords: novelTrashWords,
|
||||
matchCase: toolMatchCase,
|
||||
preview: isPreview
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Thao tác thất bại")
|
||||
|
||||
if (isPreview) {
|
||||
setToolPreviewResults(data.previews || [])
|
||||
if (data.previews?.length === 0) {
|
||||
toast.info("Không tìm thấy kết quả nào trùng khớp trên toàn truyện")
|
||||
}
|
||||
} else {
|
||||
toast.success(`Đã chạy công cụ thành công trên ${data.updatedChapters} chương!`)
|
||||
toast.info("Trang đang tự tải lại để cập nhật nội dung mới...", { duration: 2000 })
|
||||
setTimeout(() => window.location.reload(), 2000)
|
||||
setOpenToolDialog(false)
|
||||
setToolPreviewResults([])
|
||||
setToolFindText("")
|
||||
setToolReplaceText("")
|
||||
setToolTrashWords("")
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
if (isPreview) setToolPreviewing(false)
|
||||
else setToolExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center items-center h-[50vh]"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-7xl mx-auto flex flex-col h-[calc(100vh-6rem)]">
|
||||
{/* Header & Breadcrumb */}
|
||||
<div className="flex items-center justify-between bg-card p-4 rounded-xl border shadow-sm shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/mod/chuong?novelId=${originalNovelId}`}>
|
||||
<Button variant="ghost" size="icon"><ArrowLeft className="w-5 h-5" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Chỉnh sửa Chương {number}</h1>
|
||||
<p className="text-sm text-muted-foreground">{novel?.title || `Novel ID: ${originalNovelId}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setSplitView(!splitView)} className="hidden md:flex gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
{splitView ? "Tắt Xem Trước" : "Bật Xem Trước"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Lưu Thay Đổi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Tools Bar */}
|
||||
<div className="bg-card p-3 rounded-xl border shadow-sm flex flex-wrap items-center gap-4 shrink-0">
|
||||
<Button variant="secondary" onClick={() => { setToolAction("replace"); setOpenToolDialog(true) }} className="gap-2">
|
||||
<Search className="w-4 h-4 text-muted-foreground" /> Tìm & Thay Thế
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setToolAction("trash"); setOpenToolDialog(true) }} className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200">
|
||||
<Trash2 className="w-4 h-4" /> Dọn Dẹp Từ Rác
|
||||
</Button>
|
||||
|
||||
<Dialog open={openToolDialog} onOpenChange={(open) => {
|
||||
setOpenToolDialog(open)
|
||||
if (!open) setToolPreviewResults([])
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{toolAction === "replace" ? "Bộ công cụ: Tìm Kiếm & Thay Thế" : "Bộ công cụ: Dọn Dẹp Từ Rác"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{toolAction === "replace"
|
||||
? "Thay thế cụm từ trên chương này hoặc trên toàn bộ truyện."
|
||||
: "Xóa bỏ các cụm từ rác, watermark trên chương này hoặc trên toàn bộ truyện."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 custom-scrollbar pr-2">
|
||||
{toolPreviewResults.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg">Bản xem trước ({toolPreviewResults.length} ví dụ)</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => setToolPreviewResults([])}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Quay lại tuỳ chỉnh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{toolPreviewResults.map((res: any) => (
|
||||
<div key={res.chapterId} className="p-3 border rounded-lg bg-card text-left">
|
||||
<div className="text-sm font-medium mb-1">Chương {res.number}: {res.title}</div>
|
||||
<div className="text-sm text-muted-foreground bg-muted p-2 rounded italic font-serif">
|
||||
{res.snippet}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Scope Selector */}
|
||||
<div className="p-3 border rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-medium mb-2">Phạm vi áp dụng thao tác:</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="toolScope" checked={toolScope === "chapter"} onChange={() => setToolScope("chapter")} />
|
||||
<span>Chỉ Chương Này</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="toolScope" checked={toolScope === "novel"} onChange={() => setToolScope("novel")} />
|
||||
<span className="text-primary font-medium">Toàn Bộ Truyện</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Content */}
|
||||
{toolAction === "replace" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tìm cụm từ:</label>
|
||||
<Input
|
||||
placeholder="Ví dụ: truyenchu.vn"
|
||||
value={toolFindText}
|
||||
onChange={(e) => setToolFindText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thay thế bằng (Bỏ trống để xóa hoàn toàn):</label>
|
||||
<Input
|
||||
placeholder="..."
|
||||
value={toolReplaceText}
|
||||
onChange={(e) => setToolReplaceText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-2 w-max">
|
||||
<input type="checkbox" className="w-4 h-4 rounded" checked={toolMatchCase} onChange={(e) => setToolMatchCase(e.target.checked)} />
|
||||
<span className="text-sm">Phân biệt chữ Hoa / chữ thường</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{novelTrashWords.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Danh sách từ rác hiện tại:</label>
|
||||
<div className="flex flex-col gap-2 max-h-[40vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{novelTrashWords.map((word, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-3 relative bg-red-50 dark:bg-red-950/20 text-red-900 border border-red-200 dark:border-red-900/50 rounded-lg group">
|
||||
<pre className="text-sm flex-1 whitespace-pre-wrap font-sans">{word}</pre>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500 hover:text-red-700 hover:bg-red-100 shrink-0" onClick={() => handleRemoveTrashWord(idx)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mt-4 pt-4 border-t">
|
||||
<label className="text-sm font-medium">Thêm từ rác (Hỗ trợ nhiều dòng với Enter):</label>
|
||||
<Textarea
|
||||
placeholder="Ví dụ:\n\n.\n\n.\n\n."
|
||||
value={toolTrashWords}
|
||||
onChange={(e) => setToolTrashWords(e.target.value)}
|
||||
className="resize-none h-24"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddTrashWord} disabled={!toolTrashWords.trim()} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" /> Thêm vào danh sách CSDL
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Các cụm từ rác sẽ được lưu lại cho toàn bộ truyện. Chế độ lọc rác tự động tìm kiếm không phân biệt Hoa/thường.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-auto pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setOpenToolDialog(false)
|
||||
setToolPreviewResults([])
|
||||
}}>Đóng</Button>
|
||||
|
||||
{toolPreviewResults.length === 0 ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => handleToolExecute(true)} disabled={toolPreviewing || toolScope === 'chapter' || (toolAction === 'replace' ? !toolFindText : novelTrashWords.length === 0)}>
|
||||
{toolPreviewing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Xem Trước
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleToolExecute(false)} disabled={toolExecuting || (toolAction === 'trash' && novelTrashWords.length === 0)}>
|
||||
{toolExecuting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Áp Dụng Thực Thay
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="destructive" onClick={() => handleToolExecute(false)} disabled={toolExecuting || (toolAction === 'trash' && novelTrashWords.length === 0)}>
|
||||
{toolExecuting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Đã Chắc Chắn, Bắt Đầu Thay Thế!
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Editor Workspace */}
|
||||
<div className="flex flex-col flex-1 pb-4 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 flex gap-4 min-h-0 ${splitView ? 'flex-row' : 'flex-col'}`}>
|
||||
{/* Left: Raw Textarea */}
|
||||
<div className={`flex flex-col flex-1 h-full min-h-0 border rounded-xl overflow-hidden bg-background shadow-inner`}>
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold uppercase text-muted-foreground shrink-0">
|
||||
Nội Dung Nguồn
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onScroll={handleTextareaScroll}
|
||||
className="flex-1 w-full p-4 resize-none border-0 focus-visible:ring-0 rounded-none h-full custom-scrollbar text-base"
|
||||
placeholder="Nội dung chương..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview (Only shown if splitView is true and on tablet/desktop) */}
|
||||
<div className={`flex flex-col flex-1 h-full min-h-0 border rounded-xl overflow-hidden bg-background shadow-inner ${splitView ? 'hidden md:flex' : 'hidden'}`}>
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold uppercase text-muted-foreground shrink-0 flex justify-between">
|
||||
<span>Bản Hiển Thị</span>
|
||||
<span className="text-primary normal-case">Link được nhận diện tự động</span>
|
||||
</div>
|
||||
<div
|
||||
ref={previewRef}
|
||||
onScroll={handlePreviewScroll}
|
||||
className="flex-1 overflow-y-auto p-6 bg-card custom-scrollbar"
|
||||
>
|
||||
<div className="prose prose-sm md:prose-base dark:prose-invert max-w-none font-serif">
|
||||
{content ? renderWithLinks(content) : <p className="text-muted-foreground italic">Nội dung trống...</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { EditorClient } from "./editor-client"
|
||||
|
||||
export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
const resolvedParams = await params
|
||||
|
||||
return <EditorClient chapterId={resolvedParams.id} />
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, Suspense } from "react"
|
||||
import { useState, useEffect, Suspense, useRef } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -14,9 +14,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2 } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
// @ts-ignore
|
||||
import * as mammoth from "mammoth"
|
||||
|
||||
interface Chapter {
|
||||
_id: string
|
||||
@@ -70,11 +73,18 @@ function ChapterManager() {
|
||||
const [openDelete, setOpenDelete] = useState(false)
|
||||
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Form states
|
||||
const [number, setNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
// Multi-upload states
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingMulti, setUploadingMulti] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [totalUpload, setTotalUpload] = useState(0)
|
||||
|
||||
const fetchChapters = async (pageToFetch = 1) => {
|
||||
if (!novelId) return
|
||||
setLoading(true)
|
||||
@@ -136,6 +146,65 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMultiFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0 || !novelId) return
|
||||
|
||||
setUploadingMulti(true)
|
||||
setTotalUpload(files.length)
|
||||
setUploadProgress(0)
|
||||
|
||||
// Sort files by name to ensure order (e.g. Chapter 1, Chapter 2)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
|
||||
try {
|
||||
let currentNumber = parseInt(number) || 1
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
let content = ""
|
||||
if (file.name.endsWith(".txt")) {
|
||||
content = await file.text()
|
||||
} else if (file.name.endsWith(".docx")) {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.extractRawText({ arrayBuffer })
|
||||
content = result.value
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!content.trim()) continue // Bỏ qua file rỗng
|
||||
|
||||
let fileTitle = file.name.replace(/\.[^/.]+$/, "")
|
||||
|
||||
// Loại bỏ "Chương X: " khỏi file title nếu cần thiết
|
||||
let cleanedTitle = fileTitle.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
||||
if (!cleanedTitle) cleanedTitle = fileTitle
|
||||
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ novelId, number: currentNumber, title: cleanedTitle, content }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || `Lỗi khi lưu file ${file.name}`)
|
||||
}
|
||||
|
||||
currentNumber++
|
||||
setUploadProgress(i + 1)
|
||||
}
|
||||
|
||||
toast.success(`Đã tải lên thành công ${files.length} chương!`)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setUploadingMulti(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewOptimize = () => {
|
||||
let newChapters = [...chapters]
|
||||
|
||||
@@ -189,58 +258,10 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (chapter: Chapter) => {
|
||||
setEditingChapterId(chapter._id)
|
||||
setNumber(chapter.number.toString())
|
||||
setTitle(chapter.title)
|
||||
setContent("") // Khởi tạo rỗng trong lúc chờ fetch nội dung
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
try {
|
||||
// Lấy chi tiết chương từ list db để có nội dung qua API GET /api/mod/chuong/[id] vừa tạo
|
||||
const res = await fetch(`/api/mod/chuong/${chapter._id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data.content)
|
||||
} else {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
} finally {
|
||||
setLoadingEditData(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!number || !title || !content || !novelId || !editingChapterId) {
|
||||
toast.error("Vui lòng điền đầy đủ")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: editingChapterId, novelId, number: parseInt(number), title, content }),
|
||||
})
|
||||
|
||||
const resData = await res.json()
|
||||
if (!res.ok) throw new Error(resData.error || "Cập nhật thất bại")
|
||||
|
||||
toast.success("Đã cập nhật chương thành công!")
|
||||
setOpenEdit(false)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
// handleOpenEdit has been removed because edit is now via dedicated page
|
||||
|
||||
// handleDelete remains the same
|
||||
const handleDelete = async () => {
|
||||
if (!deletingChapterId || !novelId) return
|
||||
setSubmitting(true)
|
||||
@@ -285,6 +306,7 @@ function ChapterManager() {
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
|
||||
<Button variant="secondary" className="gap-2" onClick={() => {
|
||||
setOpenOptimize(true)
|
||||
setPreviewMode(false)
|
||||
@@ -292,6 +314,19 @@ function ChapterManager() {
|
||||
<Wand2 className="h-4 w-4" /> Tối ưu hóa
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleMultiFileUpload}
|
||||
multiple
|
||||
accept=".txt,.docx"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="secondary" className="gap-2" onClick={() => fileInputRef.current?.click()} disabled={uploadingMulti}>
|
||||
{uploadingMulti ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{uploadingMulti ? `Đang tải lên ${uploadProgress}/${totalUpload}...` : "Tải lên hàng loạt"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
@@ -336,51 +371,6 @@ function ChapterManager() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chỉnh Sửa Chương</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thay đổi nội dung hoặc thông tin chương truyện.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingEditData ? (
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEditSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<label className="text-sm font-medium">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<label className="text-sm font-medium">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 flex flex-col h-full">
|
||||
<label className="text-sm font-medium">Nội dung văn bản</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -471,6 +461,7 @@ function ChapterManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -498,9 +489,11 @@ function ChapterManager() {
|
||||
<td className="px-5 py-4 text-right">{ch.views}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50" onClick={() => handleOpenEdit(ch)}>
|
||||
<Edit className="w-4 h-4 mr-1" /> Sửa
|
||||
</Button>
|
||||
<Link href={`/mod/chuong/${ch._id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50">
|
||||
<Edit className="w-4 h-4 mr-1" /> Sửa
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
|
||||
setDeletingChapterId(ch._id)
|
||||
setOpenDelete(true)
|
||||
|
||||
+234
-58
@@ -13,7 +13,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2 } from "lucide-react"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -24,6 +24,7 @@ interface Novel {
|
||||
authorName: string
|
||||
status: string
|
||||
totalChapters: number
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
@@ -40,9 +41,16 @@ export function NovelClient() {
|
||||
|
||||
// Form states
|
||||
const [title, setTitle] = useState("")
|
||||
const [originalTitle, setOriginalTitle] = useState("")
|
||||
const [authorName, setAuthorName] = useState("")
|
||||
const [originalAuthorName, setOriginalAuthorName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [coverUrl, setCoverUrl] = useState("")
|
||||
const [status, setStatus] = useState("Đang ra")
|
||||
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
@@ -150,14 +158,17 @@ export function NovelClient() {
|
||||
const res = await fetch("/api/mod/truyen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, authorName, description, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
|
||||
body: JSON.stringify({ title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
|
||||
})
|
||||
if (!res.ok) throw new Error("Thêm mới thất bại")
|
||||
toast.success("Đã thêm truyện thành công!")
|
||||
setOpenAdd(false)
|
||||
setTitle("")
|
||||
setOriginalTitle("")
|
||||
setAuthorName("")
|
||||
setOriginalAuthorName("")
|
||||
setDescription("")
|
||||
setCoverUrl("")
|
||||
setStatus("Đang ra")
|
||||
setSelectedGenres([])
|
||||
fetchNovels()
|
||||
@@ -203,12 +214,47 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error("Vui lòng chọn file hình ảnh")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingCover(true)
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mod/upload-cover", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi khi tải lên ảnh bìa")
|
||||
|
||||
setCoverUrl(data.url)
|
||||
toast.success("Tải ảnh bìa thành công!")
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Có lỗi xảy ra khi xử lý ảnh bìa")
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (novel: Novel) => {
|
||||
setEditingNovel(novel)
|
||||
setTitle(novel.title)
|
||||
setAuthorName(novel.authorName)
|
||||
setStatus(novel.status)
|
||||
setDescription("")
|
||||
setOriginalTitle("")
|
||||
setOriginalAuthorName("")
|
||||
setCoverUrl(novel.coverUrl || "")
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
@@ -217,6 +263,8 @@ export function NovelClient() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDescription(data.description || "")
|
||||
setOriginalTitle(data.originalTitle || "")
|
||||
setOriginalAuthorName(data.originalAuthorName || "")
|
||||
if (data.genres && Array.isArray(data.genres)) {
|
||||
setSelectedGenres(data.genres.map((g: any) => g.genreId))
|
||||
} else {
|
||||
@@ -247,8 +295,11 @@ export function NovelClient() {
|
||||
body: JSON.stringify({
|
||||
id: editingNovel.id,
|
||||
title,
|
||||
originalTitle,
|
||||
authorName,
|
||||
originalAuthorName,
|
||||
description,
|
||||
coverUrl,
|
||||
genreIds: selectedGenres,
|
||||
status: status
|
||||
}),
|
||||
@@ -298,6 +349,27 @@ export function NovelClient() {
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex bg-muted rounded-md p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-8 px-2 ${viewMode === 'list' ? 'bg-background shadow-sm' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="Dạng danh sách"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-8 px-2 ${viewMode === 'grid' ? 'bg-background shadow-sm' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Dạng lưới"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="epub-upload"
|
||||
@@ -319,7 +391,7 @@ export function NovelClient() {
|
||||
<Dialog open={openAdd} onOpenChange={(val) => {
|
||||
setOpenAdd(val);
|
||||
if (val) {
|
||||
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName("");
|
||||
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSelectedGenres([]); setNewGenreName("");
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -340,9 +412,32 @@ export function NovelClient() {
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Phàm Nhân Tu Tiên" autoFocus />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<label className="text-sm font-medium">Tên gốc (Tùy chọn)</label>
|
||||
<Input value={originalTitle} onChange={(e) => setOriginalTitle(e.target.value)} placeholder="Ví dụ: 凡人修仙传" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Ví dụ: Vong Ngữ" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc (Tùy chọn)</label>
|
||||
<Input value={originalAuthorName} onChange={(e) => setOriginalAuthorName(e.target.value)} placeholder="Ví dụ: 忘语" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Ảnh bìa (Tùy chọn)</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="URL ảnh..." className="flex-1" />
|
||||
<input type="file" id="cover-upload-add" className="hidden" accept="image/*" onChange={handleCoverUpload} />
|
||||
<Button type="button" variant="secondary" onClick={() => document.getElementById('cover-upload-add')?.click()} disabled={uploadingCover}>
|
||||
{uploadingCover ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{coverUrl && (
|
||||
<div className="mt-2 w-24 h-32 rounded border overflow-hidden">
|
||||
<img src={coverUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thêm thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -408,9 +503,32 @@ export function NovelClient() {
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<label className="text-sm font-medium">Tên gốc (Tùy chọn)</label>
|
||||
<Input value={originalTitle} onChange={(e) => setOriginalTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc (Tùy chọn)</label>
|
||||
<Input value={originalAuthorName} onChange={(e) => setOriginalAuthorName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Ảnh bìa (Tùy chọn)</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="URL ảnh..." className="flex-1" />
|
||||
<input type="file" id="cover-upload-edit" className="hidden" accept="image/*" onChange={handleCoverUpload} />
|
||||
<Button type="button" variant="secondary" onClick={() => document.getElementById('cover-upload-edit')?.click()} disabled={uploadingCover}>
|
||||
{uploadingCover ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{coverUrl && (
|
||||
<div className="mt-2 w-24 h-32 rounded border overflow-hidden">
|
||||
<img src={coverUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cập nhật thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -498,61 +616,119 @@ export function NovelClient() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card overflow-hidden shadow-sm">
|
||||
<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 scope="col" className="px-5 py-4 font-semibold">Tên truyện</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Tác giả</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Số chương</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Trạng thái</th>
|
||||
<th scope="col" className="px-5 py-4 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="h-6 w-6 animate-spin mx-auto" /></td></tr>
|
||||
) : novels.length === 0 ? (
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</td></tr>
|
||||
) : (
|
||||
novels.map((novel) => (
|
||||
<tr key={novel.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">{novel.title}</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{novel.authorName}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
|
||||
{novel.totalChapters}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="bg-emerald-100 text-emerald-800 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
{novel.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
Cập nhật chương
|
||||
{viewMode === 'list' ? (
|
||||
<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 scope="col" className="px-5 py-4 font-semibold">Tên truyện</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Tác giả</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Số chương</th>
|
||||
<th scope="col" className="px-5 py-4 font-semibold">Trạng thái</th>
|
||||
<th scope="col" className="px-5 py-4 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="h-6 w-6 animate-spin mx-auto" /></td></tr>
|
||||
) : novels.length === 0 ? (
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</td></tr>
|
||||
) : (
|
||||
novels.map((novel) => (
|
||||
<tr key={novel.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 flex items-center gap-3">
|
||||
{novel.coverUrl ? (
|
||||
<img src={novel.coverUrl} alt={novel.title} className="w-8 h-10 object-cover rounded shadow-sm hidden sm:block" />
|
||||
) : (
|
||||
<div className="w-8 h-10 bg-muted rounded shadow-sm hidden sm:flex items-center justify-center text-muted-foreground"><BookOpen className="w-4 h-4" /></div>
|
||||
)}
|
||||
{novel.title}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{novel.authorName}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
|
||||
{novel.totalChapters}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="bg-emerald-100 text-emerald-800 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
{novel.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
Cập nhật chương
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit 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={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 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={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 sm:p-6 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
|
||||
{loading ? (
|
||||
<div className="col-span-full py-12 flex justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : novels.length === 0 ? (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</div>
|
||||
) : (
|
||||
novels.map((novel) => (
|
||||
<div key={novel.id} className="group relative flex flex-col rounded-xl overflow-hidden border shadow-sm transition-all hover:-translate-y-1 hover:shadow-md bg-card">
|
||||
<div className="aspect-[2/3] w-full bg-muted relative border-b">
|
||||
{novel.coverUrl ? (
|
||||
<img src={novel.coverUrl} alt={novel.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<BookOpen className="w-8 h-8 opacity-20" />
|
||||
<span className="text-xs opacity-50 font-medium">No Cover</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="bg-emerald-100 text-emerald-800 text-[10px] font-bold px-1.5 py-0.5 rounded shadow-sm dark:bg-emerald-900 dark:text-emerald-300">
|
||||
{novel.totalChapters} Chương
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex flex-col flex-1">
|
||||
<h3 className="font-semibold text-sm line-clamp-2 leading-tight mb-1" title={novel.title}>{novel.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">{novel.authorName}</p>
|
||||
|
||||
<div className="mt-auto grid grid-cols-2 gap-1.5">
|
||||
<Button size="sm" variant="outline" className="w-full h-7 text-xs px-0" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-3 w-3 mr-1" /> Sửa
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="w-full h-7 text-xs px-0 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3 mr-1" /> Xóa
|
||||
</Button>
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`} className="col-span-2">
|
||||
<Button size="sm" className="w-full h-7 text-xs">
|
||||
<List className="h-3 w-3 mr-1" /> DS Chương
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+32
-25
@@ -17,28 +17,39 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Shield: <Shield className="h-5 w-5" />,
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomePage() {
|
||||
const popularNovels = await prisma.novel.findMany({
|
||||
take: 6,
|
||||
orderBy: { views: "desc" },
|
||||
})
|
||||
let popularNovels: any[] = []
|
||||
let latestNovels: any[] = []
|
||||
let topRated: any[] = []
|
||||
let genres: any[] = []
|
||||
let featured = null
|
||||
|
||||
const latestNovels = await prisma.novel.findMany({
|
||||
take: 6,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
try {
|
||||
popularNovels = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
orderBy: { views: "desc" },
|
||||
})
|
||||
|
||||
const topRated = await prisma.novel.findMany({
|
||||
take: 4,
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
latestNovels = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
})
|
||||
topRated = await prisma.novel.findMany({
|
||||
take: 4,
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
|
||||
// get the most popular as featured (can be empty if DB is new)
|
||||
const featured = popularNovels[0]
|
||||
genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
})
|
||||
|
||||
featured = popularNovels.length > 0 ? popularNovels[0] : null
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data for homepage during build/runtime", error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
@@ -49,12 +60,10 @@ export default async function HomePage() {
|
||||
href={`/truyen/${featured.slug}`}
|
||||
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row"
|
||||
>
|
||||
<div className={`flex h-48 items-center justify-center bg-gradient-to-br ${featured.coverColor || "from-slate-700 to-slate-800"} md:h-auto md:w-72`}>
|
||||
<BookOpen className="h-16 w-16 text-background/80" />
|
||||
</div>
|
||||
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full object-cover md:h-auto md:w-72" />
|
||||
<div className="flex flex-1 flex-col justify-center gap-3 p-6">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-primary">Truyện Nổi Bật</span>
|
||||
<h1 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">
|
||||
{featured.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Tác giả: {featured.authorName}</p>
|
||||
@@ -119,11 +128,9 @@ export default async function HomePage() {
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className={`flex h-12 w-9 shrink-0 items-center justify-center rounded bg-gradient-to-br ${novel.coverColor || "from-slate-700 to-slate-800"}`}>
|
||||
<BookOpen className="h-4 w-4 text-background/80" />
|
||||
</div>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded object-cover" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 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>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm font-semibold text-primary">
|
||||
|
||||
@@ -4,6 +4,8 @@ import { prisma } from "@/lib/prisma"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
|
||||
@@ -25,7 +27,8 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc"
|
||||
}
|
||||
},
|
||||
take: 20
|
||||
})
|
||||
|
||||
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
||||
|
||||
+14
-6
@@ -15,14 +15,22 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Shield: <Shield className="h-6 w-6" />,
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function GenresPage() {
|
||||
const genres = await prisma.genre.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { novels: true }
|
||||
let genres: any[] = []
|
||||
|
||||
try {
|
||||
genres = await prisma.genre.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { novels: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch genres during build/runtime", error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -60,6 +62,7 @@ export default async function SearchPage({
|
||||
const filteredNovels = await prisma.novel.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: 20,
|
||||
})
|
||||
|
||||
const genres = await prisma.genre.findMany()
|
||||
|
||||
@@ -2,14 +2,15 @@ import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, List } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ReadingSettings } from "@/components/reading-settings"
|
||||
import { CommentSection } from "@/components/comment-section"
|
||||
import { TTSPlayer } from "@/components/tts-player"
|
||||
import { ReaderFAB } from "@/components/reader-fab"
|
||||
import { ChapterReaderProgress } from "./chapter-reader-progress"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter as ChapterModel } from "@/lib/models/chapter"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
|
||||
const { slug, chapterId } = await params
|
||||
const chapterNumber = parseInt(chapterId, 10)
|
||||
@@ -47,19 +48,14 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
username: c.user.name || "User",
|
||||
avatarColor: c.user.image || "bg-primary",
|
||||
novelId: c.novelId,
|
||||
chapterId: c.chapterId,
|
||||
chapterId: c.chapterId || undefined,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
// Increment views quietly (fire and forget to not block render)
|
||||
Promise.all([
|
||||
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } }),
|
||||
prisma.novel.update({
|
||||
where: { id: novel.id },
|
||||
data: { views: { increment: 1 } }
|
||||
}).catch(e => console.error("Error incrementing novel views:", e))
|
||||
]).catch(e => console.error("Error updating views:", e))
|
||||
// Increment chapter views quietly (fire and forget to not block render)
|
||||
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } })
|
||||
.catch(e => console.error("Error updating chapter views:", e))
|
||||
|
||||
const hasPrev = chapterNumber > 1
|
||||
const hasNext = chapterNumber < maxChapter
|
||||
@@ -68,7 +64,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mx-auto max-w-4xl lg:max-w-screen-lg px-4 py-6 md:px-8">
|
||||
{/* Top navigation */}
|
||||
<div className="mb-6 flex flex-col gap-3">
|
||||
<Link href={`/truyen/${slug}`} className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
@@ -76,12 +72,9 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-lg font-bold text-foreground">
|
||||
<h1 className="text-lg font-bold text-foreground md:text-xl lg:text-2xl">
|
||||
Chương {chapter.number}: {chapter.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +106,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
</div>
|
||||
|
||||
{/* Chapter content */}
|
||||
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-6 font-serif text-foreground/90 md:p-8">
|
||||
<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">
|
||||
{paragraphs.map((text: string, idx: number) => (
|
||||
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
|
||||
{text}
|
||||
@@ -151,10 +144,11 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter._id.toString()} />
|
||||
</section>
|
||||
|
||||
{/* TTS Player */}
|
||||
<TTSPlayer
|
||||
paragraphs={paragraphs}
|
||||
{/* Floating Reader Actions & TTS Player */}
|
||||
<ReaderFAB
|
||||
novelId={novel.id}
|
||||
novelSlug={slug}
|
||||
paragraphs={paragraphs}
|
||||
currentChapter={chapterNumber}
|
||||
maxChapter={maxChapter}
|
||||
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
|
||||
|
||||
@@ -26,26 +26,36 @@ export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: N
|
||||
: "#"
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button asChild>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild className="bg-red-600 hover:bg-red-700 text-white font-bold px-6 border-0 shadow-sm">
|
||||
<Link href={readLink}>
|
||||
<BookOpen className="mr-1.5 h-4 w-4" />
|
||||
{progress?.lastChapterNumber ? `Đọc tiếp Ch. ${progress.lastChapterNumber}` : "Đọc Truyện"}
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
{progress?.lastChapterNumber ? `Đọc tiếp Ch. ${progress.lastChapterNumber}` : "Đọc truyện"}
|
||||
</Link>
|
||||
</Button>
|
||||
{user ? (
|
||||
<Button variant={bookmarked ? "secondary" : "outline"} onClick={() => toggleBookmark(novelId)}>
|
||||
{bookmarked ? <BookmarkCheck className="mr-1.5 h-4 w-4" /> : <BookMarked className="mr-1.5 h-4 w-4" />}
|
||||
{bookmarked ? "Đã Lưu" : "Lưu Truyện"}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => toggleBookmark(novelId)}
|
||||
className={`font-semibold px-4 border ${bookmarked ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20' : 'bg-[#334155] hover:bg-[#475569] text-white border-transparent'}`}
|
||||
>
|
||||
{bookmarked ? <BookmarkCheck className="mr-2 h-4 w-4 fill-primary" /> : <BookMarked className="mr-2 h-4 w-4" />}
|
||||
{bookmarked ? "Đã Đánh dấu" : "Đánh dấu"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" asChild className="font-semibold px-4 border-transparent bg-[#334155] hover:bg-[#475569] text-white">
|
||||
<Link href="/dang-nhap">
|
||||
<BookMarked className="mr-1.5 h-4 w-4" />
|
||||
Lưu Truyện
|
||||
<BookMarked className="mr-2 h-4 w-4" />
|
||||
Đánh dấu
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mocking ThumbsUp (Đề cử) button */}
|
||||
<Button variant="outline" className="font-semibold px-4 border-transparent bg-[#334155] hover:bg-[#475569] text-white" onClick={() => alert("Chức năng đề cử đang phát triển.")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2 h-4 w-4"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z"/></svg>
|
||||
Đề cử
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+87
-31
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
import { GenreBadge } from "@/components/genre-badge"
|
||||
@@ -10,6 +11,8 @@ import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function NovelDetailPage({
|
||||
params,
|
||||
searchParams
|
||||
@@ -36,12 +39,6 @@ export default async function NovelDetailPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Increment view quietly
|
||||
prisma.novel.update({
|
||||
where: { id: novel.id },
|
||||
data: { views: { increment: 1 } }
|
||||
}).catch(e => console.error("Error incrementing view:", e))
|
||||
|
||||
// Fetch chapters from MongoDB
|
||||
await connectToMongoDB()
|
||||
const skip = (currentPage - 1) * limit
|
||||
@@ -86,6 +83,23 @@ export default async function NovelDetailPage({
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
const chapterCommentsData = await prisma.comment.findMany({
|
||||
where: { novelId: novel.id, chapterId: { not: null } },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
})
|
||||
|
||||
// Format explicitly as the CommentProp type
|
||||
const chapterComments = chapterCommentsData.map(c => ({
|
||||
id: c.id,
|
||||
userId: c.user.id,
|
||||
username: c.user.name || "User",
|
||||
avatarColor: c.user.image || "bg-primary",
|
||||
novelId: c.novelId,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
const novelGenres = novel.genres.map(ng => ng.genre) || []
|
||||
|
||||
return (
|
||||
@@ -93,47 +107,85 @@ export default async function NovelDetailPage({
|
||||
{/* Novel Header */}
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
{/* Cover */}
|
||||
<div className={`flex h-64 w-44 shrink-0 items-center justify-center self-center rounded-xl bg-gradient-to-br shadow-lg md:self-start ${novel.coverColor || "from-slate-700 to-slate-800"}`}>
|
||||
<BookOpen className="h-14 w-14 text-background/80" />
|
||||
</div>
|
||||
<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" />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground text-balance md:text-3xl">{novel.title}</h1>
|
||||
<h1 title={novel.title} className="text-2xl font-bold text-foreground text-balance md:text-3xl">{novel.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><User className="h-3.5 w-3.5" />{novel.authorName}</span>
|
||||
<span className="flex items-center gap-1"><Layers className="h-3.5 w-3.5" />{novel.totalChapters} chương</span>
|
||||
<span className="flex items-center gap-1"><Eye className="h-3.5 w-3.5" />{formatViews(novel.views)} lượt xem</span>
|
||||
<span className="flex items-center gap-1"><BookMarked className="h-3.5 w-3.5" />{formatViews(novel.bookmarkCount)} bookmark</span>
|
||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" />Cập nhật: {novel.updatedAt.toLocaleDateString('vi-VN')}</span>
|
||||
<div className="flex flex-col gap-1 text-sm text-muted-foreground mt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Tác giả:</span>
|
||||
<Link href={`/tim-kiem?q=${encodeURIComponent(novel.authorName)}`} className="text-red-500 font-medium hover:underline">
|
||||
{novel.authorName}
|
||||
</Link>
|
||||
{novel.originalAuthorName && <span>({novel.originalAuthorName})</span>}
|
||||
</div>
|
||||
{novel.originalTitle &&
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Tên gốc:</span>
|
||||
<span>{novel.originalTitle}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${novel.status === "Hoàn thành" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
novel.status === "Đang ra" ? "bg-primary/10 text-primary" :
|
||||
"bg-muted text-muted-foreground"
|
||||
<div className="flex flex-col gap-3 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Trạng thái:</span>
|
||||
<span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${
|
||||
novel.status === "Hoàn thành" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
novel.status === "Tạm dừng" || novel.status === "Tạm ngưng" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" :
|
||||
"bg-primary/10 text-primary" // Đang ra
|
||||
}`}>
|
||||
{novel.status}
|
||||
</span>
|
||||
{novel.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{novelGenres.map((g, i) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/the-loai/${g.slug}`}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors hover:opacity-80 ${
|
||||
i % 2 === 0 ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StarRating rating={novel.rating} ratingCount={novel.ratingCount} novelId={novel.id} interactive />
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{novelGenres.map((g) => (
|
||||
<GenreBadge key={g.id} slug={g.slug} name={g.name} variant="link" />
|
||||
))}
|
||||
{/* Stats Row */}
|
||||
<div className="flex items-center gap-6 mt-4 md:gap-8 overflow-hidden">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.totalChapters}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Chương</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.views}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Lượt đọc</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.bookmarkCount}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Cất giữ</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.ratingCount}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Đề cử</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
|
||||
<div className="mt-4">
|
||||
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-bold text-foreground">Giới Thiệu</h2>
|
||||
<p className="text-sm leading-relaxed text-foreground/80">{novel.description}</p>
|
||||
<div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description}</div>
|
||||
</section>
|
||||
|
||||
{/* Chapter list */}
|
||||
@@ -152,7 +204,11 @@ export default async function NovelDetailPage({
|
||||
|
||||
{/* Comments */}
|
||||
<section className="mt-8">
|
||||
<CommentSection comments={comments as any} novelId={novel.id} />
|
||||
<CommentSection
|
||||
comments={comments as any}
|
||||
chapterComments={chapterComments as any}
|
||||
novelId={novel.id}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,11 +54,11 @@ export default function BookshelfPage() {
|
||||
key={novel.id}
|
||||
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}`} className={`flex h-16 w-12 shrink-0 items-center justify-center rounded-md bg-gradient-to-br ${novel.coverColor}`}>
|
||||
<BookOpen className="h-5 w-5 text-background/80" />
|
||||
<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" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<Link 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}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ filename: string }> }) {
|
||||
const { filename } = await params
|
||||
|
||||
if (!filename) {
|
||||
return new NextResponse("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
const sanitizedFilename = path.basename(filename)
|
||||
const filePath = path.join(process.cwd(), "public", "uploads", "covers", sanitizedFilename)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new NextResponse("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(filePath)
|
||||
|
||||
const ext = path.extname(sanitizedFilename).toLowerCase()
|
||||
let contentType = "image/jpeg"
|
||||
if (ext === ".png") contentType = "image/png"
|
||||
else if (ext === ".webp") contentType = "image/webp"
|
||||
else if (ext === ".gif") contentType = "image/gif"
|
||||
else if (ext === ".svg") contentType = "image/svg+xml"
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user