Initial commit

This commit is contained in:
2026-03-05 16:46:38 +07:00
commit 112e8604e2
124 changed files with 14369 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth"
import { authOptions } from "@/lib/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
+30
View File
@@ -0,0 +1,30 @@
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() {
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
const session = await getServerSession(authOptions)
if (!session || !session.user || !session.user.email) {
return NextResponse.json({ error: "Bạn phải đăng nhập trước" }, { status: 401 })
}
try {
const updatedUser = await prisma.user.update({
where: { email: session.user.email },
data: { role: "MOD" },
})
return NextResponse.json({
message: `Tài khoản ${updatedUser.email} đã được cấp quyền MOD. Xin hãy Đăng xuất và Đăng nhập lại để cập nhật phiên.`,
user: updatedUser
})
} catch (error) {
return NextResponse.json({ error: "Lỗi hệ thống" }, { status: 500 })
}
}
+72
View File
@@ -0,0 +1,72 @@
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 GET(req: Request) {
const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId")
if (!novelId) {
return NextResponse.json({ error: "novelId is required" }, { status: 400 })
}
try {
await connectToMongoDB()
const chapters = await Chapter.find({ novelId }).sort({ number: 1 }).select("-content")
return NextResponse.json(chapters)
} catch (error) {
console.error("GET Chapter Error:", error)
return NextResponse.json({ error: "Failed to fetch chapters" }, { status: 500 })
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { novelId, number, title, content } = data
// Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({
where: { id: novelId, uploaderId: session.user.id },
})
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 })
}
await connectToMongoDB()
// Kiểm tra chương đã tồn tại
const existingChapter = await Chapter.findOne({ novelId, number })
if (existingChapter) {
return NextResponse.json({ error: "Chương này đã tồn tại" }, { status: 400 })
}
const newChapter = await Chapter.create({
novelId,
number,
title,
content,
})
// Cập nhật số chương trong table PostgreSQL, tự động đếm lại
const totalChapters = await Chapter.countDocuments({ novelId })
await prisma.novel.update({
where: { id: novelId },
data: { totalChapters },
})
return NextResponse.json(newChapter, { status: 201 })
} catch (error) {
console.error("POST Chapter Error:", error)
return NextResponse.json({ error: "Failed to create chapter" }, { status: 500 })
}
}
+52
View File
@@ -0,0 +1,52 @@
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() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const novels = await prisma.novel.findMany({
where: { uploaderId: session.user.id },
orderBy: { updatedAt: "desc" },
})
return NextResponse.json(novels)
} catch (error) {
return NextResponse.json({ error: "Failed to fetch novels" }, { status: 500 })
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
// Tạo slug từ title
const slug = data.title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
const newNovel = await prisma.novel.create({
data: {
title: data.title,
slug: slug,
authorName: data.authorName,
description: data.description,
uploaderId: session.user.id,
},
})
return NextResponse.json(newNovel, { status: 201 })
} catch (error) {
return NextResponse.json({ error: "Failed to create novel" }, { status: 500 })
}
}
+75
View File
@@ -0,0 +1,75 @@
"use client"
import { useEffect } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { BookOpen } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context"
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
)
}
export default function LoginPage() {
const router = useRouter()
const { user, loginWithGoogle } = useAuth()
useEffect(() => {
if (user) {
router.push("/")
}
}, [user, router])
const handleGoogleLogin = () => {
loginWithGoogle()
router.push("/")
}
return (
<div className="flex min-h-[calc(100svh-8rem)] items-center justify-center px-4">
<div className="w-full max-w-sm">
<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>
</Link>
<h1 className="mt-6 text-2xl font-bold text-foreground">Chao mung ban</h1>
<p className="mt-2 text-sm text-muted-foreground">
{"Dang nhap de luu truyen va theo doi tien do doc"}
</p>
</div>
<div className="rounded-xl border border-border bg-card p-6">
<Button
variant="outline"
className="flex h-12 w-full items-center justify-center gap-3 text-sm font-medium"
onClick={handleGoogleLogin}
>
<GoogleIcon className="h-5 w-5" />
{"Dang nhap bang Google"}
</Button>
<div className="mt-4 text-center text-xs text-muted-foreground">
{"Chung toi se khong bao gio chia se thong tin cua ban voi bat ky ai."}
</div>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
{"Khi dang nhap, ban dong y voi "}
<span className="text-primary hover:underline cursor-pointer">{"Dieu khoan su dung"}</span>
{" va "}
<span className="text-primary hover:underline cursor-pointer">{"Chinh sach bao mat"}</span>
{" cua chung toi."}
</p>
</div>
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.98 0.002 80);
--foreground: oklch(0.17 0.012 75);
--card: oklch(1 0 0);
--card-foreground: oklch(0.17 0.012 75);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.17 0.012 75);
--primary: oklch(0.55 0.18 38);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.95 0.01 80);
--secondary-foreground: oklch(0.25 0.012 75);
--muted: oklch(0.94 0.008 80);
--muted-foreground: oklch(0.50 0.01 75);
--accent: oklch(0.55 0.18 38);
--accent-foreground: oklch(0.99 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.008 80);
--input: oklch(0.91 0.008 80);
--ring: oklch(0.55 0.18 38);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.55 0.18 38);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.55 0.18 38);
}
.dark {
--background: oklch(0.13 0.008 260);
--foreground: oklch(0.93 0.005 80);
--card: oklch(0.17 0.01 260);
--card-foreground: oklch(0.93 0.005 80);
--popover: oklch(0.17 0.01 260);
--popover-foreground: oklch(0.93 0.005 80);
--primary: oklch(0.65 0.18 45);
--primary-foreground: oklch(0.13 0.008 260);
--secondary: oklch(0.22 0.012 260);
--secondary-foreground: oklch(0.93 0.005 80);
--muted: oklch(0.22 0.012 260);
--muted-foreground: oklch(0.65 0.01 80);
--accent: oklch(0.65 0.18 45);
--accent-foreground: oklch(0.13 0.008 260);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.25 0.012 260);
--input: oklch(0.25 0.012 260);
--ring: oklch(0.65 0.18 45);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.17 0.01 260);
--sidebar-foreground: oklch(0.93 0.005 80);
--sidebar-primary: oklch(0.65 0.18 45);
--sidebar-primary-foreground: oklch(0.93 0.005 80);
--sidebar-accent: oklch(0.22 0.012 260);
--sidebar-accent-foreground: oklch(0.93 0.005 80);
--sidebar-border: oklch(0.25 0.012 260);
--sidebar-ring: oklch(0.65 0.18 45);
}
@theme inline {
--font-sans: var(--font-sans, 'Be Vietnam Pro', sans-serif);
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
--font-serif: 'Georgia', 'Times New Roman', serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+70
View File
@@ -0,0 +1,70 @@
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'
import { Header } from '@/components/header'
import { Footer } from '@/components/footer'
import './globals.css'
const beVietnam = Be_Vietnam_Pro({
subsets: ['vietnamese', 'latin'],
weight: ['300', '400', '500', '600', '700'],
variable: '--font-sans',
})
export const metadata: Metadata = {
title: 'TruyenChu - Đọ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: {
icon: [
{
url: '/icon-light-32x32.png',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
},
}
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#f8f6f4' },
{ media: '(prefers-color-scheme: dark)', color: '#1a1a2e' },
],
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="vi" suppressHydrationWarning>
<body className={`${beVietnam.variable} font-sans antialiased`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AuthProvider>
<BookmarkProvider>
<div className="flex min-h-svh flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</BookmarkProvider>
</AuthProvider>
</ThemeProvider>
<Analytics />
</body>
</html>
)
}
+203
View File
@@ -0,0 +1,203 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FileText, Loader2, Plus, ArrowLeft } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
interface Chapter {
_id: string
number: number
title: string
views: number
createdAt: string
}
function ChapterManager() {
const searchParams = useSearchParams()
const novelId = searchParams.get("novelId")
const [chapters, setChapters] = useState<Chapter[]>([])
const [loading, setLoading] = useState(true)
const [openAdd, setOpenAdd] = useState(false)
const [submitting, setSubmitting] = useState(false)
// Form states
const [number, setNumber] = useState("")
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const fetchChapters = async () => {
if (!novelId) return
try {
const res = await fetch(`/api/mod/chuong?novelId=${novelId}`)
if (!res.ok) throw new Error("Lỗi fetch")
const data = await res.json()
setChapters(data)
if (data.length > 0) {
setNumber((data[data.length - 1].number + 1).toString())
} else {
setNumber("1")
}
} catch {
toast.error("Không tải được danh sách chương")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchChapters()
}, [novelId])
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!number || !title || !content || !novelId) {
toast.error("Vui lòng điền đầy đủ")
return
}
setSubmitting(true)
try {
const res = await fetch("/api/mod/chuong", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId, number: parseInt(number), title, content }),
})
const resData = await res.json()
if (!res.ok) throw new Error(resData.error || "Thêm mới thất bại")
toast.success("Đã đăng chương mới thành công!")
setOpenAdd(false)
setTitle("")
setContent("")
setNumber((parseInt(number) + 1).toString())
fetchChapters()
} catch (error: any) {
toast.error(error.message)
} finally {
setSubmitting(false)
}
}
if (!novelId) {
return (
<div className="text-center py-20 text-muted-foreground">
Vui lòng chọn một truyện từ mục Quản truyện đ xem danh sách chương.
<br />
<Link href="/mod/truyen">
<Button variant="link" className="mt-4"><ArrowLeft className="mr-2 h-4 w-4" /> Quay lại Quản truyện</Button>
</Link>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="h-6 w-6 text-primary" /> Quản chương
</h1>
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" /> Đăng chương mới
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Đăng Chương Mới</DialogTitle>
<DialogDescription>
Thêm nội dung một chương truyện đ gửi đến đc giả.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
<div className="grid grid-cols-4 gap-4">
<div className="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)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
</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 (Hỗ trợ xuống dòng)</label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="flex-1 w-full p-4 resize-none min-h-[300px]"
placeholder="Paste văn bản của chương vào đây..."
required
/>
</div>
<DialogFooter className="mt-auto pt-4">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Đăng ngay
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</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 w-24">Chương</th>
<th scope="col" className="px-5 py-4 font-semibold">Tên chương</th>
<th scope="col" className="px-5 py-4 font-semibold text-right">Lượt đc</th>
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
) : chapters.length === 0 ? (
<tr><td colSpan={4} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr>
) : (
chapters.map((ch) => (
<tr key={ch._id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0">
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right space-x-3">
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa nội dung</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
export function ChapterClient() {
return (
<Suspense fallback={<div className="flex justify-center p-8"><Loader2 className="h-6 w-6 animate-spin text-primary" /></div>}>
<ChapterManager />
</Suspense>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { ChapterClient } from "./chapter-client"
export default async function ModChuongPage() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <ChapterClient />
}
+43
View File
@@ -0,0 +1,43 @@
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import Link from "next/link"
import { BookOpen, Users, List, Home } from "lucide-react"
export default async function ModLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession(authOptions)
// Kiểm tra quyền
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/") // Không đủ quyền, đưa về trang chủ
}
return (
<div className="flex min-h-[calc(100vh-3.5rem)] bg-muted/20">
{/* Sidebar */}
<aside className="w-64 border-r bg-background p-4 hidden md:block">
<h2 className="mb-6 px-2 text-lg font-bold">Mod Dashboard</h2>
<nav className="flex flex-col gap-2">
<Link href="/mod" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<Home className="h-4 w-4" /> Tổng quan
</Link>
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<BookOpen className="h-4 w-4" /> Quản truyện
</Link>
<Link href="/mod/chuong" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<List className="h-4 w-4" /> Quản chương
</Link>
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 p-6">
{children}
</main>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
export default async function ModDashboardPage() {
const session = await getServerSession(authOptions)
return (
<div>
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1>
<p className="text-muted-foreground mb-6">
Chào mừng bạn đến với trang quản trị dành cho Moderator.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Truyện của bạn</h3>
<p className="text-3xl font-bold mt-2">0</p>
</div>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Tổng lượt xem</h3>
<p className="text-3xl font-bold mt-2">0</p>
</div>
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg">Bình luận mới</h3>
<p className="text-3xl font-bold mt-2">0</p>
</div>
</div>
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { BookOpen, Loader2, Plus } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
interface Novel {
id: string
title: string
slug: string
authorName: string
status: string
totalChapters: number
}
export function NovelClient() {
const [novels, setNovels] = useState<Novel[]>([])
const [loading, setLoading] = useState(true)
const [openAdd, setOpenAdd] = useState(false)
const [submitting, setSubmitting] = useState(false)
// Form states
const [title, setTitle] = useState("")
const [authorName, setAuthorName] = useState("")
const [description, setDescription] = useState("")
const fetchNovels = async () => {
try {
const res = await fetch("/api/mod/truyen")
if (!res.ok) throw new Error("Lấy danh sách lỗi")
const data = await res.json()
setNovels(data)
} catch {
toast.error("Không thể tải danh sách truyện")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchNovels()
}, [])
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title || !authorName || !description) {
toast.error("Vui lòng điền đầy đủ thông tin")
return
}
setSubmitting(true)
try {
const res = await fetch("/api/mod/truyen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, authorName, description }),
})
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("")
setAuthorName("")
setDescription("")
fetchNovels()
} catch {
toast.error("Lỗi khi thêm truyện mới")
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center bg-card p-4 rounded-xl border shadow-sm">
<h1 className="text-2xl font-bold flex items-center gap-2">
<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>
<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 space-x-3">
<Link href={`/mod/chuong?novelId=${novel.id}`} className="font-medium text-blue-500 hover:text-blue-600 hover:underline">
Đăng chương
</Link>
<button className="font-medium text-amber-500 hover:text-amber-600 hover:underline">Sửa</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { NovelClient } from "./novel-client"
export default async function ModTruyenPage() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <NovelClient />
}
+147
View File
@@ -0,0 +1,147 @@
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"
const iconMap: Record<string, React.ReactNode> = {
Sparkles: <Sparkles className="h-5 w-5" />,
Flame: <Flame className="h-5 w-5" />,
Heart: <Heart className="h-5 w-5" />,
Sword: <Swords className="h-5 w-5" />,
Building: <Building2 className="h-5 w-5" />,
Rocket: <Rocket className="h-5 w-5" />,
Crown: <Crown className="h-5 w-5" />,
Laugh: <Laugh className="h-5 w-5" />,
Search: <Search className="h-5 w-5" />,
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
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>
</div>
</div>
</Link>
</section>
{/* Popular Novels */}
<section className="mb-10">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-foreground">Truyện Hot</h2>
<Link href="/tim-kiem?sort=popular" className="flex items-center gap-1 text-sm text-primary hover:underline">
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{popularNovels.map((novel) => (
<NovelCard key={novel.id} novel={novel} />
))}
</div>
</section>
{/* Latest Updated */}
<section className="mb-10">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-foreground">Mới Cập Nhật</h2>
<Link href="/tim-kiem?sort=latest" className="flex items-center gap-1 text-sm text-primary hover:underline">
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{latestNovels.map((novel) => (
<NovelCard key={novel.id} novel={novel} variant="compact" />
))}
</div>
</section>
{/* Two columns: Top Rated + Genres */}
<div className="grid gap-10 lg:grid-cols-2">
{/* Top Rated */}
<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) => (
<Link
key={novel.id}
href={`/truyen/${novel.slug}`}
className="group flex items-center gap-4 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30"
>
<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}`}>
<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>
</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>
))}
</div>
</section>
{/* Genres */}
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-foreground">Thể Loại</h2>
<Link href="/the-loai" className="flex items-center gap-1 text-sm text-primary hover:underline">
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-3">
{genres.slice(0, 8).map((genre) => (
<Link
key={genre.id}
href={`/the-loai/${genre.slug}`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-accent/50"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{iconMap[genre.icon] || <BookOpen className="h-5 w-5" />}
</span>
<div>
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-1">{genre.description}</p>
</div>
</Link>
))}
</div>
</section>
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import { use, useState, useMemo } from "react"
import Link from "next/link"
import { ChevronLeft } from "lucide-react"
import { getGenreBySlug, getNovelsByGenre } from "@/lib/data"
import { NovelCard } from "@/components/novel-card"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { notFound } from "next/navigation"
export default function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
const genre = getGenreBySlug(slug)
if (!genre) {
notFound()
}
return <GenreContent genreName={genre.name} genreSlug={genre.slug} genreDescription={genre.description} />
}
function GenreContent({ genreName, genreSlug, genreDescription }: { genreName: string; genreSlug: string; genreDescription: string }) {
const [sortBy, setSortBy] = useState("latest")
const allNovels = getNovelsByGenre(genreSlug)
const sortedNovels = useMemo(() => {
const sorted = [...allNovels]
switch (sortBy) {
case "popular":
sorted.sort((a, b) => b.views - a.views)
break
case "rating":
sorted.sort((a, b) => b.rating - a.rating)
break
case "latest":
default:
sorted.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime())
}
return sorted
}, [allNovels, sortBy])
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-6">
<Link href="/the-loai" className="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-4 w-4" /> Thể Loại
</Link>
<h1 className="text-2xl font-bold text-foreground">{genreName}</h1>
<p className="mt-1 text-sm text-muted-foreground">{genreDescription}</p>
</div>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">{sortedNovels.length} truyện</p>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">Mới nhất</SelectItem>
<SelectItem value="popular">Xem nhiều</SelectItem>
<SelectItem value="rating">Đánh giá cao</SelectItem>
</SelectContent>
</Select>
</div>
{sortedNovels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<p className="text-lg font-medium">Chưa truyện nào</p>
<p className="text-sm">Thể loại này chưa truyện, hãy quay lại sau.</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{sortedNovels.map((novel) => (
<NovelCard key={novel.id} novel={novel} />
))}
</div>
)}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link"
import { BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
import { genres, getNovelsByGenre } from "@/lib/data"
const iconMap: Record<string, React.ReactNode> = {
Sparkles: <Sparkles className="h-6 w-6" />,
Flame: <Flame className="h-6 w-6" />,
Heart: <Heart className="h-6 w-6" />,
Sword: <Swords className="h-6 w-6" />,
Building: <Building2 className="h-6 w-6" />,
Rocket: <Rocket className="h-6 w-6" />,
Crown: <Crown className="h-6 w-6" />,
Laugh: <Laugh className="h-6 w-6" />,
Search: <Search className="h-6 w-6" />,
Shield: <Shield className="h-6 w-6" />,
}
export default function GenresPage() {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold text-foreground">Thể Loại Truyện</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{genres.map((genre) => {
const novelCount = getNovelsByGenre(genre.slug).length
return (
<Link
key={genre.id}
href={`/the-loai/${genre.slug}`}
className="group flex items-start gap-4 rounded-xl border border-border bg-card p-5 transition-all hover:border-primary/30 hover:shadow-md"
>
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
{iconMap[genre.icon] || <BookOpen className="h-6 w-6" />}
</span>
<div>
<h2 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h2>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{genre.description}</p>
<p className="mt-2 text-xs text-muted-foreground">{novelCount} truyện</p>
</div>
</Link>
)
})}
</div>
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
"use client"
import { useState, useMemo } from "react"
import { useSearchParams } from "next/navigation"
import { Search } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { NovelCard } from "@/components/novel-card"
import { novels, genres, searchNovels } from "@/lib/data"
export default function SearchPage() {
const searchParams = useSearchParams()
const initialQuery = searchParams.get("q") || ""
const initialSort = searchParams.get("sort") || "latest"
const [query, setQuery] = useState(initialQuery)
const [sortBy, setSortBy] = useState(initialSort)
const [genreFilter, setGenreFilter] = useState("all")
const [statusFilter, setStatusFilter] = useState("all")
const filteredNovels = useMemo(() => {
let results = query.trim() ? searchNovels(query) : [...novels]
if (genreFilter !== "all") {
results = results.filter((n) => n.genres.includes(genreFilter))
}
if (statusFilter !== "all") {
results = results.filter((n) => n.status === statusFilter)
}
switch (sortBy) {
case "popular":
results.sort((a, b) => b.views - a.views)
break
case "rating":
results.sort((a, b) => b.rating - a.rating)
break
case "name":
results.sort((a, b) => a.title.localeCompare(b.title))
break
case "latest":
default:
results.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime())
}
return results
}, [query, sortBy, genreFilter, statusFilter])
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold text-foreground">Tìm Kiếm Truyện</h1>
{/* Search bar */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Tìm theo tên truyện, tác giả..."
className="pl-9"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{/* Filters */}
<div className="mb-6 flex flex-wrap gap-3">
<Select value={genreFilter} onValueChange={setGenreFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Thể loại" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả thể loại</SelectItem>
{genres.map((g) => (
<SelectItem key={g.slug} value={g.slug}>{g.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Trạng thái" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả</SelectItem>
<SelectItem value="Đang ra">Đang ra</SelectItem>
<SelectItem value="Hoàn thành">Hoàn thành</SelectItem>
<SelectItem value="Tạm ngưng">Tạm ngưng</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Sắp xếp" />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">Mới nhất</SelectItem>
<SelectItem value="popular">Xem nhiều</SelectItem>
<SelectItem value="rating">Đánh giá cao</SelectItem>
<SelectItem value="name">Theo tên</SelectItem>
</SelectContent>
</Select>
</div>
{/* Results */}
<p className="mb-4 text-sm text-muted-foreground">{filteredNovels.length} kết quả</p>
{filteredNovels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Search className="mb-3 h-10 w-10 text-muted-foreground/40" />
<p className="text-lg font-medium">Không tìm thấy truyện</p>
<p className="text-sm">Thử tìm kiếm với từ khóa khác hoặc thay đi bộ lọc.</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{filteredNovels.map((novel) => (
<NovelCard key={novel.id} novel={novel} />
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,24 @@
"use client"
import { useEffect } from "react"
import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context"
interface ChapterReaderProgressProps {
novelId: string
chapterId: string
chapterNumber: number
}
export function ChapterReaderProgress({ novelId, chapterId, chapterNumber }: ChapterReaderProgressProps) {
const { user } = useAuth()
const { updateProgress } = useBookmarks()
useEffect(() => {
if (user) {
updateProgress(novelId, chapterId, chapterNumber)
}
}, [user, novelId, chapterId, chapterNumber, updateProgress])
return null
}
+135
View File
@@ -0,0 +1,135 @@
"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"
export default function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
const { slug, chapterId } = use(params)
const chapterNumber = parseInt(chapterId, 10)
const novel = getNovelBySlug(slug)
if (!novel || isNaN(chapterNumber)) {
notFound()
}
const chapter = getChapter(novel.id, chapterNumber)
const allChapters = getChaptersByNovelId(novel.id)
const maxChapter = allChapters.length
if (!chapter) {
notFound()
}
const comments = getCommentsByChapterId(chapter.id)
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]
)
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, idx) => (
<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} chapterNumber={chapter.number} />
{/* Comments */}
<section className="border-t border-border pt-8">
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter.id} />
</section>
{/* TTS Player */}
<TTSPlayer
paragraphs={paragraphs}
novelSlug={slug}
currentChapter={chapterNumber}
maxChapter={maxChapter}
chapterTitle={`Chuong ${chapter.number}: ${chapter.title}`}
/>
</div>
)
}
@@ -0,0 +1,51 @@
"use client"
import Link from "next/link"
import { BookOpen, BookMarked, BookmarkCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context"
interface NovelDetailActionsProps {
novelId: string
novelSlug: string
firstChapterNumber?: number
}
export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: NovelDetailActionsProps) {
const { user } = useAuth()
const { isBookmarked, toggleBookmark, getProgress } = useBookmarks()
const bookmarked = isBookmarked(novelId)
const progress = getProgress(novelId)
const readLink = progress?.lastChapterNumber
? `/truyen/${novelSlug}/${progress.lastChapterNumber}`
: firstChapterNumber
? `/truyen/${novelSlug}/${firstChapterNumber}`
: "#"
return (
<div className="flex flex-wrap gap-2 pt-1">
<Button asChild>
<Link href={readLink}>
<BookOpen className="mr-1.5 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>
) : (
<Button variant="outline" asChild>
<Link href="/dang-nhap">
<BookMarked className="mr-1.5 h-4 w-4" />
Lưu Truyện
</Link>
</Button>
)}
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
"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 { 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"
export default function NovelDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
const novel = getNovelBySlug(slug)
if (!novel) {
notFound()
}
const chapters = getChaptersByNovelId(novel.id)
const comments = getCommentsByNovelId(novel.id)
const novelGenres = novel.genres
.map((gSlug) => genres.find((g) => g.slug === gSlug))
.filter(Boolean) as typeof genres
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}`}>
<BookOpen className="h-14 w-14 text-background/80" />
</div>
{/* 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>
<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"><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>
</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"
}`}>
{novel.status}
</span>
</div>
<StarRating rating={novel.rating} ratingCount={novel.ratingCount} interactive />
<div className="flex flex-wrap gap-1.5">
{novelGenres.map((g) => (
<GenreBadge key={g.id} slug={g.slug} name={g.name} variant="link" />
))}
</div>
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={chapters[0]?.number} />
</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>
</section>
{/* Chapter list */}
<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} />
</div>
</section>
{/* Comments */}
<section className="mt-8">
<CommentSection comments={comments} novelId={novel.id} />
</section>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
"use client"
import Link from "next/link"
import { BookOpen, BookMarked, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context"
import { getNovelById } from "@/lib/data"
export default function BookshelfPage() {
const { user } = useAuth()
const { bookmarks, toggleBookmark } = useBookmarks()
if (!user) {
return (
<div className="flex min-h-[calc(100svh-8rem)] flex-col items-center justify-center px-4 text-center">
<BookMarked className="mb-4 h-12 w-12 text-muted-foreground/40" />
<h1 className="text-xl font-bold text-foreground">Tủ Sách</h1>
<p className="mt-2 text-sm text-muted-foreground">Đăng nhập đ xem danh sách truyện đã lưu</p>
<Button className="mt-4" asChild>
<Link href="/dang-nhap">Đăng Nhập</Link>
</Button>
</div>
)
}
const bookmarkedNovels = bookmarks
.map((b) => {
const novel = getNovelById(b.novelId)
return novel ? { novel, bookmark: b } : null
})
.filter(Boolean) as Array<{ novel: NonNullable<ReturnType<typeof getNovelById>>; bookmark: typeof bookmarks[number] }>
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold text-foreground">Tủ Sách</h1>
{bookmarkedNovels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookOpen className="mb-3 h-10 w-10 text-muted-foreground/40" />
<p className="text-lg font-medium text-muted-foreground">Chưa truyện nào</p>
<p className="text-sm text-muted-foreground">Hãy thêm truyện yêu thích vào tủ sách của bạn.</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/">Khám phá truyện</Link>
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{bookmarkedNovels.map(({ novel, bookmark }) => {
const readLink = bookmark.lastChapterNumber
? `/truyen/${novel.slug}/${bookmark.lastChapterNumber}`
: `/truyen/${novel.slug}/1`
return (
<div
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>
<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">
{novel.title}
</Link>
<p className="text-xs text-muted-foreground">{novel.author}</p>
{bookmark.lastChapterNumber && (
<p className="text-xs text-muted-foreground">
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" asChild>
<Link href={readLink}>
{bookmark.lastChapterNumber ? "Đọc tiếp" : "Đọc"}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => toggleBookmark(novel.id)}
aria-label="Xóa khỏi tủ sách"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
})}
</div>
)}
</div>
)
}