Initial commit
This commit is contained in:
@@ -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 }
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 lý 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 lý 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 lý 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 có 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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 lý 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 lý chương
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 có truyện nào</p>
|
||||
<p className="text-sm">Thể loại này chưa có 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 có 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user