diff --git a/.gitignore b/.gitignore index 2129939..5b3c903 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __v0_jsx-dev-runtime.ts node_modules/ .next/ .env*.local -.DS_Store \ No newline at end of file +.DS_Store +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27edd8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Stage 1: Dependencies +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable pnpm && pnpm install --frozen-lockfile + +# Stage 2: Builder +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Update Alpine and install ssl for Prisma +RUN apk add --no-cache openssl +# Tạo prisma client +RUN npx prisma generate +# Chạy build +RUN corepack enable pnpm && pnpm run build + +# Stage 3: Runner +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +# Install openssl for Prisma runtime +RUN apk add --no-cache openssl + +COPY --from=builder /app/public ./public +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0a736b --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# Reader Project + +Đây là dự án nền tảng đọc truyện (Web Application) được xây dựng với kiến trúc hiện đại, kết hợp cơ sở dữ liệu quan hệ (PostgreSQL) và NoSQL (MongoDB) để tối ưu hóa việc lưu trữ và truy xuất nội dung văn bản lớn. + +## 🚀 Tính năng nổi bật + +- **Xác thực & Phân quyền**: Đăng nhập bằng Google Authentication (NextAuth). Hỗ trợ phân quyền người dùng (USER, MOD, ADMIN). +- **Quản lý nội dung (Dành cho MOD/ADMIN)**: Dashboard quản lý truyện, tải lên chương mới, quản lý trạng thái truyện (Đang ra, Hoàn thành, Tạm ngưng). +- **Trải nghiệm đọc**: Khám phá truyện theo thể loại, tìm kiếm truyện, đọc chương truyện với hiệu suất cao (nội dung lưu ở MongoDB). +- **Tương tác người dùng**: Tính năng tủ sách (bookmark) giúp lưu lại tiến độ đọc, hỗ trợ bình luận ở truyện và từng chương. + +## 🛠 Tech Stack + +- **Framework**: [Next.js](https://nextjs.org/) (App Router), React 19 +- **Styling**: [TailwindCSS v4](https://tailwindcss.com/) & [Radix UI](https://www.radix-ui.com/) (shadcn/ui) +- **Database Hybrid**: + - **PostgreSQL**: Lưu trữ dữ liệu cấu trúc (Tài khoản, Truyện, Thể loại, Bình luận, Tủ sách) thông qua **Prisma ORM**. + - **MongoDB**: Lưu trữ nội dung lớn (Chương truyện) thông qua **Mongoose**. +- **Auth**: [NextAuth.js](https://next-auth.js.org/) + +--- + +## 💻 Hướng dẫn chạy Local (Phát triển) + +### 1. Yêu cầu cài đặt +- [Node.js](https://nodejs.org/) (Khuyến nghị bản LTS) +- [pnpm](https://pnpm.io/) (Tool quản lý package) +- Database: PostgreSQL và MongoDB đang chạy cục bộ hoặc trên máy chủ. + +### 2. Cấu hình môi trường +Tạo file `.env` ở thư mục gốc dựa trên `.env.example` (nếu có) hoặc điền các thông tin sau: + +```env +# URL kết nối PostgreSQL +DATABASE_URL="postgresql://user:password@localhost:5432/reader?schema=public" + +# URL kết nối MongoDB +MONGODB_URI="mongodb://user:password@localhost:27017/reader?authSource=admin" + +# Cấu hình NextAuth +NEXTAUTH_SECRET="your-super-secret-key" +NEXTAUTH_URL="http://localhost:3000" + +# Cấu hình Google Login +GOOGLE_CLIENT_ID="your_google_client_id" +GOOGLE_CLIENT_SECRET="your_google_client_secret" +``` + +### 3. Cài đặt dependencies và khởi tạo DB + +```bash +# Cài đặt các gói thư viện +pnpm install + +# Đồng bộ schema xuống PostgreSQL và generate Prisma client +npx prisma db push +# hoặc (nếu muốn dùng migrate) +# npx prisma migrate dev + +# Generate thư viện Prisma +npx prisma generate +``` + +### 4. Chạy môi trường phát triển + +```bash +pnpm dev +``` +Truy cập vào [http://localhost:3000](http://localhost:3000) để xem ứng dụng. + +--- + +## 🏗 Hướng dẫn Build + +Để build project cho môi trường production: + +```bash +# Đảm bảo Prisma Client đã được generate +npx prisma generate + +# Chạy lệnh build của Next.js +pnpm build +``` + +Sau khi build xong, bạn có thể khởi chạy server production bằng: +```bash +pnpm start +``` + +--- + +## 🐳 Triển khai dưới dạng Docker + +Bạn có thể dễ dàng triển khai ứng dụng bằng nền tảng Docker. Dưới đây là cách đóng gói và chạy thông qua `docker-compose`. + +### 1. Tạo file `Dockerfile` +Tạo file `Dockerfile` ở thư mục gốc của dự án với cấu hình multi-stage build để tối ưu dung lượng: + +```dockerfile +# Stage 1: Dependencies +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable pnpm && pnpm install --frozen-lockfile + +# Stage 2: Builder +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Tạo prisma client +RUN npx prisma generate +# Chạy build +RUN corepack enable pnpm && pnpm build + +# Stage 3: Runner +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 +ENV PORT=3000 + +CMD ["node", "server.js"] +``` +*(Lưu ý: Để build mục `standalone` hoạt động, bạn cần bổ sung `output: 'standalone'` trong file `next.config.mjs`)* + +### 2. Tạo file `docker-compose.yml` +Sử dụng Docker Compose để chạy ứng dụng (giả sử Database của bạn được host riêng hoặc bạn có thể thêm service DB vào file này): + +```yaml +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile + image: reader-web:latest + container_name: reader-app + ports: + - "3000:3000" + env_file: + - .env + restart: unless-stopped +``` + +### 3. Khởi chạy bằng Docker +Chạy lệnh sau để build image và start container: + +```bash +# Build và chạy ngầm (detached mode) +docker-compose up -d --build +``` + +Để xem log của container: +```bash +docker-compose logs -f web +``` +Dừng và xóa container: +```bash +docker-compose down +``` diff --git a/app/api/mod/chuong/[id]/route.ts b/app/api/mod/chuong/[id]/route.ts index 1bdbcad..9cf2ca9 100644 --- a/app/api/mod/chuong/[id]/route.ts +++ b/app/api/mod/chuong/[id]/route.ts @@ -26,15 +26,22 @@ export async function GET( return NextResponse.json({ error: "Chapter not found" }, { status: 404 }) } - // Verify the moderator owns the related novel + // Verify the moderator owns the related novel (or is an ADMIN) + let novelQuery: any = { id: chapter.novelId } + if (session.user.role !== "ADMIN") { + novelQuery.uploaderId = session.user.id + } + const novel = await prisma.novel.findFirst({ - where: { - id: chapter.novelId, - uploaderId: session.user.id - } + where: novelQuery }) if (!novel) { + console.log("Novel not found or unauthorized:", { + chapterNovelId: chapter.novelId, + userId: session.user.id, + role: session.user.role + }) return NextResponse.json({ error: "Unauthorized access to this chapter" }, { status: 403 }) } diff --git a/app/api/mod/chuong/global-replace/route.ts b/app/api/mod/chuong/global-replace/route.ts new file mode 100644 index 0000000..d7ae839 --- /dev/null +++ b/app/api/mod/chuong/global-replace/route.ts @@ -0,0 +1,121 @@ +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 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 body = await req.json() + const { novelId, action = "replace", findText, replaceText, matchCase = false, trashWords = "", preview = false } = body + + if (!novelId) { + return NextResponse.json({ error: "novelId is required" }, { status: 400 }) + } + + // Verify that the novel belongs to the uploader + let novelQuery: any = { id: novelId } + if (session.user.role !== "ADMIN") { + novelQuery.uploaderId = session.user.id + } + + const novel = await prisma.novel.findFirst({ + where: novelQuery, + }) + + if (!novel) { + return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 }) + } + + await connectToMongoDB() + + let patterns: { regex: RegExp, replaceWith: string }[] = [] + + if (action === "replace") { + if (!findText) return NextResponse.json({ error: "findText is required for replace action" }, { status: 400 }) + const flags = matchCase ? "g" : "gi" + const safeFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + patterns.push({ regex: new RegExp(safeFindText, flags), replaceWith: replaceText || "" }) + } else if (action === "trash") { + let words: string[] = [] + if (Array.isArray(trashWords)) { + words = trashWords + } else if (typeof trashWords === "string") { + words = trashWords.split(',').map((w: string) => w.trim()).filter((w: string) => w.length > 0) + } + + if (words.length === 0) return NextResponse.json({ error: "No valid words provided" }, { status: 400 }) + + words.forEach((word: string) => { + const safeWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + patterns.push({ regex: new RegExp(safeWord, 'gi'), replaceWith: "" }) + }) + } else { + return NextResponse.json({ error: "Invalid action" }, { status: 400 }) + } + + // Find all chapters for the novel + const chapters = await Chapter.find({ novelId }).sort({ number: 1 }) + let updatedCount = 0 + let previewResults: any[] = [] + + for (const chap of chapters) { + let originalContent = chap.content || "" + let newContent = originalContent + let modified = false + + patterns.forEach(({ regex, replaceWith }) => { + if (regex.test(newContent)) { + modified = true + newContent = newContent.replace(regex, replaceWith) + } + }) + + if (modified) { + if (preview && previewResults.length < 5) { // Limit previews to 5 chapters to save payload size + // Capture a small text snippet from the first pattern match + let snippet = "" + if (patterns.length > 0) { + const match = patterns[0].regex.exec(originalContent) + if (match) { + const matchIndex = match.index + const start = Math.max(0, matchIndex - 30) + const end = Math.min(originalContent.length, matchIndex + match[0].length + 30) + snippet = "..." + originalContent.substring(start, end).replace(/\n/g, ' ') + "..." + } + } + + previewResults.push({ + chapterId: chap._id, + number: chap.number, + title: chap.title, + snippet + }) + } + + if (!preview) { + chap.content = newContent + await chap.save() + } + + updatedCount++ + } + } + + return NextResponse.json({ + message: preview ? "Preview generated" : "Success", + updatedChapters: updatedCount, + previews: previewResults + }, { status: 200 }) + + } catch (error) { + console.error("Global Replace Error:", error) + return NextResponse.json({ error: "Failed to perform global replacement" }, { status: 500 }) + } +} diff --git a/app/api/mod/truyen/[id]/trash-words/route.ts b/app/api/mod/truyen/[id]/trash-words/route.ts new file mode 100644 index 0000000..e16ad91 --- /dev/null +++ b/app/api/mod/truyen/[id]/trash-words/route.ts @@ -0,0 +1,72 @@ +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(req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: novelId } = await params + + try { + const novel = await prisma.novel.findUnique({ + where: { id: novelId }, + select: { trashWords: true, uploaderId: true } + }) + + if (!novel) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + return NextResponse.json({ trashWords: novel.trashWords }) + } catch (error) { + console.error("GET Trash Words Error:", error) + return NextResponse.json({ error: "Lỗi Server" }, { status: 500 }) + } +} + +export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: novelId } = await params + + try { + const novel = await prisma.novel.findUnique({ + where: { id: novelId }, + select: { id: true, uploaderId: true } + }) + + if (!novel) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const body = await req.json() + const { trashWords } = body + + if (!Array.isArray(trashWords)) { + return NextResponse.json({ error: "Mảng từ rác không hợp lệ" }, { status: 400 }) + } + + const updated = await prisma.novel.update({ + where: { id: novelId }, + data: { trashWords } + }) + + return NextResponse.json({ success: true, trashWords: updated.trashWords }) + } catch (error) { + console.error("PUT Trash Words Error:", error) + return NextResponse.json({ error: "Lỗi Server" }, { status: 500 }) + } +} diff --git a/app/api/mod/truyen/route.ts b/app/api/mod/truyen/route.ts index 9080b34..b2a4f17 100644 --- a/app/api/mod/truyen/route.ts +++ b/app/api/mod/truyen/route.ts @@ -29,16 +29,19 @@ export async function POST(req: Request) { try { const data = await req.json() - const { title, authorName, description, genreIds = [] } = data + const { title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds = [] } = data // Tạo slug từ title const slug = slugify(title) const newNovel = await prisma.novel.create({ data: { title, + originalTitle, slug: slug, authorName, + originalAuthorName, description, + coverUrl, uploaderId: session.user.id, genres: { create: genreIds.map((id: string) => ({ @@ -61,15 +64,18 @@ export async function PUT(req: Request) { try { const data = await req.json() - const { id, title, authorName, description, status, genreIds } = data + const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data // Update basic info and recreate genre relations const updatedNovel = await prisma.novel.update({ where: { id: id, uploaderId: session.user.id }, // Make sure they own it data: { title, + originalTitle, authorName, + originalAuthorName, description, + coverUrl, status, // Replace all existing genres if genreIds is provided ...(genreIds !== undefined && { diff --git a/app/api/mod/upload-cover/route.ts b/app/api/mod/upload-cover/route.ts new file mode 100644 index 0000000..2bd362d --- /dev/null +++ b/app/api/mod/upload-cover/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/lib/auth" +import { writeFile } from "fs/promises" +import path from "path" +import fs from "fs" + +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions) + if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const formData = await req.formData() + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }) + } + + if (!file.type.startsWith("image/")) { + return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 }) + } + + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + + const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9) + const ext = path.extname(file.name) || ".jpg" + const filename = `cover-${uniqueSuffix}${ext}` + + const uploadDir = path.join(process.cwd(), "public", "uploads", "covers") + + // Ensure directory exists + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + + const filepath = path.join(uploadDir, filename) + await writeFile(filepath, buffer) + + return NextResponse.json({ url: `/uploads/covers/${filename}` }) + } catch (error: any) { + console.error("Cover upload error:", error) + return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 }) + } +} diff --git a/app/api/truyen/[id]/chapters/route.ts b/app/api/truyen/[id]/chapters/route.ts new file mode 100644 index 0000000..70fdd10 --- /dev/null +++ b/app/api/truyen/[id]/chapters/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import connectToMongoDB from "@/lib/mongoose" +import { Chapter } from "@/lib/models/chapter" + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> } // `id` is the `novel.id` +) { + try { + const { id: novelId } = await params + + const { searchParams } = new URL(req.url) + const page = parseInt(searchParams.get("page") || "1", 10) + const limit = parseInt(searchParams.get("limit") || "100", 10) + + await connectToMongoDB() + + const skip = (page - 1) * limit + + const [chapters, totalChapters] = await Promise.all([ + Chapter.find({ novelId }) + .sort({ number: 1 }) + .skip(skip) + .limit(limit) + .select("number title createdAt") // don't return content + .lean(), + Chapter.countDocuments({ novelId }) + ]) + + return NextResponse.json({ + chapters: chapters.map(c => ({ + id: c._id.toString(), + number: c.number, + title: c.title, + createdAt: (c.createdAt as Date).toISOString() + })), + totalChapters, + totalPages: Math.ceil(totalChapters / limit), + currentPage: page + }) + + } catch (error: any) { + console.error("Fetch novel chapters error:", error) + return NextResponse.json( + { error: "Không thể lấy danh sách chương" }, + { status: 500 } + ) + } +} diff --git a/app/api/user/bookmarks/route.ts b/app/api/user/bookmarks/route.ts index 4d931ee..0c8b899 100644 --- a/app/api/user/bookmarks/route.ts +++ b/app/api/user/bookmarks/route.ts @@ -78,6 +78,39 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Missing chapter info" }, { status: 400 }) } + // Lấy bookmark cũ (nếu có) + const existingBookmark = await prisma.bookmark.findUnique({ + where: { + userId_novelId: { + userId: session.user.id, + novelId, + } + } + }) + + let newReadChapters: number[] = [] + let newHasCountedView = false + let shouldIncrementNovelView = false + + if (existingBookmark) { + newReadChapters = existingBookmark.readChapters || [] + newHasCountedView = existingBookmark.hasCountedView + + // Nếu chương này chưa đọc, thêm vào mảng + if (!newReadChapters.includes(lastChapterNumber)) { + newReadChapters.push(lastChapterNumber) + } + + // Nếu đọc đủ 5 chương và chưa từng đếm view + if (newReadChapters.length >= 5 && !newHasCountedView) { + newHasCountedView = true + shouldIncrementNovelView = true + } + } else { + newReadChapters = [lastChapterNumber] + // Chưa đủ 5 chương ngay từ lần đầu tạo + } + const bookmark = await prisma.bookmark.upsert({ where: { userId_novelId: { @@ -87,15 +120,27 @@ export async function POST(req: Request) { }, update: { lastChapterId, - lastChapterNumber + lastChapterNumber, + readChapters: newReadChapters, + hasCountedView: newHasCountedView }, create: { userId: session.user.id, novelId, lastChapterId, - lastChapterNumber + lastChapterNumber, + readChapters: newReadChapters, + hasCountedView: newHasCountedView } }) + + if (shouldIncrementNovelView) { + await prisma.novel.update({ + where: { id: novelId }, + data: { views: { increment: 1 } } + }) + } + return NextResponse.json({ status: "updated", bookmark }) } diff --git a/app/dang-nhap/page.tsx b/app/dang-nhap/page.tsx index ac15455..07861b0 100644 --- a/app/dang-nhap/page.tsx +++ b/app/dang-nhap/page.tsx @@ -39,7 +39,7 @@ export default function LoginPage() {
diff --git a/app/layout.tsx b/app/layout.tsx
index ff0038a..9cb5482 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,6 +1,5 @@
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'
@@ -15,7 +14,7 @@ const beVietnam = Be_Vietnam_Pro({
})
export const metadata: Metadata = {
- title: 'TruyenChu - Đọc Truyện Chữ Online',
+ title: "Virtus's Reader - Đọ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: {
@@ -63,7 +62,6 @@ export default function RootLayout({
-