From 112e8604e211cd16082917dd2be9e307758e0500 Mon Sep 17 00:00:00 2001 From: fevirtus Date: Thu, 5 Mar 2026 16:46:38 +0700 Subject: [PATCH] Initial commit --- .env | 6 + .gitattributes | 2 + .gitignore | 10 + app/api/auth/[...nextauth]/route.ts | 6 + app/api/dev/make-mod/route.ts | 30 + app/api/mod/chuong/route.ts | 72 + app/api/mod/truyen/route.ts | 52 + app/dang-nhap/page.tsx | 75 + app/globals.css | 126 + app/layout.tsx | 70 + app/mod/chuong/chapter-client.tsx | 203 + app/mod/chuong/page.tsx | 13 + app/mod/layout.tsx | 43 + app/mod/page.tsx | 30 + app/mod/truyen/novel-client.tsx | 176 + app/mod/truyen/page.tsx | 13 + app/page.tsx | 147 + app/the-loai/[slug]/page.tsx | 81 + app/the-loai/page.tsx | 45 + app/tim-kiem/page.tsx | 122 + .../[chapterId]/chapter-reader-progress.tsx | 24 + app/truyen/[slug]/[chapterId]/page.tsx | 135 + app/truyen/[slug]/novel-detail-actions.tsx | 51 + app/truyen/[slug]/page.tsx | 91 + app/tu-sach/page.tsx | 96 + components.json | 21 + components/chapter-list.tsx | 35 + components/comment-section.tsx | 102 + components/footer.tsx | 25 + components/genre-badge.tsx | 26 + components/header.tsx | 202 + components/novel-card.tsx | 70 + components/reading-settings.tsx | 94 + components/star-rating.tsx | 52 + components/theme-provider.tsx | 11 + components/theme-toggle.tsx | 35 + components/tts-player.tsx | 400 ++ components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 60 + components/ui/calendar.tsx | 213 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 ++ components/ui/chart.tsx | 353 ++ components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 252 ++ components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 ++ components/ui/empty.tsx | 104 + components/ui/field.tsx | 244 ++ components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 169 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 ++ components/ui/navigation-menu.tsx | 166 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 185 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 726 ++++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 25 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.tsx | 19 + components/ui/use-toast.ts | 191 + hooks/use-mobile.ts | 19 + hooks/use-toast.ts | 191 + lib/auth-context.tsx | 37 + lib/auth.ts | 35 + lib/bookmark-context.tsx | 89 + lib/data.ts | 379 ++ lib/models/chapter.ts | 23 + lib/mongoose.ts | 40 + lib/prisma.ts | 11 + lib/types.ts | 62 + lib/utils.ts | 6 + next-env.d.ts | 6 + next.config.mjs | 11 + package.json | 79 + pnpm-lock.yaml | 3738 +++++++++++++++++ postcss.config.mjs | 8 + prisma/schema.prisma | 138 + public/apple-icon.png | Bin 0 -> 2626 bytes public/icon-dark-32x32.png | Bin 0 -> 585 bytes public/icon-light-32x32.png | Bin 0 -> 566 bytes public/icon.svg | 26 + public/placeholder-logo.png | Bin 0 -> 568 bytes public/placeholder-logo.svg | 1 + public/placeholder-user.jpg | Bin 0 -> 1635 bytes public/placeholder.jpg | Bin 0 -> 1064 bytes public/placeholder.svg | 1 + scripts/setup-db.js | 49 + styles/globals.css | 125 + tsconfig.json | 41 + types/next-auth.d.ts | 17 + 124 files changed, 14369 insertions(+) create mode 100644 .env create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/dev/make-mod/route.ts create mode 100644 app/api/mod/chuong/route.ts create mode 100644 app/api/mod/truyen/route.ts create mode 100644 app/dang-nhap/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/mod/chuong/chapter-client.tsx create mode 100644 app/mod/chuong/page.tsx create mode 100644 app/mod/layout.tsx create mode 100644 app/mod/page.tsx create mode 100644 app/mod/truyen/novel-client.tsx create mode 100644 app/mod/truyen/page.tsx create mode 100644 app/page.tsx create mode 100644 app/the-loai/[slug]/page.tsx create mode 100644 app/the-loai/page.tsx create mode 100644 app/tim-kiem/page.tsx create mode 100644 app/truyen/[slug]/[chapterId]/chapter-reader-progress.tsx create mode 100644 app/truyen/[slug]/[chapterId]/page.tsx create mode 100644 app/truyen/[slug]/novel-detail-actions.tsx create mode 100644 app/truyen/[slug]/page.tsx create mode 100644 app/tu-sach/page.tsx create mode 100644 components.json create mode 100644 components/chapter-list.tsx create mode 100644 components/comment-section.tsx create mode 100644 components/footer.tsx create mode 100644 components/genre-badge.tsx create mode 100644 components/header.tsx create mode 100644 components/novel-card.tsx create mode 100644 components/reading-settings.tsx create mode 100644 components/star-rating.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/theme-toggle.tsx create mode 100644 components/tts-player.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-toast.ts create mode 100644 lib/auth-context.tsx create mode 100644 lib/auth.ts create mode 100644 lib/bookmark-context.tsx create mode 100644 lib/data.ts create mode 100644 lib/models/chapter.ts create mode 100644 lib/mongoose.ts create mode 100644 lib/prisma.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 prisma/schema.prisma create mode 100644 public/apple-icon.png create mode 100644 public/icon-dark-32x32.png create mode 100644 public/icon-light-32x32.png create mode 100644 public/icon.svg create mode 100644 public/placeholder-logo.png create mode 100644 public/placeholder-logo.svg create mode 100644 public/placeholder-user.jpg create mode 100644 public/placeholder.jpg create mode 100644 public/placeholder.svg create mode 100644 scripts/setup-db.js create mode 100644 styles/globals.css create mode 100644 tsconfig.json create mode 100644 types/next-auth.d.ts diff --git a/.env b/.env new file mode 100644 index 0000000..ec4d03b --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DATABASE_URL="postgresql://reader:reader%40123@master-02:5432/reader?schema=public" +MONGODB_URI="mongodb://root:virtus%40123@master-02:27017/reader?authSource=admin" +NEXTAUTH_SECRET="your-super-secret-key" +NEXTAUTH_URL="http://localhost:3000" +GOOGLE_CLIENT_ID="752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="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" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2129939 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# v0 runtime files +__v0_runtime_loader.js +__v0_devtools.tsx +__v0_jsx-dev-runtime.ts + +# Common ignores +node_modules/ +.next/ +.env*.local +.DS_Store \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b6149fb --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 } diff --git a/app/api/dev/make-mod/route.ts b/app/api/dev/make-mod/route.ts new file mode 100644 index 0000000..30b58b9 --- /dev/null +++ b/app/api/dev/make-mod/route.ts @@ -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 }) + } +} diff --git a/app/api/mod/chuong/route.ts b/app/api/mod/chuong/route.ts new file mode 100644 index 0000000..f849fa7 --- /dev/null +++ b/app/api/mod/chuong/route.ts @@ -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 }) + } +} diff --git a/app/api/mod/truyen/route.ts b/app/api/mod/truyen/route.ts new file mode 100644 index 0000000..0b5d930 --- /dev/null +++ b/app/api/mod/truyen/route.ts @@ -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 }) + } +} diff --git a/app/dang-nhap/page.tsx b/app/dang-nhap/page.tsx new file mode 100644 index 0000000..ac15455 --- /dev/null +++ b/app/dang-nhap/page.tsx @@ -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 ( + + + + + + + ) +} + +export default function LoginPage() { + const router = useRouter() + const { user, loginWithGoogle } = useAuth() + + useEffect(() => { + if (user) { + router.push("/") + } + }, [user, router]) + + const handleGoogleLogin = () => { + loginWithGoogle() + router.push("/") + } + + return ( +
+
+
+ + + TruyenChu + +

Chao mung ban

+

+ {"Dang nhap de luu truyen va theo doi tien do doc"} +

+
+ +
+ + +
+ {"Chung toi se khong bao gio chia se thong tin cua ban voi bat ky ai."} +
+
+ +

+ {"Khi dang nhap, ban dong y voi "} + {"Dieu khoan su dung"} + {" va "} + {"Chinh sach bao mat"} + {" cua chung toi."} +

+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b4295fb --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ff0038a --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + +
+
+
{children}
+
+
+
+
+
+ + + + ) +} diff --git a/app/mod/chuong/chapter-client.tsx b/app/mod/chuong/chapter-client.tsx new file mode 100644 index 0000000..1cda041 --- /dev/null +++ b/app/mod/chuong/chapter-client.tsx @@ -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([]) + 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 ( +
+ Vui lòng chọn một truyện từ mục Quản lý truyện để xem danh sách chương. +
+ + + +
+ ) + } + + return ( +
+
+

+ Quản lý chương +

+ + + + + + + + Đăng Chương Mới + + Thêm nội dung một chương truyện để gửi đến độc giả. + + +
+
+
+ + setNumber(e.target.value)} required /> +
+
+ + setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus /> +
+
+
+ +