75ed8e233b
Add moderator/admin backend APIs and client features for managing novels and chapters. New endpoints include mod chapter routes (paginated list, single GET, PUT, DELETE, and bulk optimize), mod novel routes (create, GET by id, update, delete), genre CRUD, user bookmarks, novel comments, and rating endpoints. Update EPUB import to use a shared slugify util. Enhance moderator UI: chapter manager gains pagination, bulk optimization preview/apply, edit/delete dialogs; novel client adds genre management and edit/delete flows. Also update Prisma schema, add a DB wipe script, remove unused lib/data.ts, and adjust related types/utils and bookmark context.
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
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 { 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 async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
|
|
const { slug, chapterId } = await params
|
|
const chapterNumber = parseInt(chapterId, 10)
|
|
|
|
if (isNaN(chapterNumber)) {
|
|
notFound()
|
|
}
|
|
|
|
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 maxChapter = await ChapterModel.countDocuments({ novelId: novel.id })
|
|
|
|
const commentsData = await prisma.comment.findMany({
|
|
where: { novelId: novel.id, chapterId: chapter._id.toString() },
|
|
include: { user: true },
|
|
orderBy: { createdAt: "desc" }
|
|
})
|
|
|
|
const comments = commentsData.map(c => ({
|
|
id: c.id,
|
|
userId: c.user.id,
|
|
username: c.user.name || "User",
|
|
avatarColor: c.user.image || "bg-primary",
|
|
novelId: c.novelId,
|
|
chapterId: c.chapterId,
|
|
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))
|
|
|
|
const hasPrev = chapterNumber > 1
|
|
const hasNext = chapterNumber < maxChapter
|
|
|
|
// Extract paragraphs for TTS
|
|
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">
|
|
{/* 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">
|
|
<ChevronLeft className="h-4 w-4" /> {novel.title}
|
|
</Link>
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h1 className="text-lg font-bold text-foreground">
|
|
Chương {chapter.number}: {chapter.title}
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
<ReadingSettings />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chapter navigation top */}
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
|
{hasPrev ? (
|
|
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
|
|
<ChevronLeft className="mr-1 h-4 w-4" /> Ch. trước
|
|
</Link>
|
|
) : (
|
|
<span><ChevronLeft className="mr-1 h-4 w-4" /> Ch. trước</span>
|
|
)}
|
|
</Button>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/truyen/${slug}`}>
|
|
<List className="mr-1 h-4 w-4" /> Mục lục
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
|
{hasNext ? (
|
|
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
|
|
Ch. sau <ChevronRight className="ml-1 h-4 w-4" />
|
|
</Link>
|
|
) : (
|
|
<span>Ch. sau <ChevronRight className="ml-1 h-4 w-4" /></span>
|
|
)}
|
|
</Button>
|
|
</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">
|
|
{paragraphs.map((text: string, idx: number) => (
|
|
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
|
|
{text}
|
|
</p>
|
|
))}
|
|
</article>
|
|
|
|
{/* Chapter navigation bottom */}
|
|
<div className="mb-8 flex items-center justify-between">
|
|
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
|
{hasPrev ? (
|
|
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
|
|
<ChevronLeft className="mr-1 h-4 w-4" /> Chương trước
|
|
</Link>
|
|
) : (
|
|
<span><ChevronLeft className="mr-1 h-4 w-4" /> Chương trước</span>
|
|
)}
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
|
{hasNext ? (
|
|
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
|
|
Chương sau <ChevronRight className="ml-1 h-4 w-4" />
|
|
</Link>
|
|
) : (
|
|
<span>Chương sau <ChevronRight className="ml-1 h-4 w-4" /></span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Save reading progress */}
|
|
<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.toString()} />
|
|
</section>
|
|
|
|
{/* TTS Player */}
|
|
<TTSPlayer
|
|
paragraphs={paragraphs}
|
|
novelSlug={slug}
|
|
currentChapter={chapterNumber}
|
|
maxChapter={maxChapter}
|
|
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|