Refactor code structure for improved readability and maintainability
This commit is contained in:
+2
-1
@@ -7,4 +7,5 @@ __v0_jsx-dev-runtime.ts
|
||||
node_modules/
|
||||
.next/
|
||||
.env*.local
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.env
|
||||
+36
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function LoginPage() {
|
||||
<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>
|
||||
<span className="text-xl font-bold text-foreground">Virtus's Reader</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">
|
||||
|
||||
+1
-3
@@ -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({
|
||||
</BookmarkProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Loader2, ArrowLeft, Save, SplitSquareHorizontal, Search, Trash2, X, Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
||||
// Helper to convert plain text with URLs to HTML with <a> tags
|
||||
const renderWithLinks = (text: string) => {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
return text.split('\n').map((paragraph, index) => {
|
||||
if (!paragraph.trim()) return <br key={index} />
|
||||
|
||||
const parts = paragraph.split(urlRegex)
|
||||
return (
|
||||
<p key={index} className="mb-4 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.match(urlRegex)) {
|
||||
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{part}</a>
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function EditorClient({ chapterId }: { chapterId: string }) {
|
||||
const router = useRouter()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [novel, setNovel] = useState<any>(null)
|
||||
|
||||
// Core states
|
||||
const [number, setNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalNovelId, setOriginalNovelId] = useState("")
|
||||
|
||||
// Tool states
|
||||
const [openToolDialog, setOpenToolDialog] = useState(false)
|
||||
const [toolAction, setToolAction] = useState<"replace" | "trash">("replace")
|
||||
const [toolScope, setToolScope] = useState<"chapter" | "novel">("chapter")
|
||||
const [toolFindText, setToolFindText] = useState("")
|
||||
const [toolReplaceText, setToolReplaceText] = useState("")
|
||||
const [toolTrashWords, setToolTrashWords] = useState("") // Just for the input box
|
||||
const [novelTrashWords, setNovelTrashWords] = useState<string[]>([]) // Persisted DB array
|
||||
const [toolMatchCase, setToolMatchCase] = useState(false)
|
||||
const [toolExecuting, setToolExecuting] = useState(false)
|
||||
const [toolPreviewing, setToolPreviewing] = useState(false)
|
||||
const [toolPreviewResults, setToolPreviewResults] = useState<any[]>([])
|
||||
|
||||
// UI Layout states
|
||||
const [splitView, setSplitView] = useState(true)
|
||||
|
||||
// Sync Scroll Refs
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const isScrolling = useRef<'textarea' | 'preview' | null>(null)
|
||||
const scrollTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChapter = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mod/chuong/${chapterId}`)
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
console.error("Fetch chapter error:", res.status, errorData)
|
||||
throw new Error(errorData.error || `Không thể tải chương (${res.status})`)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
setNumber(data.number.toString())
|
||||
setTitle(data.title)
|
||||
setContent(data.content)
|
||||
setOriginalNovelId(data.novelId)
|
||||
|
||||
// Fetch novel details to show breadcrumbs
|
||||
const novelRes = await fetch(`/api/truyen?slug=${data.novelId}`)
|
||||
if (novelRes.ok) {
|
||||
const novelData = await novelRes.json()
|
||||
setNovel(novelData)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load:", error)
|
||||
toast.error(error.message || "Lỗi khi tải dữ liệu chương")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchChapter()
|
||||
}, [chapterId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!originalNovelId) return
|
||||
const fetchTrashWords = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setNovelTrashWords(data.trashWords || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch trash words error:", e)
|
||||
}
|
||||
}
|
||||
fetchTrashWords()
|
||||
}, [originalNovelId])
|
||||
|
||||
const handleTextareaScroll = () => {
|
||||
if (!textareaRef.current || !previewRef.current) return
|
||||
if (isScrolling.current === 'preview') return
|
||||
isScrolling.current = 'textarea'
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = textareaRef.current
|
||||
const percentage = scrollTop / (scrollHeight - clientHeight) || 0
|
||||
|
||||
const maxPreviewScroll = previewRef.current.scrollHeight - previewRef.current.clientHeight
|
||||
previewRef.current.scrollTop = percentage * maxPreviewScroll
|
||||
|
||||
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
|
||||
scrollTimeout.current = setTimeout(() => { isScrolling.current = null }, 50)
|
||||
}
|
||||
|
||||
const handlePreviewScroll = () => {
|
||||
if (!textareaRef.current || !previewRef.current) return
|
||||
if (isScrolling.current === 'textarea') return
|
||||
isScrolling.current = 'preview'
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = previewRef.current
|
||||
const percentage = scrollTop / (scrollHeight - clientHeight) || 0
|
||||
|
||||
const maxTextareaScroll = textareaRef.current.scrollHeight - textareaRef.current.clientHeight
|
||||
textareaRef.current.scrollTop = percentage * maxTextareaScroll
|
||||
|
||||
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
|
||||
scrollTimeout.current = setTimeout(() => { isScrolling.current = null }, 50)
|
||||
}
|
||||
|
||||
const handleAddTrashWord = async () => {
|
||||
if (!toolTrashWords.trim()) return
|
||||
const newWords = [...novelTrashWords, toolTrashWords]
|
||||
setNovelTrashWords(newWords)
|
||||
setToolTrashWords("")
|
||||
try {
|
||||
await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trashWords: newWords })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Save trash words error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTrashWord = async (index: number) => {
|
||||
const newWords = novelTrashWords.filter((_, i) => i !== index)
|
||||
setNovelTrashWords(newWords)
|
||||
try {
|
||||
await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trashWords: newWords })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Save trash words error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title || !content || !number) {
|
||||
toast.error("Vui lòng điền đủ thông tin")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: chapterId,
|
||||
novelId: originalNovelId,
|
||||
number: parseInt(number),
|
||||
title,
|
||||
content
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Cập nhật thất bại")
|
||||
|
||||
toast.success("Đã lưu chương thành công!")
|
||||
router.push(`/mod/chuong?novelId=${originalNovelId}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToolExecute = async (isPreview: boolean = false) => {
|
||||
if (toolAction === "replace" && !toolFindText) {
|
||||
toast.error("Vui lòng nhập từ khóa cần tìm")
|
||||
return
|
||||
}
|
||||
if (toolAction === "trash" && novelTrashWords.length === 0) {
|
||||
toast.error("Danh sách từ rác trống. Vui lòng thêm từ rác trước.")
|
||||
return
|
||||
}
|
||||
|
||||
if (toolScope === "chapter") {
|
||||
if (isPreview) {
|
||||
toast.info("Xem trước chỉ áp dụng cho Toàn Truyện. Xin hãy áp dụng ngay cho chương này.")
|
||||
return
|
||||
}
|
||||
|
||||
let newContent = content
|
||||
let count = 0
|
||||
const flags = toolMatchCase ? 'g' : 'gi'
|
||||
|
||||
if (toolAction === "replace") {
|
||||
const safeFindText = toolFindText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(safeFindText, flags)
|
||||
const matches = newContent.match(regex)
|
||||
if (matches) count = matches.length
|
||||
newContent = newContent.replace(regex, toolReplaceText)
|
||||
toast.success(`Đã thay thế ${count} lần nhóm từ "${toolFindText}" thành "${toolReplaceText}"`)
|
||||
} else {
|
||||
novelTrashWords.forEach(word => {
|
||||
const safeWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(safeWord, flags)
|
||||
const matches = newContent.match(regex)
|
||||
if (matches) count += matches.length
|
||||
newContent = newContent.replace(regex, '')
|
||||
})
|
||||
toast.success(`Đã lọc bỏ ${count} từ rác`)
|
||||
}
|
||||
setContent(newContent)
|
||||
setOpenToolDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Global replace (Entire novel scope)
|
||||
if (isPreview) setToolPreviewing(true)
|
||||
else setToolExecuting(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong/global-replace", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
novelId: originalNovelId,
|
||||
action: toolAction,
|
||||
findText: toolFindText,
|
||||
replaceText: toolReplaceText,
|
||||
trashWords: novelTrashWords,
|
||||
matchCase: toolMatchCase,
|
||||
preview: isPreview
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Thao tác thất bại")
|
||||
|
||||
if (isPreview) {
|
||||
setToolPreviewResults(data.previews || [])
|
||||
if (data.previews?.length === 0) {
|
||||
toast.info("Không tìm thấy kết quả nào trùng khớp trên toàn truyện")
|
||||
}
|
||||
} else {
|
||||
toast.success(`Đã chạy công cụ thành công trên ${data.updatedChapters} chương!`)
|
||||
toast.info("Trang đang tự tải lại để cập nhật nội dung mới...", { duration: 2000 })
|
||||
setTimeout(() => window.location.reload(), 2000)
|
||||
setOpenToolDialog(false)
|
||||
setToolPreviewResults([])
|
||||
setToolFindText("")
|
||||
setToolReplaceText("")
|
||||
setToolTrashWords("")
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
if (isPreview) setToolPreviewing(false)
|
||||
else setToolExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center items-center h-[50vh]"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-7xl mx-auto flex flex-col h-[calc(100vh-6rem)]">
|
||||
{/* Header & Breadcrumb */}
|
||||
<div className="flex items-center justify-between bg-card p-4 rounded-xl border shadow-sm shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/mod/chuong?novelId=${originalNovelId}`}>
|
||||
<Button variant="ghost" size="icon"><ArrowLeft className="w-5 h-5" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Chỉnh sửa Chương {number}</h1>
|
||||
<p className="text-sm text-muted-foreground">{novel?.title || `Novel ID: ${originalNovelId}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setSplitView(!splitView)} className="hidden md:flex gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
{splitView ? "Tắt Xem Trước" : "Bật Xem Trước"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Lưu Thay Đổi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Tools Bar */}
|
||||
<div className="bg-card p-3 rounded-xl border shadow-sm flex flex-wrap items-center gap-4 shrink-0">
|
||||
<Button variant="secondary" onClick={() => { setToolAction("replace"); setOpenToolDialog(true) }} className="gap-2">
|
||||
<Search className="w-4 h-4 text-muted-foreground" /> Tìm & Thay Thế
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setToolAction("trash"); setOpenToolDialog(true) }} className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200">
|
||||
<Trash2 className="w-4 h-4" /> Dọn Dẹp Từ Rác
|
||||
</Button>
|
||||
|
||||
<Dialog open={openToolDialog} onOpenChange={(open) => {
|
||||
setOpenToolDialog(open)
|
||||
if (!open) setToolPreviewResults([])
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{toolAction === "replace" ? "Bộ công cụ: Tìm Kiếm & Thay Thế" : "Bộ công cụ: Dọn Dẹp Từ Rác"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{toolAction === "replace"
|
||||
? "Thay thế cụm từ trên chương này hoặc trên toàn bộ truyện."
|
||||
: "Xóa bỏ các cụm từ rác, watermark trên chương này hoặc trên toàn bộ truyện."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 custom-scrollbar pr-2">
|
||||
{toolPreviewResults.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg">Bản xem trước ({toolPreviewResults.length} ví dụ)</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => setToolPreviewResults([])}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Quay lại tuỳ chỉnh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{toolPreviewResults.map((res: any) => (
|
||||
<div key={res.chapterId} className="p-3 border rounded-lg bg-card text-left">
|
||||
<div className="text-sm font-medium mb-1">Chương {res.number}: {res.title}</div>
|
||||
<div className="text-sm text-muted-foreground bg-muted p-2 rounded italic font-serif">
|
||||
{res.snippet}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Scope Selector */}
|
||||
<div className="p-3 border rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-medium mb-2">Phạm vi áp dụng thao tác:</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="toolScope" checked={toolScope === "chapter"} onChange={() => setToolScope("chapter")} />
|
||||
<span>Chỉ Chương Này</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="toolScope" checked={toolScope === "novel"} onChange={() => setToolScope("novel")} />
|
||||
<span className="text-primary font-medium">Toàn Bộ Truyện</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Content */}
|
||||
{toolAction === "replace" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tìm cụm từ:</label>
|
||||
<Input
|
||||
placeholder="Ví dụ: truyenchu.vn"
|
||||
value={toolFindText}
|
||||
onChange={(e) => setToolFindText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thay thế bằng (Bỏ trống để xóa hoàn toàn):</label>
|
||||
<Input
|
||||
placeholder="..."
|
||||
value={toolReplaceText}
|
||||
onChange={(e) => setToolReplaceText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-2 w-max">
|
||||
<input type="checkbox" className="w-4 h-4 rounded" checked={toolMatchCase} onChange={(e) => setToolMatchCase(e.target.checked)} />
|
||||
<span className="text-sm">Phân biệt chữ Hoa / chữ thường</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{novelTrashWords.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Danh sách từ rác hiện tại:</label>
|
||||
<div className="flex flex-col gap-2 max-h-[40vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{novelTrashWords.map((word, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-3 relative bg-red-50 dark:bg-red-950/20 text-red-900 border border-red-200 dark:border-red-900/50 rounded-lg group">
|
||||
<pre className="text-sm flex-1 whitespace-pre-wrap font-sans">{word}</pre>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500 hover:text-red-700 hover:bg-red-100 shrink-0" onClick={() => handleRemoveTrashWord(idx)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mt-4 pt-4 border-t">
|
||||
<label className="text-sm font-medium">Thêm từ rác (Hỗ trợ nhiều dòng với Enter):</label>
|
||||
<Textarea
|
||||
placeholder="Ví dụ:\n\n.\n\n.\n\n."
|
||||
value={toolTrashWords}
|
||||
onChange={(e) => setToolTrashWords(e.target.value)}
|
||||
className="resize-none h-24"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddTrashWord} disabled={!toolTrashWords.trim()} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" /> Thêm vào danh sách CSDL
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Các cụm từ rác sẽ được lưu lại cho toàn bộ truyện. Chế độ lọc rác tự động tìm kiếm không phân biệt Hoa/thường.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-auto pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setOpenToolDialog(false)
|
||||
setToolPreviewResults([])
|
||||
}}>Đóng</Button>
|
||||
|
||||
{toolPreviewResults.length === 0 ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => handleToolExecute(true)} disabled={toolPreviewing || toolScope === 'chapter' || (toolAction === 'replace' ? !toolFindText : novelTrashWords.length === 0)}>
|
||||
{toolPreviewing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Xem Trước
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleToolExecute(false)} disabled={toolExecuting || (toolAction === 'trash' && novelTrashWords.length === 0)}>
|
||||
{toolExecuting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Áp Dụng Thực Thay
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="destructive" onClick={() => handleToolExecute(false)} disabled={toolExecuting || (toolAction === 'trash' && novelTrashWords.length === 0)}>
|
||||
{toolExecuting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Đã Chắc Chắn, Bắt Đầu Thay Thế!
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Editor Workspace */}
|
||||
<div className="flex flex-col flex-1 pb-4 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label>
|
||||
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 flex gap-4 min-h-0 ${splitView ? 'flex-row' : 'flex-col'}`}>
|
||||
{/* Left: Raw Textarea */}
|
||||
<div className={`flex flex-col flex-1 h-full min-h-0 border rounded-xl overflow-hidden bg-background shadow-inner`}>
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold uppercase text-muted-foreground shrink-0">
|
||||
Nội Dung Nguồn
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onScroll={handleTextareaScroll}
|
||||
className="flex-1 w-full p-4 resize-none border-0 focus-visible:ring-0 rounded-none h-full custom-scrollbar text-base"
|
||||
placeholder="Nội dung chương..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview (Only shown if splitView is true and on tablet/desktop) */}
|
||||
<div className={`flex flex-col flex-1 h-full min-h-0 border rounded-xl overflow-hidden bg-background shadow-inner ${splitView ? 'hidden md:flex' : 'hidden'}`}>
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold uppercase text-muted-foreground shrink-0 flex justify-between">
|
||||
<span>Bản Hiển Thị</span>
|
||||
<span className="text-primary normal-case">Link được nhận diện tự động</span>
|
||||
</div>
|
||||
<div
|
||||
ref={previewRef}
|
||||
onScroll={handlePreviewScroll}
|
||||
className="flex-1 overflow-y-auto p-6 bg-card custom-scrollbar"
|
||||
>
|
||||
<div className="prose prose-sm md:prose-base dark:prose-invert max-w-none font-serif">
|
||||
{content ? renderWithLinks(content) : <p className="text-muted-foreground italic">Nội dung trống...</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { EditorClient } from "./editor-client"
|
||||
|
||||
export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
const resolvedParams = await params
|
||||
|
||||
return <EditorClient chapterId={resolvedParams.id} />
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, Suspense } from "react"
|
||||
import { useState, useEffect, Suspense, useRef } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -14,9 +14,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2 } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
// @ts-ignore
|
||||
import * as mammoth from "mammoth"
|
||||
|
||||
interface Chapter {
|
||||
_id: string
|
||||
@@ -70,11 +73,18 @@ function ChapterManager() {
|
||||
const [openDelete, setOpenDelete] = useState(false)
|
||||
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Form states
|
||||
const [number, setNumber] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
// Multi-upload states
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingMulti, setUploadingMulti] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [totalUpload, setTotalUpload] = useState(0)
|
||||
|
||||
const fetchChapters = async (pageToFetch = 1) => {
|
||||
if (!novelId) return
|
||||
setLoading(true)
|
||||
@@ -136,6 +146,65 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMultiFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0 || !novelId) return
|
||||
|
||||
setUploadingMulti(true)
|
||||
setTotalUpload(files.length)
|
||||
setUploadProgress(0)
|
||||
|
||||
// Sort files by name to ensure order (e.g. Chapter 1, Chapter 2)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
|
||||
try {
|
||||
let currentNumber = parseInt(number) || 1
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
let content = ""
|
||||
if (file.name.endsWith(".txt")) {
|
||||
content = await file.text()
|
||||
} else if (file.name.endsWith(".docx")) {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.extractRawText({ arrayBuffer })
|
||||
content = result.value
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!content.trim()) continue // Bỏ qua file rỗng
|
||||
|
||||
let fileTitle = file.name.replace(/\.[^/.]+$/, "")
|
||||
|
||||
// Loại bỏ "Chương X: " khỏi file title nếu cần thiết
|
||||
let cleanedTitle = fileTitle.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
|
||||
if (!cleanedTitle) cleanedTitle = fileTitle
|
||||
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ novelId, number: currentNumber, title: cleanedTitle, content }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || `Lỗi khi lưu file ${file.name}`)
|
||||
}
|
||||
|
||||
currentNumber++
|
||||
setUploadProgress(i + 1)
|
||||
}
|
||||
|
||||
toast.success(`Đã tải lên thành công ${files.length} chương!`)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setUploadingMulti(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewOptimize = () => {
|
||||
let newChapters = [...chapters]
|
||||
|
||||
@@ -189,58 +258,10 @@ function ChapterManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (chapter: Chapter) => {
|
||||
setEditingChapterId(chapter._id)
|
||||
setNumber(chapter.number.toString())
|
||||
setTitle(chapter.title)
|
||||
setContent("") // Khởi tạo rỗng trong lúc chờ fetch nội dung
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
try {
|
||||
// Lấy chi tiết chương từ list db để có nội dung qua API GET /api/mod/chuong/[id] vừa tạo
|
||||
const res = await fetch(`/api/mod/chuong/${chapter._id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data.content)
|
||||
} else {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Không tải được nội dung chương")
|
||||
} finally {
|
||||
setLoadingEditData(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!number || !title || !content || !novelId || !editingChapterId) {
|
||||
toast.error("Vui lòng điền đầy đủ")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/mod/chuong", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: editingChapterId, novelId, number: parseInt(number), title, content }),
|
||||
})
|
||||
|
||||
const resData = await res.json()
|
||||
if (!res.ok) throw new Error(resData.error || "Cập nhật thất bại")
|
||||
|
||||
toast.success("Đã cập nhật chương thành công!")
|
||||
setOpenEdit(false)
|
||||
fetchChapters()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
// handleOpenEdit has been removed because edit is now via dedicated page
|
||||
|
||||
// handleDelete remains the same
|
||||
const handleDelete = async () => {
|
||||
if (!deletingChapterId || !novelId) return
|
||||
setSubmitting(true)
|
||||
@@ -285,6 +306,7 @@ function ChapterManager() {
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
|
||||
<Button variant="secondary" className="gap-2" onClick={() => {
|
||||
setOpenOptimize(true)
|
||||
setPreviewMode(false)
|
||||
@@ -292,6 +314,19 @@ function ChapterManager() {
|
||||
<Wand2 className="h-4 w-4" /> Tối ưu hóa
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleMultiFileUpload}
|
||||
multiple
|
||||
accept=".txt,.docx"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="secondary" className="gap-2" onClick={() => fileInputRef.current?.click()} disabled={uploadingMulti}>
|
||||
{uploadingMulti ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{uploadingMulti ? `Đang tải lên ${uploadProgress}/${totalUpload}...` : "Tải lên hàng loạt"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
@@ -336,51 +371,6 @@ function ChapterManager() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
|
||||
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chỉnh Sửa Chương</DialogTitle>
|
||||
<DialogDescription>
|
||||
Thay đổi nội dung hoặc thông tin chương truyện.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingEditData ? (
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEditSubmit} 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)} required />
|
||||
</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</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full p-4 resize-none min-h-[300px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-auto pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Lưu thay đổi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -471,6 +461,7 @@ function ChapterManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -498,9 +489,11 @@ function ChapterManager() {
|
||||
<td className="px-5 py-4 text-right">{ch.views}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50" onClick={() => handleOpenEdit(ch)}>
|
||||
<Edit className="w-4 h-4 mr-1" /> Sửa
|
||||
</Button>
|
||||
<Link href={`/mod/chuong/${ch._id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50">
|
||||
<Edit className="w-4 h-4 mr-1" /> Sửa
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
|
||||
setDeletingChapterId(ch._id)
|
||||
setOpenDelete(true)
|
||||
|
||||
+234
-58
@@ -13,7 +13,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2 } from "lucide-react"
|
||||
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -24,6 +24,7 @@ interface Novel {
|
||||
authorName: string
|
||||
status: string
|
||||
totalChapters: number
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
@@ -40,9 +41,16 @@ export function NovelClient() {
|
||||
|
||||
// Form states
|
||||
const [title, setTitle] = useState("")
|
||||
const [originalTitle, setOriginalTitle] = useState("")
|
||||
const [authorName, setAuthorName] = useState("")
|
||||
const [originalAuthorName, setOriginalAuthorName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [coverUrl, setCoverUrl] = useState("")
|
||||
const [status, setStatus] = useState("Đang ra")
|
||||
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
|
||||
// Edit states
|
||||
const [openEdit, setOpenEdit] = useState(false)
|
||||
@@ -150,14 +158,17 @@ export function NovelClient() {
|
||||
const res = await fetch("/api/mod/truyen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, authorName, description, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
|
||||
body: JSON.stringify({ title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
|
||||
})
|
||||
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("")
|
||||
setOriginalTitle("")
|
||||
setAuthorName("")
|
||||
setOriginalAuthorName("")
|
||||
setDescription("")
|
||||
setCoverUrl("")
|
||||
setStatus("Đang ra")
|
||||
setSelectedGenres([])
|
||||
fetchNovels()
|
||||
@@ -203,12 +214,47 @@ export function NovelClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error("Vui lòng chọn file hình ảnh")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingCover(true)
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mod/upload-cover", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Lỗi khi tải lên ảnh bìa")
|
||||
|
||||
setCoverUrl(data.url)
|
||||
toast.success("Tải ảnh bìa thành công!")
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Có lỗi xảy ra khi xử lý ảnh bìa")
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = async (novel: Novel) => {
|
||||
setEditingNovel(novel)
|
||||
setTitle(novel.title)
|
||||
setAuthorName(novel.authorName)
|
||||
setStatus(novel.status)
|
||||
setDescription("")
|
||||
setOriginalTitle("")
|
||||
setOriginalAuthorName("")
|
||||
setCoverUrl(novel.coverUrl || "")
|
||||
setOpenEdit(true)
|
||||
setLoadingEditData(true)
|
||||
|
||||
@@ -217,6 +263,8 @@ export function NovelClient() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDescription(data.description || "")
|
||||
setOriginalTitle(data.originalTitle || "")
|
||||
setOriginalAuthorName(data.originalAuthorName || "")
|
||||
if (data.genres && Array.isArray(data.genres)) {
|
||||
setSelectedGenres(data.genres.map((g: any) => g.genreId))
|
||||
} else {
|
||||
@@ -247,8 +295,11 @@ export function NovelClient() {
|
||||
body: JSON.stringify({
|
||||
id: editingNovel.id,
|
||||
title,
|
||||
originalTitle,
|
||||
authorName,
|
||||
originalAuthorName,
|
||||
description,
|
||||
coverUrl,
|
||||
genreIds: selectedGenres,
|
||||
status: status
|
||||
}),
|
||||
@@ -298,6 +349,27 @@ export function NovelClient() {
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex bg-muted rounded-md p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-8 px-2 ${viewMode === 'list' ? 'bg-background shadow-sm' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="Dạng danh sách"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-8 px-2 ${viewMode === 'grid' ? 'bg-background shadow-sm' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Dạng lưới"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="epub-upload"
|
||||
@@ -319,7 +391,7 @@ export function NovelClient() {
|
||||
<Dialog open={openAdd} onOpenChange={(val) => {
|
||||
setOpenAdd(val);
|
||||
if (val) {
|
||||
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName("");
|
||||
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSelectedGenres([]); setNewGenreName("");
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -340,9 +412,32 @@ export function NovelClient() {
|
||||
<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>
|
||||
<label className="text-sm font-medium">Tên gốc (Tùy chọn)</label>
|
||||
<Input value={originalTitle} onChange={(e) => setOriginalTitle(e.target.value)} placeholder="Ví dụ: 凡人修仙传" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả</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">Tác giả gốc (Tùy chọn)</label>
|
||||
<Input value={originalAuthorName} onChange={(e) => setOriginalAuthorName(e.target.value)} placeholder="Ví dụ: 忘语" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Ảnh bìa (Tùy chọn)</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="URL ảnh..." className="flex-1" />
|
||||
<input type="file" id="cover-upload-add" className="hidden" accept="image/*" onChange={handleCoverUpload} />
|
||||
<Button type="button" variant="secondary" onClick={() => document.getElementById('cover-upload-add')?.click()} disabled={uploadingCover}>
|
||||
{uploadingCover ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{coverUrl && (
|
||||
<div className="mt-2 w-24 h-32 rounded border overflow-hidden">
|
||||
<img src={coverUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Thêm thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -408,9 +503,32 @@ export function NovelClient() {
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc</label>
|
||||
<label className="text-sm font-medium">Tên gốc (Tùy chọn)</label>
|
||||
<Input value={originalTitle} onChange={(e) => setOriginalTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả</label>
|
||||
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tác giả gốc (Tùy chọn)</label>
|
||||
<Input value={originalAuthorName} onChange={(e) => setOriginalAuthorName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Ảnh bìa (Tùy chọn)</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="URL ảnh..." className="flex-1" />
|
||||
<input type="file" id="cover-upload-edit" className="hidden" accept="image/*" onChange={handleCoverUpload} />
|
||||
<Button type="button" variant="secondary" onClick={() => document.getElementById('cover-upload-edit')?.click()} disabled={uploadingCover}>
|
||||
{uploadingCover ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{coverUrl && (
|
||||
<div className="mt-2 w-24 h-32 rounded border overflow-hidden">
|
||||
<img src={coverUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cập nhật thể loại</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -498,61 +616,119 @@ export function NovelClient() {
|
||||
</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">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
Cập nhật chương
|
||||
{viewMode === 'list' ? (
|
||||
<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 flex items-center gap-3">
|
||||
{novel.coverUrl ? (
|
||||
<img src={novel.coverUrl} alt={novel.title} className="w-8 h-10 object-cover rounded shadow-sm hidden sm:block" />
|
||||
) : (
|
||||
<div className="w-8 h-10 bg-muted rounded shadow-sm hidden sm:flex items-center justify-center text-muted-foreground"><BookOpen className="w-4 h-4" /></div>
|
||||
)}
|
||||
{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">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`}>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
Cập nhật chương
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50" onClick={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50" onClick={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 sm:p-6 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
|
||||
{loading ? (
|
||||
<div className="col-span-full py-12 flex justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : novels.length === 0 ? (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</div>
|
||||
) : (
|
||||
novels.map((novel) => (
|
||||
<div key={novel.id} className="group relative flex flex-col rounded-xl overflow-hidden border shadow-sm transition-all hover:-translate-y-1 hover:shadow-md bg-card">
|
||||
<div className="aspect-[2/3] w-full bg-muted relative border-b">
|
||||
{novel.coverUrl ? (
|
||||
<img src={novel.coverUrl} alt={novel.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<BookOpen className="w-8 h-8 opacity-20" />
|
||||
<span className="text-xs opacity-50 font-medium">No Cover</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="bg-emerald-100 text-emerald-800 text-[10px] font-bold px-1.5 py-0.5 rounded shadow-sm dark:bg-emerald-900 dark:text-emerald-300">
|
||||
{novel.totalChapters} Chương
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex flex-col flex-1">
|
||||
<h3 className="font-semibold text-sm line-clamp-2 leading-tight mb-1" title={novel.title}>{novel.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">{novel.authorName}</p>
|
||||
|
||||
<div className="mt-auto grid grid-cols-2 gap-1.5">
|
||||
<Button size="sm" variant="outline" className="w-full h-7 text-xs px-0" onClick={() => handleOpenEdit(novel)}>
|
||||
<Edit className="h-3 w-3 mr-1" /> Sửa
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="w-full h-7 text-xs px-0 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
|
||||
setDeletingNovelId(novel.id)
|
||||
setOpenDelete(true)
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3 mr-1" /> Xóa
|
||||
</Button>
|
||||
<Link href={`/mod/chuong?novelId=${novel.id}`} className="col-span-2">
|
||||
<Button size="sm" className="w-full h-7 text-xs">
|
||||
<List className="h-3 w-3 mr-1" /> DS Chương
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+32
-25
@@ -17,28 +17,39 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Shield: <Shield className="h-5 w-5" />,
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomePage() {
|
||||
const popularNovels = await prisma.novel.findMany({
|
||||
take: 6,
|
||||
orderBy: { views: "desc" },
|
||||
})
|
||||
let popularNovels: any[] = []
|
||||
let latestNovels: any[] = []
|
||||
let topRated: any[] = []
|
||||
let genres: any[] = []
|
||||
let featured = null
|
||||
|
||||
const latestNovels = await prisma.novel.findMany({
|
||||
take: 6,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
try {
|
||||
popularNovels = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
orderBy: { views: "desc" },
|
||||
})
|
||||
|
||||
const topRated = await prisma.novel.findMany({
|
||||
take: 4,
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
latestNovels = await prisma.novel.findMany({
|
||||
take: 20,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
})
|
||||
topRated = await prisma.novel.findMany({
|
||||
take: 4,
|
||||
orderBy: { rating: "desc" },
|
||||
})
|
||||
|
||||
// get the most popular as featured (can be empty if DB is new)
|
||||
const featured = popularNovels[0]
|
||||
genres = await prisma.genre.findMany({
|
||||
take: 8,
|
||||
})
|
||||
|
||||
featured = popularNovels.length > 0 ? popularNovels[0] : null
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data for homepage during build/runtime", error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
@@ -49,12 +60,10 @@ export default async function HomePage() {
|
||||
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 || "from-slate-700 to-slate-800"} md:h-auto md:w-72`}>
|
||||
<BookOpen className="h-16 w-16 text-background/80" />
|
||||
</div>
|
||||
<img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full object-cover md:h-auto md:w-72" />
|
||||
<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">
|
||||
<h1 title={featured.title} 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.authorName}</p>
|
||||
@@ -119,11 +128,9 @@ export default async function HomePage() {
|
||||
<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 || "from-slate-700 to-slate-800"}`}>
|
||||
<BookOpen className="h-4 w-4 text-background/80" />
|
||||
</div>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded object-cover" />
|
||||
<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>
|
||||
<h3 title={novel.title} 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.authorName} - Ch. {novel.totalChapters}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm font-semibold text-primary">
|
||||
|
||||
@@ -4,6 +4,8 @@ import { prisma } from "@/lib/prisma"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
|
||||
@@ -25,7 +27,8 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc"
|
||||
}
|
||||
},
|
||||
take: 20
|
||||
})
|
||||
|
||||
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
||||
|
||||
+14
-6
@@ -15,14 +15,22 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Shield: <Shield className="h-6 w-6" />,
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function GenresPage() {
|
||||
const genres = await prisma.genre.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { novels: true }
|
||||
let genres: any[] = []
|
||||
|
||||
try {
|
||||
genres = await prisma.genre.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { novels: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch genres during build/runtime", error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -60,6 +62,7 @@ export default async function SearchPage({
|
||||
const filteredNovels = await prisma.novel.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: 20,
|
||||
})
|
||||
|
||||
const genres = await prisma.genre.findMany()
|
||||
|
||||
@@ -2,14 +2,15 @@ import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, List } from "lucide-react"
|
||||
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 { ReaderFAB } from "@/components/reader-fab"
|
||||
import { ChapterReaderProgress } from "./chapter-reader-progress"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter as ChapterModel } from "@/lib/models/chapter"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
|
||||
const { slug, chapterId } = await params
|
||||
const chapterNumber = parseInt(chapterId, 10)
|
||||
@@ -47,19 +48,14 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
username: c.user.name || "User",
|
||||
avatarColor: c.user.image || "bg-primary",
|
||||
novelId: c.novelId,
|
||||
chapterId: c.chapterId,
|
||||
chapterId: c.chapterId || undefined,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
// Increment views quietly (fire and forget to not block render)
|
||||
Promise.all([
|
||||
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } }),
|
||||
prisma.novel.update({
|
||||
where: { id: novel.id },
|
||||
data: { views: { increment: 1 } }
|
||||
}).catch(e => console.error("Error incrementing novel views:", e))
|
||||
]).catch(e => console.error("Error updating views:", e))
|
||||
// Increment chapter views quietly (fire and forget to not block render)
|
||||
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } })
|
||||
.catch(e => console.error("Error updating chapter views:", e))
|
||||
|
||||
const hasPrev = chapterNumber > 1
|
||||
const hasNext = chapterNumber < maxChapter
|
||||
@@ -68,7 +64,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mx-auto max-w-4xl lg:max-w-screen-lg px-4 py-6 md:px-8">
|
||||
{/* 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">
|
||||
@@ -76,12 +72,9 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-lg font-bold text-foreground">
|
||||
<h1 className="text-lg font-bold text-foreground md:text-xl lg:text-2xl">
|
||||
Chương {chapter.number}: {chapter.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +106,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
</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">
|
||||
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-6 font-serif text-foreground/90 md:p-8 lg:p-12 text-justify">
|
||||
{paragraphs.map((text: string, idx: number) => (
|
||||
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
|
||||
{text}
|
||||
@@ -151,10 +144,11 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
||||
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter._id.toString()} />
|
||||
</section>
|
||||
|
||||
{/* TTS Player */}
|
||||
<TTSPlayer
|
||||
paragraphs={paragraphs}
|
||||
{/* Floating Reader Actions & TTS Player */}
|
||||
<ReaderFAB
|
||||
novelId={novel.id}
|
||||
novelSlug={slug}
|
||||
paragraphs={paragraphs}
|
||||
currentChapter={chapterNumber}
|
||||
maxChapter={maxChapter}
|
||||
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
|
||||
|
||||
@@ -26,26 +26,36 @@ export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: N
|
||||
: "#"
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button asChild>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild className="bg-red-600 hover:bg-red-700 text-white font-bold px-6 border-0 shadow-sm">
|
||||
<Link href={readLink}>
|
||||
<BookOpen className="mr-1.5 h-4 w-4" />
|
||||
{progress?.lastChapterNumber ? `Đọc tiếp Ch. ${progress.lastChapterNumber}` : "Đọc Truyện"}
|
||||
<BookOpen className="mr-2 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
|
||||
variant="outline"
|
||||
onClick={() => toggleBookmark(novelId)}
|
||||
className={`font-semibold px-4 border ${bookmarked ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20' : 'bg-[#334155] hover:bg-[#475569] text-white border-transparent'}`}
|
||||
>
|
||||
{bookmarked ? <BookmarkCheck className="mr-2 h-4 w-4 fill-primary" /> : <BookMarked className="mr-2 h-4 w-4" />}
|
||||
{bookmarked ? "Đã Đánh dấu" : "Đánh dấu"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" asChild className="font-semibold px-4 border-transparent bg-[#334155] hover:bg-[#475569] text-white">
|
||||
<Link href="/dang-nhap">
|
||||
<BookMarked className="mr-1.5 h-4 w-4" />
|
||||
Lưu Truyện
|
||||
<BookMarked className="mr-2 h-4 w-4" />
|
||||
Đánh dấu
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mocking ThumbsUp (Đề cử) button */}
|
||||
<Button variant="outline" className="font-semibold px-4 border-transparent bg-[#334155] hover:bg-[#475569] text-white" onClick={() => alert("Chức năng đề cử đang phát triển.")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2 h-4 w-4"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z"/></svg>
|
||||
Đề cử
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+87
-31
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
import { GenreBadge } from "@/components/genre-badge"
|
||||
@@ -10,6 +11,8 @@ import { prisma } from "@/lib/prisma"
|
||||
import connectToMongoDB from "@/lib/mongoose"
|
||||
import { Chapter } from "@/lib/models/chapter"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function NovelDetailPage({
|
||||
params,
|
||||
searchParams
|
||||
@@ -36,12 +39,6 @@ export default async function NovelDetailPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Increment view quietly
|
||||
prisma.novel.update({
|
||||
where: { id: novel.id },
|
||||
data: { views: { increment: 1 } }
|
||||
}).catch(e => console.error("Error incrementing view:", e))
|
||||
|
||||
// Fetch chapters from MongoDB
|
||||
await connectToMongoDB()
|
||||
const skip = (currentPage - 1) * limit
|
||||
@@ -86,6 +83,23 @@ export default async function NovelDetailPage({
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
const chapterCommentsData = await prisma.comment.findMany({
|
||||
where: { novelId: novel.id, chapterId: { not: null } },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
})
|
||||
|
||||
// Format explicitly as the CommentProp type
|
||||
const chapterComments = chapterCommentsData.map(c => ({
|
||||
id: c.id,
|
||||
userId: c.user.id,
|
||||
username: c.user.name || "User",
|
||||
avatarColor: c.user.image || "bg-primary",
|
||||
novelId: c.novelId,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
||||
}))
|
||||
|
||||
const novelGenres = novel.genres.map(ng => ng.genre) || []
|
||||
|
||||
return (
|
||||
@@ -93,47 +107,85 @@ export default async function NovelDetailPage({
|
||||
{/* 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 || "from-slate-700 to-slate-800"}`}>
|
||||
<BookOpen className="h-14 w-14 text-background/80" />
|
||||
</div>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-64 w-44 shrink-0 self-center rounded-xl object-cover shadow-lg md:self-start bg-muted" />
|
||||
|
||||
{/* 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>
|
||||
<h1 title={novel.title} 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.authorName}</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.updatedAt.toLocaleDateString('vi-VN')}</span>
|
||||
<div className="flex flex-col gap-1 text-sm text-muted-foreground mt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Tác giả:</span>
|
||||
<Link href={`/tim-kiem?q=${encodeURIComponent(novel.authorName)}`} className="text-red-500 font-medium hover:underline">
|
||||
{novel.authorName}
|
||||
</Link>
|
||||
{novel.originalAuthorName && <span>({novel.originalAuthorName})</span>}
|
||||
</div>
|
||||
{novel.originalTitle &&
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Tên gốc:</span>
|
||||
<span>{novel.originalTitle}</span>
|
||||
</div>
|
||||
}
|
||||
</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"
|
||||
<div className="flex flex-col gap-3 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Trạng thái:</span>
|
||||
<span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${
|
||||
novel.status === "Hoàn thành" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
novel.status === "Tạm dừng" || novel.status === "Tạm ngưng" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" :
|
||||
"bg-primary/10 text-primary" // Đang ra
|
||||
}`}>
|
||||
{novel.status}
|
||||
</span>
|
||||
{novel.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{novelGenres.map((g, i) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/the-loai/${g.slug}`}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors hover:opacity-80 ${
|
||||
i % 2 === 0 ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StarRating rating={novel.rating} ratingCount={novel.ratingCount} novelId={novel.id} interactive />
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{novelGenres.map((g) => (
|
||||
<GenreBadge key={g.id} slug={g.slug} name={g.name} variant="link" />
|
||||
))}
|
||||
{/* Stats Row */}
|
||||
<div className="flex items-center gap-6 mt-4 md:gap-8 overflow-hidden">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.totalChapters}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Chương</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.views}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Lượt đọc</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.bookmarkCount}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Cất giữ</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl md:text-2xl font-bold text-foreground">{novel.ratingCount}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Đề cử</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
|
||||
<div className="mt-4">
|
||||
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
|
||||
</div>
|
||||
</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>
|
||||
<div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description}</div>
|
||||
</section>
|
||||
|
||||
{/* Chapter list */}
|
||||
@@ -152,7 +204,11 @@ export default async function NovelDetailPage({
|
||||
|
||||
{/* Comments */}
|
||||
<section className="mt-8">
|
||||
<CommentSection comments={comments as any} novelId={novel.id} />
|
||||
<CommentSection
|
||||
comments={comments as any}
|
||||
chapterComments={chapterComments as any}
|
||||
novelId={novel.id}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,11 +54,11 @@ export default function BookshelfPage() {
|
||||
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 href={`/truyen/${novel.slug}`}>
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-16 w-12 shrink-0 rounded-md object-cover hover:opacity-90" />
|
||||
</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">
|
||||
<Link title={novel.title} 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.authorName}</p>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ filename: string }> }) {
|
||||
const { filename } = await params
|
||||
|
||||
if (!filename) {
|
||||
return new NextResponse("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
const sanitizedFilename = path.basename(filename)
|
||||
const filePath = path.join(process.cwd(), "public", "uploads", "covers", sanitizedFilename)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new NextResponse("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(filePath)
|
||||
|
||||
const ext = path.extname(sanitizedFilename).toLowerCase()
|
||||
let contentType = "image/jpeg"
|
||||
if (ext === ".png") contentType = "image/png"
|
||||
else if (ext === ".webp") contentType = "image/webp"
|
||||
else if (ext === ".gif") contentType = "image/gif"
|
||||
else if (ext === ".svg") contentType = "image/svg+xml"
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,16 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import type { Comment } from "@/lib/types"
|
||||
import Link from "next/link"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
interface CommentSectionProps {
|
||||
comments: Comment[]
|
||||
chapterComments?: Comment[]
|
||||
novelId: string
|
||||
chapterId?: string
|
||||
}
|
||||
|
||||
export function CommentSection({ comments: initialComments, novelId, chapterId }: CommentSectionProps) {
|
||||
export function CommentSection({ comments: initialComments, chapterComments, novelId, chapterId }: CommentSectionProps) {
|
||||
const { user } = useAuth()
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [content, setContent] = useState("")
|
||||
@@ -84,27 +86,78 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Chưa có bình luận nào. Hãy là người đầu tiên!</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
|
||||
{comment.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
|
||||
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
|
||||
{/* Comments list with Tabs for Novel Details Page */}
|
||||
{chapterComments ? (
|
||||
<Tabs defaultValue="novel" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="novel">Bình luận Truyện ({comments.length})</TabsTrigger>
|
||||
<TabsTrigger value="chapter">Bình luận Chương ({chapterComments.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="novel" className="flex flex-col gap-4 mt-0">
|
||||
{comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Chưa có bình luận nào cho truyện này. Hãy là người đầu tiên!</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
|
||||
{comment.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
|
||||
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chapter" className="flex flex-col gap-4 mt-0">
|
||||
{chapterComments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Chưa có bình luận nào trên các chương.</p>
|
||||
) : (
|
||||
chapterComments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
|
||||
{comment.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
|
||||
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Chưa có bình luận nào. Hãy là người đầu tiên!</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
|
||||
{comment.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
|
||||
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ export function Footer() {
|
||||
<div className="flex flex-col items-center gap-6 md:flex-row md:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
<span className="text-lg font-bold text-foreground">TruyenChu</span>
|
||||
<span className="text-lg font-bold text-foreground">Virtus's Reader</span>
|
||||
</div>
|
||||
<nav className="flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
<Link href="/" className="transition-colors hover:text-foreground">Trang Chủ</Link>
|
||||
<Link href="/the-loai" className="transition-colors hover:text-foreground">Thể Loại</Link>
|
||||
<Link href="/tim-kiem" className="transition-colors hover:text-foreground">Tìm Kiếm</Link>
|
||||
</nav>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
TruyenChu - Đọc truyện chữ online
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Virtus's Reader - Đọc truyện chữ online
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+34
-10
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { BookOpen, Menu, X, Search, User, LogOut, BookMarked } from "lucide-react"
|
||||
import { BookOpen, Menu, X, Search, User as UserIcon, LogOut, BookMarked, Shield } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"
|
||||
@@ -36,9 +36,9 @@ export function Header() {
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex shrink-0 items-center gap-2">
|
||||
<Link href="/" className="flex shrink-0 items-center gap-2 pr-2">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
<span className="text-lg font-bold text-foreground">TruyenChu</span>
|
||||
<span className="text-lg font-bold text-foreground">Virtus's Reader</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
@@ -95,6 +95,17 @@ export function Header() {
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{(user.role === "MOD" || user.role === "ADMIN") && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/mod" className="flex items-center gap-2 text-primary font-medium">
|
||||
<Shield className="h-4 w-4" />
|
||||
Trang Quản Trị
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/tu-sach" className="flex items-center gap-2">
|
||||
<BookMarked className="h-4 w-4" />
|
||||
@@ -155,13 +166,26 @@ export function Header() {
|
||||
</Link>
|
||||
))}
|
||||
{user && (
|
||||
<Link
|
||||
href="/tu-sach"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
Tủ Sách
|
||||
</Link>
|
||||
<>
|
||||
{(user.role === "MOD" || user.role === "ADMIN") && (
|
||||
<Link
|
||||
href="/mod"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-primary hover:bg-secondary flex items-center gap-2"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Trang Quản Trị
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/tu-sach"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary hover:text-foreground flex items-center gap-2"
|
||||
>
|
||||
<BookMarked className="h-4 w-4" />
|
||||
Tủ Sách
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
{!user && (
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface CardNovel {
|
||||
title: string
|
||||
authorName: string
|
||||
coverColor: string | null
|
||||
coverUrl?: string | null
|
||||
rating: number
|
||||
views: number
|
||||
totalChapters: number
|
||||
@@ -26,11 +27,11 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group flex gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-accent/50"
|
||||
>
|
||||
<div className={`flex h-16 w-12 shrink-0 items-center justify-center rounded bg-gradient-to-br ${novel.coverColor}`}>
|
||||
<BookOpen className="h-5 w-5 text-background/80" />
|
||||
<div className="relative h-16 w-12 shrink-0 rounded overflow-hidden">
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
<h3 title={novel.title} className="line-clamp-2 text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{novel.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
@@ -51,8 +52,9 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
|
||||
href={`/truyen/${novel.slug}`}
|
||||
className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className={`relative flex h-44 items-center justify-center bg-gradient-to-br ${novel.coverColor}`}>
|
||||
<BookOpen className="h-10 w-10 text-background/80" />
|
||||
<div className="relative h-44 w-full">
|
||||
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
{novel.status === "Đang ra" && (
|
||||
<span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
Đang ra
|
||||
@@ -60,7 +62,7 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary transition-colors text-balance">
|
||||
<h3 title={novel.title} className="line-clamp-2 h-10 text-sm leading-tight font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{novel.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { List, Settings2, Headphones, X, Settings, Menu, ArrowUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { ReadingSettingsContent } from "./reading-settings"
|
||||
import { TTSPlayer } from "./tts-player"
|
||||
import { ReaderTOC } from "./reader-toc"
|
||||
|
||||
interface ReaderFABProps {
|
||||
novelId: string
|
||||
novelSlug: string
|
||||
// TTS Props
|
||||
paragraphs: string[]
|
||||
currentChapter: number
|
||||
maxChapter: number
|
||||
chapterTitle: string
|
||||
}
|
||||
|
||||
export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxChapter, chapterTitle }: ReaderFABProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isTTSOpen, setIsTTSOpen] = useState(false)
|
||||
const [isTTSExpanded, setIsTTSExpanded] = useState(false)
|
||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 400)
|
||||
}
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => window.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
// Reading settings state lifted up for persistence
|
||||
const [fontSize, setFontSize] = useState(18)
|
||||
const [lineHeight, setLineHeight] = useState(1.8)
|
||||
const [letterSpacing, setLetterSpacing] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
"fixed right-6 z-50 flex flex-col items-center gap-3 transition-all duration-300",
|
||||
isTTSOpen ? (isTTSExpanded ? "bottom-[12rem]" : "bottom-24") : "bottom-6"
|
||||
)}>
|
||||
{/* Main FAB Toggle (Mobile mostly, but works as container) */}
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full shadow-lg md:hidden"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</Button>
|
||||
|
||||
{/* Action Items */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 transition-all duration-300 origin-bottom center",
|
||||
isOpen ? "scale-100 opacity-100" : "scale-0 opacity-0 pointer-events-none md:scale-100 md:opacity-100 md:pointer-events-auto"
|
||||
)}
|
||||
>
|
||||
{/* TTS Toggle */}
|
||||
<Button
|
||||
variant={isTTSOpen ? "default" : "secondary"}
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-md relative group"
|
||||
onClick={() => {
|
||||
setIsTTSOpen(!isTTSOpen)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<Headphones className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{isTTSOpen ? "Đóng Audio" : "Nghe Audio"}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* TOC */}
|
||||
<ReaderTOC
|
||||
novelId={novelId}
|
||||
novelSlug={novelSlug}
|
||||
currentChapterNumber={currentChapter}
|
||||
/>
|
||||
|
||||
{/* Settings */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-md relative group"
|
||||
>
|
||||
<Settings2 className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Tùy chỉnh
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 mb-2 mr-4 flex" align="end" side="left">
|
||||
<ReadingSettingsContent
|
||||
fontSize={fontSize} setFontSize={setFontSize}
|
||||
lineHeight={lineHeight} setLineHeight={setLineHeight}
|
||||
letterSpacing={letterSpacing} setLetterSpacing={setLetterSpacing}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Scroll to Top */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-full shadow-md relative group transition-all duration-300",
|
||||
showScrollTop ? "opacity-100 scale-100" : "opacity-0 scale-0 pointer-events-none"
|
||||
)}
|
||||
onClick={handleScrollToTop}
|
||||
>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Lên đầu trang
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inject styles OUTSIDE the popover so it survives */}
|
||||
<style>{`
|
||||
.chapter-content {
|
||||
font-size: ${fontSize}px !important;
|
||||
line-height: ${lineHeight} !important;
|
||||
letter-spacing: ${letterSpacing}px !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Render the TTS Player connected to this FAB state */}
|
||||
<TTSPlayer
|
||||
isOpen={isTTSOpen}
|
||||
onClose={() => setIsTTSOpen(false)}
|
||||
isExpanded={isTTSExpanded}
|
||||
onExpandedChange={setIsTTSExpanded}
|
||||
paragraphs={paragraphs}
|
||||
novelSlug={novelSlug}
|
||||
currentChapter={currentChapter}
|
||||
maxChapter={maxChapter}
|
||||
chapterTitle={chapterTitle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetDescription } from "@/components/ui/sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { List, Loader2, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface ReaderTOCProps {
|
||||
novelId: string
|
||||
novelSlug: string
|
||||
currentChapterNumber: number
|
||||
}
|
||||
|
||||
interface TOCChapter {
|
||||
id: string
|
||||
number: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTOCProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [chapters, setChapters] = useState<TOCChapter[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
|
||||
// Each page will fetch 100 chapters to make scrolling efficient
|
||||
const ITEMS_PER_PAGE = 100
|
||||
|
||||
// Calculate the initial page where the current chapter belongs
|
||||
useEffect(() => {
|
||||
const initialPage = Math.ceil(currentChapterNumber / ITEMS_PER_PAGE)
|
||||
setCurrentPage(initialPage || 1)
|
||||
}, [currentChapterNumber])
|
||||
|
||||
// Fetch chapters when page changes and TOC is open
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const fetchChapters = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/truyen/${novelId}/chapters?page=${currentPage}&limit=${ITEMS_PER_PAGE}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setChapters(data.chapters)
|
||||
setTotalPages(data.totalPages)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load chapters for TOC", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchChapters()
|
||||
}, [isOpen, currentPage, novelId])
|
||||
|
||||
// Optional: Auto-scroll to the current chapter on initial load
|
||||
useEffect(() => {
|
||||
if (!loading && isOpen && chapters.length > 0) {
|
||||
setTimeout(() => {
|
||||
const activeItem = document.getElementById(`toc-chap-${currentChapterNumber}`)
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [loading, isOpen, chapters, currentChapterNumber])
|
||||
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-md relative group"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Mục lục
|
||||
</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent side="left" className="w-[300px] sm:w-[350px] flex flex-col p-4">
|
||||
<SheetHeader className="pb-4 border-b">
|
||||
<SheetTitle>Mục lục chương</SheetTitle>
|
||||
<SheetDescription className="sr-only">Danh sách mục lục chương được liệt kê theo danh sách để điều hướng thuận tiện</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar -mx-4 px-4 py-2 space-y-1 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
chapters.map((chap) => {
|
||||
const isActive = chap.number === currentChapterNumber
|
||||
return (
|
||||
<Link
|
||||
key={chap.id}
|
||||
id={`toc-chap-${chap.number}`}
|
||||
href={`/truyen/${novelSlug}/${chap.number}`}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`block px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground/80'
|
||||
}`}
|
||||
>
|
||||
<span className={isActive ? "text-primary-foreground/90 font-bold mr-2 lg:mr-3" : "text-muted-foreground mr-2 lg:mr-3"}>
|
||||
{chap.number}.
|
||||
</span>
|
||||
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Details */}
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-4 border-t flex items-center justify-between gap-2 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={currentPage <= 1 || loading}
|
||||
onClick={() => setCurrentPage(p => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Trang {currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
onClick={() => setCurrentPage(p => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Minus, Plus, ALargeSmall } from "lucide-react"
|
||||
import { Minus, Plus, ALargeSmall, RotateCcw } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
interface ReadingSettingsProps {
|
||||
fontSize: number
|
||||
setFontSize: (v: number) => void
|
||||
lineHeight: number
|
||||
setLineHeight: (v: number) => void
|
||||
letterSpacing: number
|
||||
setLetterSpacing: (v: number) => void
|
||||
}
|
||||
|
||||
export function ReadingSettings() {
|
||||
const [fontSize, setFontSize] = useState(18)
|
||||
const [lineHeight, setLineHeight] = useState(1.8)
|
||||
|
||||
export function ReadingSettingsContent({
|
||||
fontSize, setFontSize,
|
||||
lineHeight, setLineHeight,
|
||||
letterSpacing, setLetterSpacing
|
||||
}: ReadingSettingsProps) {
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<ALargeSmall className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cài đặt</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Cỡ chữ: {fontSize}px</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -78,17 +79,86 @@ export function ReadingSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Inject styles */}
|
||||
<style>{`
|
||||
.chapter-content {
|
||||
font-size: ${fontSize}px;
|
||||
line-height: ${lineHeight};
|
||||
}
|
||||
`}</style>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Khoảng cách chữ: {letterSpacing.toFixed(1)}px</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setLetterSpacing(Math.max(-1, letterSpacing - 0.5))}
|
||||
disabled={letterSpacing <= -1}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${((letterSpacing + 1) / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setLetterSpacing(Math.min(3, letterSpacing + 0.5))}
|
||||
disabled={letterSpacing >= 3}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setFontSize(18)
|
||||
setLineHeight(1.8)
|
||||
setLetterSpacing(0)
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3 w-3" />
|
||||
Khôi phục mặc định
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReadingSettings() {
|
||||
const [fontSize, setFontSize] = useState(18)
|
||||
const [lineHeight, setLineHeight] = useState(1.8)
|
||||
const [letterSpacing, setLetterSpacing] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<ALargeSmall className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cài đặt</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<ReadingSettingsContent
|
||||
fontSize={fontSize} setFontSize={setFontSize}
|
||||
lineHeight={lineHeight} setLineHeight={setLineHeight}
|
||||
letterSpacing={letterSpacing} setLetterSpacing={setLetterSpacing}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Inject styles */}
|
||||
<style>{`
|
||||
.chapter-content {
|
||||
font-size: ${fontSize}px !important;
|
||||
line-height: ${lineHeight} !important;
|
||||
letter-spacing: ${letterSpacing}px !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+146
-31
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"
|
||||
import { Play, Pause, Square, SkipForward, SkipBack, Volume2, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TTSPlayerProps {
|
||||
@@ -14,16 +14,20 @@ interface TTSPlayerProps {
|
||||
currentChapter: number
|
||||
maxChapter: number
|
||||
chapterTitle: string
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
isExpanded?: boolean
|
||||
onExpandedChange?: (val: boolean) => void
|
||||
}
|
||||
|
||||
export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, chapterTitle }: TTSPlayerProps) {
|
||||
export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, chapterTitle, isOpen = true, onClose, isExpanded = false, onExpandedChange }: TTSPlayerProps) {
|
||||
const router = useRouter()
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [currentParagraphIndex, setCurrentParagraphIndex] = useState(0)
|
||||
const [rate, setRate] = useState(1.0)
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
||||
const [selectedVoiceURI, setSelectedVoiceURI] = useState("")
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [autoNextChapter, setAutoNextChapter] = useState(true)
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
|
||||
@@ -60,10 +64,36 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
)
|
||||
|
||||
// Filter out overly robotic generic fallbacks if we have good ones
|
||||
const goodViVoices = viVoices.filter(v => v.name.includes("Google") || v.name.includes("Microsoft") || v.name.includes("Natural"))
|
||||
let goodViVoices = viVoices.filter(v =>
|
||||
v.name.includes("Google") ||
|
||||
v.name.includes("Microsoft") ||
|
||||
v.name.includes("Natural") ||
|
||||
v.name.toLowerCase().includes("female") ||
|
||||
v.name.toLowerCase().includes("nữ")
|
||||
)
|
||||
|
||||
// Sort to prioritize female voices
|
||||
goodViVoices.sort((a, b) => {
|
||||
const aIsFemale = a.name.toLowerCase().includes("female") || a.name.toLowerCase().includes("nữ")
|
||||
const bIsFemale = b.name.toLowerCase().includes("female") || b.name.toLowerCase().includes("nữ")
|
||||
if (aIsFemale && !bIsFemale) return -1
|
||||
if (!aIsFemale && bIsFemale) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const preferredViVoices = goodViVoices.length > 0 ? goodViVoices : viVoices
|
||||
|
||||
// Sort preferred voices again just to be sure if not using goodViVoices
|
||||
if (preferredViVoices === viVoices) {
|
||||
preferredViVoices.sort((a, b) => {
|
||||
const aIsFemale = a.name.toLowerCase().includes("female") || a.name.toLowerCase().includes("nữ")
|
||||
const bIsFemale = b.name.toLowerCase().includes("female") || b.name.toLowerCase().includes("nữ")
|
||||
if (aIsFemale && !bIsFemale) return -1
|
||||
if (!aIsFemale && bIsFemale) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// If we still have NO vi voices, fallback to ALL voices so the user isn't stuck with an empty list
|
||||
const allUsable = preferredViVoices.length > 0 ? preferredViVoices : available
|
||||
|
||||
@@ -113,7 +143,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
|
||||
// Highlight current paragraph in the DOM
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
if (!isPlaying && !isPaused) return
|
||||
|
||||
const articleEl = document.querySelector(".chapter-content")
|
||||
if (!articleEl) return
|
||||
@@ -128,11 +158,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
activeEl.classList.add("tts-active-paragraph")
|
||||
activeEl.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
}
|
||||
}, [currentParagraphIndex, isPlaying])
|
||||
}, [currentParagraphIndex, isPlaying, isPaused])
|
||||
|
||||
// Clean highlights when stopped
|
||||
// Clean highlights when stopped completely (not playing and not paused)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
if (!isPlaying && !isPaused) {
|
||||
const articleEl = document.querySelector(".chapter-content")
|
||||
if (articleEl) {
|
||||
articleEl.querySelectorAll("p[data-p-index]").forEach((el) => {
|
||||
@@ -140,13 +170,16 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isPlaying])
|
||||
}, [isPlaying, isPaused])
|
||||
|
||||
|
||||
|
||||
const speakParagraph = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= paragraphs.length) {
|
||||
// Chapter finished
|
||||
setIsPlaying(false)
|
||||
setIsPaused(false)
|
||||
releaseWakeLock()
|
||||
|
||||
if (autoNextChapter && currentChapter < maxChapter) {
|
||||
@@ -179,6 +212,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
console.error("TTS Playback Error:", e.error, e)
|
||||
if (e.error !== "canceled" && e.error !== "interrupted") {
|
||||
setIsPlaying(false)
|
||||
setIsPaused(false)
|
||||
releaseWakeLock()
|
||||
|
||||
if (e.error === "synthesis-failed" || e.error === "network") {
|
||||
@@ -202,10 +236,12 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
// Pause
|
||||
speechSynthesis.cancel()
|
||||
setIsPlaying(false)
|
||||
setIsPaused(true)
|
||||
releaseWakeLock()
|
||||
} else {
|
||||
// Play / Resume
|
||||
setIsPlaying(true)
|
||||
setIsPaused(false)
|
||||
acquireWakeLock()
|
||||
speakParagraph(currentParagraphIndex)
|
||||
}
|
||||
@@ -214,9 +250,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
const handleStop = useCallback(() => {
|
||||
speechSynthesis.cancel()
|
||||
setIsPlaying(false)
|
||||
setIsPaused(false)
|
||||
setCurrentParagraphIndex(0)
|
||||
releaseWakeLock()
|
||||
}, [releaseWakeLock])
|
||||
onClose?.()
|
||||
}, [releaseWakeLock, onClose])
|
||||
|
||||
const handlePrevParagraph = useCallback(() => {
|
||||
const newIndex = Math.max(0, currentParagraphIndex - 1)
|
||||
@@ -236,6 +274,51 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
}
|
||||
}, [currentParagraphIndex, paragraphs.length, isPlaying, speakParagraph])
|
||||
|
||||
// Listen for clicks on paragraphs to jump TTS
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
document.querySelector(".chapter-content")?.classList.remove("tts-selection-mode")
|
||||
return
|
||||
}
|
||||
|
||||
const articleEl = document.querySelector(".chapter-content")
|
||||
if (!articleEl) return
|
||||
|
||||
articleEl.classList.add("tts-selection-mode")
|
||||
|
||||
const handleParagraphClick = (e: Event) => {
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const idxStr = target.getAttribute("data-p-index")
|
||||
if (idxStr !== null) {
|
||||
const index = parseInt(idxStr, 10)
|
||||
|
||||
// Stop current speech
|
||||
speechSynthesis.cancel()
|
||||
|
||||
// Set new index and play
|
||||
setCurrentParagraphIndex(index)
|
||||
setIsPlaying(true)
|
||||
setIsPaused(false)
|
||||
acquireWakeLock()
|
||||
|
||||
// Since speakParagraph is a useCallback with all valid deps, it's safe to call here:
|
||||
speakParagraph(index)
|
||||
}
|
||||
}
|
||||
|
||||
const pElements = articleEl.querySelectorAll("p[data-p-index]")
|
||||
pElements.forEach((el) => {
|
||||
el.addEventListener("click", handleParagraphClick)
|
||||
})
|
||||
|
||||
return () => {
|
||||
articleEl.classList.remove("tts-selection-mode")
|
||||
pElements.forEach((el) => {
|
||||
el.removeEventListener("click", handleParagraphClick)
|
||||
})
|
||||
}
|
||||
}, [isOpen, acquireWakeLock, speakParagraph])
|
||||
|
||||
// Auto-play TTS when coming from previous chapter auto-advance
|
||||
useEffect(() => {
|
||||
if (!isSupported) return
|
||||
@@ -259,7 +342,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
return (
|
||||
<>
|
||||
{/* Floating TTS bar */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 backdrop-blur-md shadow-lg">
|
||||
<div className={cn("fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 backdrop-blur-md shadow-[0_-4px_6px_-1px_rgb(0,0,0,0.1)] transition-transform duration-300", !isOpen && "translate-y-full")}>
|
||||
{/* Progress bar */}
|
||||
<div className="h-0.5 w-full bg-muted">
|
||||
<div
|
||||
@@ -301,6 +384,22 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Voice Selection (Always accessible) */}
|
||||
<div className="hidden md:block w-32 shrink-0">
|
||||
<Select value={selectedVoiceURI} onValueChange={setSelectedVoiceURI}>
|
||||
<SelectTrigger className="h-8 text-xs bg-muted/50 border-0">
|
||||
<SelectValue placeholder="Chọn giọng..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{voices.map((voice) => (
|
||||
<SelectItem key={voice.voiceURI} value={voice.voiceURI} className="text-[10px] py-1">
|
||||
{voice.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="hidden items-center gap-1 sm:flex">
|
||||
<Button
|
||||
@@ -327,17 +426,17 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onExpandedChange?.(!isExpanded)}>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded settings */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3">
|
||||
<div className="mt-3 flex flex-col gap-4 border-t border-border pt-4">
|
||||
{/* Speed on mobile */}
|
||||
<div className="flex items-center gap-3 sm:hidden">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">Toc do:</span>
|
||||
<span className="text-xs font-medium text-muted-foreground w-16 shrink-0">Tốc độ:</span>
|
||||
<Slider
|
||||
value={[rate]}
|
||||
min={0.5}
|
||||
@@ -349,23 +448,25 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
<span className="w-10 text-right text-xs font-medium">{rate.toFixed(2)}x</span>
|
||||
</div>
|
||||
|
||||
{/* Voice selector */}
|
||||
{voices.length > 1 && (
|
||||
{/* Voice selector (Mobile only, desktop has it on main bar) */}
|
||||
<div className="flex flex-col gap-1.5 md:hidden">
|
||||
<label className="text-xs font-medium text-muted-foreground">Giọng đọc:</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<select
|
||||
className="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={selectedVoiceURI}
|
||||
onChange={(e) => setSelectedVoiceURI(e.target.value)}
|
||||
>
|
||||
{voices.map((v) => (
|
||||
<option key={v.voiceURI} value={v.voiceURI}>
|
||||
{v.name} ({v.lang})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select value={selectedVoiceURI} onValueChange={setSelectedVoiceURI}>
|
||||
<SelectTrigger className="h-9 w-full bg-background text-xs">
|
||||
<SelectValue placeholder="Chọn giọng đọc..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{voices.map((voice) => (
|
||||
<SelectItem key={voice.voiceURI} value={voice.voiceURI} className="text-xs">
|
||||
{voice.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto next chapter toggle */}
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
@@ -388,7 +489,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tu dong chuyen chuong {currentChapter < maxChapter ? `(chuong ${currentChapter + 1})` : "(da la chuong cuoi)"}
|
||||
Tự động chuyển chương {currentChapter < maxChapter ? `(chương ${currentChapter + 1})` : "(đã là chương cuối)"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -396,8 +497,8 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer so content isn't hidden behind the player bar */}
|
||||
<div className={cn("h-16", isExpanded && "h-44")} />
|
||||
{/* Spacer so content isn't hidden behind the player bar - only active when open */}
|
||||
<div className={cn("transition-all duration-300", isOpen ? (isExpanded ? "h-44" : "h-16") : "h-0")} />
|
||||
|
||||
{/* TTS highlight styles */}
|
||||
<style>{`
|
||||
@@ -410,6 +511,20 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
margin-right: -0.75rem;
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.tts-selection-mode p[data-p-index] {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, padding 0.2s ease, margin 0.2s ease;
|
||||
border-radius: 0.375rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
margin-left: -0.75rem;
|
||||
margin-right: -0.75rem;
|
||||
}
|
||||
|
||||
.tts-selection-mode p[data-p-index]:hover:not(.tts-active-paragraph) {
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
image: fevirtus/reader:v0.0.1
|
||||
container_name: reader-web
|
||||
ports:
|
||||
- "3003:3000"
|
||||
environment:
|
||||
# KHÔNG SỬ DỤNG DẤU NGOẶC KÉP "" TRONG DOCKER COMPOSE
|
||||
- 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
|
||||
# Sửa thành domain name thực tế bạn đang truy cập
|
||||
- NEXTAUTH_URL=http://master-02:3003
|
||||
- GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com
|
||||
- GOOGLE_CLIENT_SECRET=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
|
||||
volumes:
|
||||
- ./uploads:/app/public/uploads
|
||||
restart: unless-stopped
|
||||
@@ -25,6 +25,7 @@ export function useAuth() {
|
||||
email: (sessionUser as any).email || "",
|
||||
avatarUrl: (sessionUser as any).image || "",
|
||||
avatarColor: "bg-blue-500", // Mặc định
|
||||
role: (sessionUser as any).role || "USER",
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
}
|
||||
}, [sessionUser])
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface User {
|
||||
email: string
|
||||
avatarColor: string
|
||||
avatarUrl?: string
|
||||
role?: "USER" | "MOD" | "ADMIN"
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"mongoose": "^9.2.4",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
|
||||
Generated
+174
@@ -131,6 +131,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.564.0
|
||||
version: 0.564.0(react@19.2.4)
|
||||
mammoth:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
mongoose:
|
||||
specifier: ^9.2.4
|
||||
version: 9.2.4
|
||||
@@ -1347,10 +1350,17 @@ packages:
|
||||
vue-router:
|
||||
optional: true
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
adm-zip@0.5.16:
|
||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1365,10 +1375,16 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.9.19:
|
||||
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
|
||||
hasBin: true
|
||||
|
||||
bluebird@3.4.7:
|
||||
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
||||
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
@@ -1404,6 +1420,9 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
crlf-normalize@1.0.20:
|
||||
resolution: {integrity: sha512-h/rBerTd3YHQGfv7tNT25mfhWvRq2BBLCZZ80GFarFxf6HQGbpW6iqDL3N+HBLpjLfAdcBXfWAzVlLfHkRUQBQ==}
|
||||
|
||||
@@ -1478,6 +1497,9 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
dingbat-to-unicode@1.0.1:
|
||||
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
@@ -1498,6 +1520,9 @@ packages:
|
||||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
duck@0.1.12:
|
||||
resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==}
|
||||
|
||||
electron-to-chromium@1.5.286:
|
||||
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
|
||||
|
||||
@@ -1558,6 +1583,12 @@ packages:
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
input-otp@1.4.2:
|
||||
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||
peerDependencies:
|
||||
@@ -1568,6 +1599,9 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -1581,6 +1615,9 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
kareem@3.2.0:
|
||||
resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1588,6 +1625,9 @@ packages:
|
||||
leac@0.6.0:
|
||||
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1669,6 +1709,9 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lop@0.4.2:
|
||||
resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1681,6 +1724,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mammoth@1.11.0:
|
||||
resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
hasBin: true
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -1800,9 +1848,19 @@ packages:
|
||||
openid-client@5.7.1:
|
||||
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||
|
||||
option@0.2.4:
|
||||
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parseley@0.12.1:
|
||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
@@ -1894,6 +1952,9 @@ packages:
|
||||
engines: {node: '>=16.13'}
|
||||
hasBin: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
@@ -1976,6 +2037,9 @@ packages:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
@@ -1986,6 +2050,9 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
sax@1.5.0:
|
||||
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
@@ -2001,6 +2068,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -2025,6 +2095,12 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -2081,6 +2157,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
underscore@1.13.8:
|
||||
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -2115,6 +2194,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
@@ -2140,6 +2222,10 @@ packages:
|
||||
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
xmlbuilder@10.1.1:
|
||||
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
xmlbuilder@11.0.1:
|
||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -3200,8 +3286,14 @@ snapshots:
|
||||
next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
adm-zip@0.5.16: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3220,8 +3312,12 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.9.19: {}
|
||||
|
||||
bluebird@3.4.7: {}
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
browserslist@4.28.1:
|
||||
@@ -3258,6 +3354,8 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
crlf-normalize@1.0.20(ts-toolbelt@9.6.0):
|
||||
dependencies:
|
||||
ts-type: 3.0.1(ts-toolbelt@9.6.0)
|
||||
@@ -3320,6 +3418,8 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
dingbat-to-unicode@1.0.1: {}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
@@ -3345,6 +3445,10 @@ snapshots:
|
||||
|
||||
dotenv@17.3.1: {}
|
||||
|
||||
duck@0.1.12:
|
||||
dependencies:
|
||||
underscore: 1.13.8
|
||||
|
||||
electron-to-chromium@1.5.286: {}
|
||||
|
||||
embla-carousel-react@8.6.0(react@19.2.4):
|
||||
@@ -3407,6 +3511,10 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -3414,6 +3522,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@4.15.9: {}
|
||||
@@ -3422,10 +3532,21 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
kareem@3.2.0: {}
|
||||
|
||||
leac@0.6.0: {}
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
optional: true
|
||||
|
||||
@@ -3481,6 +3602,12 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lop@0.4.2:
|
||||
dependencies:
|
||||
duck: 0.1.12
|
||||
option: 0.2.4
|
||||
underscore: 1.13.8
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
@@ -3493,6 +3620,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mammoth@1.11.0:
|
||||
dependencies:
|
||||
'@xmldom/xmldom': 0.8.11
|
||||
argparse: 1.0.10
|
||||
base64-js: 1.5.1
|
||||
bluebird: 3.4.7
|
||||
dingbat-to-unicode: 1.0.1
|
||||
jszip: 3.10.1
|
||||
lop: 0.4.2
|
||||
path-is-absolute: 1.0.1
|
||||
underscore: 1.13.8
|
||||
xmlbuilder: 10.1.1
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
mongodb-connection-string-url@7.0.1:
|
||||
@@ -3594,11 +3734,17 @@ snapshots:
|
||||
object-hash: 2.2.0
|
||||
oidc-token-hash: 5.2.0
|
||||
|
||||
option@0.2.4: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parseley@0.12.1:
|
||||
dependencies:
|
||||
leac: 0.6.0
|
||||
peberminta: 0.9.0
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
@@ -3683,6 +3829,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -3762,6 +3910,16 @@ snapshots:
|
||||
|
||||
react@19.2.4: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
@@ -3779,6 +3937,8 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
sax@1.5.0: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
@@ -3790,6 +3950,8 @@ snapshots:
|
||||
semver@7.7.4:
|
||||
optional: true
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.0.0
|
||||
@@ -3837,6 +3999,12 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -3873,6 +4041,8 @@ snapshots:
|
||||
|
||||
typescript@5.7.3: {}
|
||||
|
||||
underscore@1.13.8: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
@@ -3900,6 +4070,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
@@ -3940,6 +4112,8 @@ snapshots:
|
||||
sax: 1.5.0
|
||||
xmlbuilder: 11.0.1
|
||||
|
||||
xmlbuilder@10.1.1: {}
|
||||
|
||||
xmlbuilder@11.0.1: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
@@ -68,18 +68,22 @@ enum Role {
|
||||
model Novel {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
originalTitle String?
|
||||
slug String @unique
|
||||
authorName String // Tên tác giả nguyên bản của truyện
|
||||
originalAuthorName String?
|
||||
uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload
|
||||
uploader User? @relation("AuthorNovels", fields: [uploaderId], references: [id], onDelete: SetNull)
|
||||
description String @db.Text
|
||||
coverColor String?
|
||||
coverUrl String?
|
||||
status String @default("Đang ra") // "Đang ra", "Hoàn thành", "Tạm ngưng"
|
||||
totalChapters Int @default(0)
|
||||
views Int @default(0)
|
||||
rating Float @default(0.0)
|
||||
ratingCount Int @default(0)
|
||||
bookmarkCount Int @default(0)
|
||||
trashWords String[] @default([])
|
||||
|
||||
genres NovelGenre[]
|
||||
comments Comment[]
|
||||
@@ -130,6 +134,8 @@ model Bookmark {
|
||||
novelId String
|
||||
lastChapterId String?
|
||||
lastChapterNumber Int?
|
||||
readChapters Int[] @default([])
|
||||
hasCountedView Boolean @default(false)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="600" viewBox="0 0 400 600">
|
||||
<rect width="400" height="600" fill="#1e293b"/>
|
||||
<text x="200" y="280" font-family="sans-serif" font-size="36" font-weight="bold" fill="#64748b" text-anchor="middle">VIRTUS'S</text>
|
||||
<text x="200" y="320" font-family="sans-serif" font-size="36" font-weight="bold" fill="#64748b" text-anchor="middle">READER</text>
|
||||
<path d="M170 210 L230 210 L230 220 L170 220 Z" fill="#334155" />
|
||||
<path d="M170 380 L230 380 L230 390 L170 390 Z" fill="#334155" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user