Add EPUB upload + DB integration

Add server-side EPUB import and integrate Prisma + Mongo for novels/chapters. Introduces a new moderator API route (app/api/mod/epub/route.ts) that parses .epub files, creates a novel record in Prisma, and inserts chapter documents into MongoDB via the Chapter Mongoose model. Frontend: novel management UI now supports EPUB upload (app/mod/truyen/novel-client.tsx) with progress/toasts and preserves the manual 'Add novel' dialog. Convert app pages to fetch real data from Prisma and Mongo (app/page.tsx, app/truyen/[slug]/page.tsx, app/truyen/[slug]/[chapterId]/page.tsx), adapt types/props to use authorName, and adjust chapter/comment IDs to use Mongo _id strings. Minor fixes: TTS player logs playback errors, UI text fixes (e.g. "Chương"), and novel-card/other components updated for authorName. package.json updated with epub2, html-to-text and types; pnpm lock updated. Adds tsconfig.tsbuildinfo.
This commit is contained in:
2026-03-05 18:02:11 +07:00
parent 112e8604e2
commit ce805adb08
13 changed files with 582 additions and 138 deletions
+120
View File
@@ -0,0 +1,120 @@
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 path from "path"
import os from "os"
import { promises as fs } from "fs"
import { convert } from "html-to-text"
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 formData = await req.formData()
const epubFile = formData.get("file") as File
if (!epubFile) {
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 })
}
const buffer = Buffer.from(await epubFile.arrayBuffer())
const tempFilePath = path.join(os.tmpdir(), `upload-${Date.now()}.epub`)
await fs.writeFile(tempFilePath, buffer)
// Phân tích EPUB file
const parsedData = await new Promise<any>((resolve, reject) => {
const EPub = require("epub2").EPub || require("epub2")
const epub = new EPub(tempFilePath, "", "")
epub.on("error", (err: any) => reject(err))
epub.on("end", async () => {
const metadata = epub.metadata
const flow = epub.flow // TOC array
const chapters = []
for (let i = 0; i < flow.length; i++) {
const chapterData = flow[i]
const text = await new Promise<string>((res) => {
epub.getChapter(chapterData.id, (err: any, d: string) => {
if (err) res("")
else res(d)
})
})
if (text && text.trim().length > 0) {
const plainText = convert(text, { wordwrap: false })
chapters.push({
title: chapterData.title || `Chương ${i + 1}`,
content: plainText
})
}
}
resolve({ metadata, chapters })
})
epub.parse()
})
// 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
const baseSlug = novelTitle
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
let slug = baseSlug
let slugCounter = 1
// Đảm bảo slug là duy nhất
while (await prisma.novel.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${slugCounter}`
slugCounter++
}
const newNovel = await prisma.novel.create({
data: {
title: novelTitle,
slug: slug,
authorName: novelAuthor,
description: convert(novelDesc, { wordwrap: false }), // metadata metadata có thể chứa html
uploaderId: session.user.id,
totalChapters: chapters.length,
},
})
// Lưu chapters xuống MongoDB
await connectToMongoDB()
const chapterDocs = chapters.map((ch: any, i: number) => ({
novelId: newNovel.id,
number: i + 1,
title: ch.title,
content: ch.content,
views: 0
}))
if (chapterDocs.length > 0) {
await Chapter.insertMany(chapterDocs)
}
return NextResponse.json(newNovel, { status: 201 })
} catch (error: any) {
console.error("EPUB upload error:", error)
return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 })
}
}
+92 -36
View File
@@ -13,7 +13,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { BookOpen, Loader2, Plus } from "lucide-react"
import { BookOpen, Loader2, Plus, Upload } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
@@ -31,6 +31,7 @@ export function NovelClient() {
const [loading, setLoading] = useState(true)
const [openAdd, setOpenAdd] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [uploadingEpub, setUploadingEpub] = useState(false)
// Form states
const [title, setTitle] = useState("")
@@ -82,6 +83,41 @@ export function NovelClient() {
}
}
const handleEpubUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.name.endsWith('.epub')) {
toast.error("Vui lòng chọn file định dạng .epub")
e.target.value = "" // Reset input
return
}
setUploadingEpub(true)
const formData = new FormData()
formData.append("file", file)
try {
const res = await fetch("/api/mod/epub", {
method: "POST",
body: formData,
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Lỗi khi tải lên EPUB")
}
toast.success("Phân tích và xuất bản EPUB thành công!")
fetchNovels()
} catch (err: any) {
toast.error(err.message || "Có lỗi xảy ra khi xử lý file EPUB")
} finally {
setUploadingEpub(false)
e.target.value = "" // Reset input
}
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
@@ -89,41 +125,61 @@ export function NovelClient() {
<BookOpen className="h-6 w-6 text-primary" /> Quản truyện
</h1>
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" /> Thêm truyện
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Thêm Truyện Mới</DialogTitle>
<DialogDescription>
Nhập thông tin bản cho đu truyện mới của bạn.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddSubmit} className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tên truyện</label>
<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>
<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">Giới thiệu ngắn ( tả)</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Tóm tắt về câu chuyện..." rows={4} />
</div>
<DialogFooter>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Hoàn thành
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="flex gap-3">
<input
type="file"
id="epub-upload"
accept=".epub,application/epub+zip"
className="hidden"
onChange={handleEpubUpload}
disabled={uploadingEpub}
/>
<Button
variant="secondary"
className="gap-2"
disabled={uploadingEpub}
onClick={() => document.getElementById('epub-upload')?.click()}
>
{uploadingEpub ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Tải lên EPUB
</Button>
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" /> Thêm truyện
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Thêm Truyện Mới</DialogTitle>
<DialogDescription>
Nhập thông tin bản cho đu truyện mới của bạn.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddSubmit} className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tên truyện</label>
<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>
<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">Giới thiệu ngắn ( tả)</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Tóm tắt về câu chuyện..." rows={4} />
</div>
<DialogFooter>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Hoàn thành
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="rounded-xl border bg-card overflow-hidden shadow-sm">
+57 -41
View File
@@ -1,7 +1,8 @@
import Link from "next/link"
import { ArrowRight, BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
import { NovelCard } from "@/components/novel-card"
import { genres, getPopularNovels, getLatestNovels, getTopRatedNovels, novels } from "@/lib/data"
import { genres } from "@/lib/data"
import { prisma } from "@/lib/prisma"
const iconMap: Record<string, React.ReactNode> = {
Sparkles: <Sparkles className="h-5 w-5" />,
@@ -16,43 +17,58 @@ const iconMap: Record<string, React.ReactNode> = {
Shield: <Shield className="h-5 w-5" />,
}
export default function HomePage() {
const popularNovels = getPopularNovels(6)
const latestNovels = getLatestNovels(6)
const topRated = getTopRatedNovels(4)
const featured = novels[3] // Anh Hung Xa Dieu
export default async function HomePage() {
const popularNovels = await prisma.novel.findMany({
take: 6,
orderBy: { views: "desc" },
})
const latestNovels = await prisma.novel.findMany({
take: 6,
orderBy: { updatedAt: "desc" },
})
const 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]
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Hero / Featured Novel */}
<section className="mb-10">
<Link
href={`/truyen/${featured.slug}`}
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row"
>
<div className={`flex h-48 items-center justify-center bg-gradient-to-br ${featured.coverColor} md:h-auto md:w-72`}>
<BookOpen className="h-16 w-16 text-background/80" />
</div>
<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">
{featured.title}
</h1>
<p className="text-sm text-muted-foreground">Tác giả: {featured.author}</p>
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{featured.description}
</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{featured.totalChapters} chương</span>
<span>{featured.status}</span>
<span className="flex items-center gap-1 text-primary">
<Sparkles className="h-3.5 w-3.5" />
{featured.rating}
</span>
{featured && (
<section className="mb-10">
<Link
href={`/truyen/${featured.slug}`}
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row"
>
<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>
</div>
</Link>
</section>
<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">
{featured.title}
</h1>
<p className="text-sm text-muted-foreground">Tác giả: {featured.authorName}</p>
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{featured.description}
</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{featured.totalChapters} chương</span>
<span>{featured.status}</span>
<span className="flex items-center gap-1 text-primary">
<Sparkles className="h-3.5 w-3.5" />
{featured.rating}
</span>
</div>
</div>
</Link>
</section>
)}
{/* Popular Novels */}
<section className="mb-10">
@@ -63,9 +79,9 @@ export default function HomePage() {
</Link>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{popularNovels.map((novel) => (
{popularNovels.length > 0 ? popularNovels.map((novel) => (
<NovelCard key={novel.id} novel={novel} />
))}
)) : <p className="text-sm text-muted-foreground col-span-full">Chưa truyện nào trong hệ thống.</p>}
</div>
</section>
@@ -78,9 +94,9 @@ export default function HomePage() {
</Link>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{latestNovels.map((novel) => (
{latestNovels.length > 0 ? latestNovels.map((novel) => (
<NovelCard key={novel.id} novel={novel} variant="compact" />
))}
)) : <p className="text-sm text-muted-foreground col-span-full">Chưa truyện nào đưc cập nhật.</p>}
</div>
</section>
@@ -90,7 +106,7 @@ export default function HomePage() {
<section>
<h2 className="mb-4 text-xl font-bold text-foreground">Đánh Giá Cao</h2>
<div className="flex flex-col gap-3">
{topRated.map((novel, idx) => (
{topRated.length > 0 ? topRated.map((novel, idx) => (
<Link
key={novel.id}
href={`/truyen/${novel.slug}`}
@@ -99,19 +115,19 @@ export default 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}`}>
<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>
<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>
<p className="text-xs text-muted-foreground">{novel.author} - Ch. {novel.totalChapters}</p>
<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">
<Sparkles className="h-3.5 w-3.5" />
{novel.rating}
</div>
</Link>
))}
)) : <p className="text-sm text-muted-foreground">Chưa đánh giá.</p>}
</div>
</section>
+24 -20
View File
@@ -1,42 +1,46 @@
"use client"
import { use, useMemo } from "react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ChevronLeft, ChevronRight, List } from "lucide-react"
import { getNovelBySlug, getChapter, getChaptersByNovelId, getCommentsByChapterId } from "@/lib/data"
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 { ChapterReaderProgress } from "./chapter-reader-progress"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter as ChapterModel } from "@/lib/models/chapter"
export default function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
const { slug, chapterId } = use(params)
export default async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
const { slug, chapterId } = await params
const chapterNumber = parseInt(chapterId, 10)
const novel = getNovelBySlug(slug)
if (!novel || isNaN(chapterNumber)) {
if (isNaN(chapterNumber)) {
notFound()
}
const chapter = getChapter(novel.id, chapterNumber)
const allChapters = getChaptersByNovelId(novel.id)
const maxChapter = allChapters.length
const novel = await prisma.novel.findUnique({
where: { slug }
})
if (!novel) {
notFound()
}
await connectToMongoDB()
const chapter = await ChapterModel.findOne({ novelId: novel.id, number: chapterNumber }).lean()
if (!chapter) {
notFound()
}
const comments = getCommentsByChapterId(chapter.id)
const maxChapter = await ChapterModel.countDocuments({ novelId: novel.id })
const comments: any[] = [] // Temporarily empty
const hasPrev = chapterNumber > 1
const hasNext = chapterNumber < maxChapter
// Extract paragraphs for TTS
const paragraphs = useMemo(
() => chapter.content.split("\n").map((p) => p.trim()).filter(Boolean),
[chapter.content]
)
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">
@@ -85,7 +89,7 @@ export default function ChapterReaderPage({ params }: { params: Promise<{ slug:
{/* 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">
{paragraphs.map((text, idx) => (
{paragraphs.map((text: string, idx: number) => (
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
{text}
</p>
@@ -115,11 +119,11 @@ export default function ChapterReaderPage({ params }: { params: Promise<{ slug:
</div>
{/* Save reading progress */}
<ChapterReaderProgress novelId={novel.id} chapterId={chapter.id} chapterNumber={chapter.number} />
<ChapterReaderProgress novelId={novel.id} chapterId={chapter._id.toString()} chapterNumber={chapter.number} />
{/* Comments */}
<section className="border-t border-border pt-8">
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter.id} />
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter._id.toString()} />
</section>
{/* TTS Player */}
@@ -128,7 +132,7 @@ export default function ChapterReaderPage({ params }: { params: Promise<{ slug:
novelSlug={slug}
currentChapter={chapterNumber}
maxChapter={maxChapter}
chapterTitle={`Chuong ${chapter.number}: ${chapter.title}`}
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
/>
</div>
)
+43 -22
View File
@@ -1,36 +1,58 @@
"use client"
import { use } from "react"
import { notFound } from "next/navigation"
import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react"
import { getNovelBySlug, getChaptersByNovelId, getCommentsByNovelId, genres, formatViews } from "@/lib/data"
import { genres, formatViews } from "@/lib/data"
import { GenreBadge } from "@/components/genre-badge"
import { StarRating } from "@/components/star-rating"
import { ChapterList } from "@/components/chapter-list"
import { CommentSection } from "@/components/comment-section"
import { NovelDetailActions } from "./novel-detail-actions"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
export default function NovelDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
const novel = getNovelBySlug(slug)
export default async function NovelDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const novel = await prisma.novel.findUnique({
where: { slug },
include: {
genres: {
include: { genre: true }
}
}
})
if (!novel) {
notFound()
}
const chapters = getChaptersByNovelId(novel.id)
const comments = getCommentsByNovelId(novel.id)
// Fetch chapters from MongoDB
await connectToMongoDB()
const chapters = await Chapter.find({ novelId: novel.id })
.sort({ number: 1 })
.select("id novelId number title createdAt views")
.lean()
const novelGenres = novel.genres
.map((gSlug) => genres.find((g) => g.slug === gSlug))
.filter(Boolean) as typeof genres
// Convert Mongoose documents to plain objects for Server Component
const formattedChapters = chapters.map(c => ({
id: c._id.toString(),
novelId: c.novelId,
number: c.number,
title: c.title,
createdAt: (c.createdAt as Date).toISOString(),
views: c.views || 0,
content: "" // We don't fetch content for the list
}))
const comments: any[] = [] // Temporarily empty until we implement comments
const novelGenres = novel.genres.map(ng => ng.genre) || []
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* 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}`}>
<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>
@@ -39,19 +61,18 @@ export default function NovelDetailPage({ params }: { params: Promise<{ slug: st
<h1 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.author}</span>
<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.lastUpdated}</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>
<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" :
<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"
}`}>
"bg-muted text-muted-foreground"
}`}>
{novel.status}
</span>
</div>
@@ -64,7 +85,7 @@ export default function NovelDetailPage({ params }: { params: Promise<{ slug: st
))}
</div>
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={chapters[0]?.number} />
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
</div>
</div>
@@ -78,13 +99,13 @@ export default function NovelDetailPage({ params }: { params: Promise<{ slug: st
<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={chapters} novelSlug={novel.slug} />
<ChapterList chapters={formattedChapters as any} novelSlug={novel.slug} />
</div>
</section>
{/* Comments */}
<section className="mt-8">
<CommentSection comments={comments} novelId={novel.id} />
<CommentSection comments={comments as any} novelId={novel.id} />
</section>
</div>
)
+1 -1
View File
@@ -63,7 +63,7 @@ export default function BookshelfPage() {
<Link 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.author}</p>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
{bookmark.lastChapterNumber && (
<p className="text-xs text-muted-foreground">
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}