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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 lý 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 cơ 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 (Mô 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 cơ 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 (Mô 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
@@ -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 có 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 có 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 có đánh giá.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user