commit 112e8604e211cd16082917dd2be9e307758e0500 Author: fevirtus Date: Thu Mar 5 16:46:38 2026 +0700 Initial commit 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 /> +
+
+
+ +