Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-10 16:37:55 +07:00
parent 75ed8e233b
commit 8908395867
45 changed files with 2528 additions and 365 deletions
+1
View File
@@ -8,3 +8,4 @@ node_modules/
.next/ .next/
.env*.local .env*.local
.DS_Store .DS_Store
.env
+36
View File
@@ -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"]
+167
View File
@@ -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
```
+12 -5
View File
@@ -26,15 +26,22 @@ export async function GET(
return NextResponse.json({ error: "Chapter not found" }, { status: 404 }) 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)
const novel = await prisma.novel.findFirst({ let novelQuery: any = { id: chapter.novelId }
where: { if (session.user.role !== "ADMIN") {
id: chapter.novelId, novelQuery.uploaderId = session.user.id
uploaderId: session.user.id
} }
const novel = await prisma.novel.findFirst({
where: novelQuery
}) })
if (!novel) { 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 }) return NextResponse.json({ error: "Unauthorized access to this chapter" }, { status: 403 })
} }
+121
View File
@@ -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 })
}
}
+8 -2
View File
@@ -29,16 +29,19 @@ export async function POST(req: Request) {
try { try {
const data = await req.json() 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 // Tạo slug từ title
const slug = slugify(title) const slug = slugify(title)
const newNovel = await prisma.novel.create({ const newNovel = await prisma.novel.create({
data: { data: {
title, title,
originalTitle,
slug: slug, slug: slug,
authorName, authorName,
originalAuthorName,
description, description,
coverUrl,
uploaderId: session.user.id, uploaderId: session.user.id,
genres: { genres: {
create: genreIds.map((id: string) => ({ create: genreIds.map((id: string) => ({
@@ -61,15 +64,18 @@ export async function PUT(req: Request) {
try { try {
const data = await req.json() 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 // Update basic info and recreate genre relations
const updatedNovel = await prisma.novel.update({ const updatedNovel = await prisma.novel.update({
where: { id: id, uploaderId: session.user.id }, // Make sure they own it where: { id: id, uploaderId: session.user.id }, // Make sure they own it
data: { data: {
title, title,
originalTitle,
authorName, authorName,
originalAuthorName,
description, description,
coverUrl,
status, status,
// Replace all existing genres if genreIds is provided // Replace all existing genres if genreIds is provided
...(genreIds !== undefined && { ...(genreIds !== undefined && {
+48
View File
@@ -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 })
}
}
+49
View File
@@ -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 }
)
}
}
+47 -2
View File
@@ -78,6 +78,39 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Missing chapter info" }, { status: 400 }) 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({ const bookmark = await prisma.bookmark.upsert({
where: { where: {
userId_novelId: { userId_novelId: {
@@ -87,15 +120,27 @@ export async function POST(req: Request) {
}, },
update: { update: {
lastChapterId, lastChapterId,
lastChapterNumber lastChapterNumber,
readChapters: newReadChapters,
hasCountedView: newHasCountedView
}, },
create: { create: {
userId: session.user.id, userId: session.user.id,
novelId, novelId,
lastChapterId, 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 }) return NextResponse.json({ status: "updated", bookmark })
} }
+1 -1
View File
@@ -39,7 +39,7 @@ export default function LoginPage() {
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2 text-primary"> <Link href="/" className="inline-flex items-center gap-2 text-primary">
<BookOpen className="h-6 w-6" /> <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> </Link>
<h1 className="mt-6 text-2xl font-bold text-foreground">Chao mung ban</h1> <h1 className="mt-6 text-2xl font-bold text-foreground">Chao mung ban</h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
+1 -3
View File
@@ -1,6 +1,5 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { Be_Vietnam_Pro } from 'next/font/google' import { Be_Vietnam_Pro } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import { ThemeProvider } from '@/components/theme-provider' import { ThemeProvider } from '@/components/theme-provider'
import { AuthProvider } from '@/lib/auth-context' import { AuthProvider } from '@/lib/auth-context'
import { BookmarkProvider } from '@/lib/bookmark-context' import { BookmarkProvider } from '@/lib/bookmark-context'
@@ -15,7 +14,7 @@ const beVietnam = Be_Vietnam_Pro({
}) })
export const metadata: Metadata = { 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', 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', generator: 'v0.app',
icons: { icons: {
@@ -63,7 +62,6 @@ export default function RootLayout({
</BookmarkProvider> </BookmarkProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
<Analytics />
</body> </body>
</html> </html>
) )
+527
View File
@@ -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} 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>
)
}
+15
View File
@@ -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} />
}
+91 -98
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, Suspense } from "react" import { useState, useEffect, Suspense, useRef } from "react"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -14,9 +14,12 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } 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 { toast } from "sonner"
import Link from "next/link" import Link from "next/link"
// @ts-ignore
import * as mammoth from "mammoth"
interface Chapter { interface Chapter {
_id: string _id: string
@@ -70,11 +73,18 @@ function ChapterManager() {
const [openDelete, setOpenDelete] = useState(false) const [openDelete, setOpenDelete] = useState(false)
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null) const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
// Form states // Form states
const [number, setNumber] = useState("") const [number, setNumber] = useState("")
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [content, setContent] = 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) => { const fetchChapters = async (pageToFetch = 1) => {
if (!novelId) return if (!novelId) return
setLoading(true) 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 = () => { const handlePreviewOptimize = () => {
let newChapters = [...chapters] 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 { // handleOpenEdit has been removed because edit is now via dedicated page
// 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)
}
}
// handleDelete remains the same
const handleDelete = async () => { const handleDelete = async () => {
if (!deletingChapterId || !novelId) return if (!deletingChapterId || !novelId) return
setSubmitting(true) setSubmitting(true)
@@ -285,6 +306,7 @@ function ChapterManager() {
</h1> </h1>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="secondary" className="gap-2" onClick={() => { <Button variant="secondary" className="gap-2" onClick={() => {
setOpenOptimize(true) setOpenOptimize(true)
setPreviewMode(false) setPreviewMode(false)
@@ -292,6 +314,19 @@ function ChapterManager() {
<Wand2 className="h-4 w-4" /> Tối ưu hóa <Wand2 className="h-4 w-4" /> Tối ưu hóa
</Button> </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}> <Dialog open={openAdd} onOpenChange={setOpenAdd}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
@@ -336,51 +371,6 @@ function ChapterManager() {
</DialogContent> </DialogContent>
</Dialog> </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}> <Dialog open={openDelete} onOpenChange={setOpenDelete}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -471,6 +461,7 @@ function ChapterManager() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</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">{ch.views}</td>
<td className="px-5 py-4 text-right"> <td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2"> <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)}> <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 <Edit className="w-4 h-4 mr-1" /> Sửa
</Button> </Button>
</Link>
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => { <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) setDeletingChapterId(ch._id)
setOpenDelete(true) setOpenDelete(true)
+182 -6
View File
@@ -13,7 +13,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } 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 { toast } from "sonner"
import Link from "next/link" import Link from "next/link"
@@ -24,6 +24,7 @@ interface Novel {
authorName: string authorName: string
status: string status: string
totalChapters: number totalChapters: number
coverUrl?: string
} }
interface Genre { interface Genre {
@@ -40,10 +41,17 @@ export function NovelClient() {
// Form states // Form states
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [originalTitle, setOriginalTitle] = useState("")
const [authorName, setAuthorName] = useState("") const [authorName, setAuthorName] = useState("")
const [originalAuthorName, setOriginalAuthorName] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [coverUrl, setCoverUrl] = useState("")
const [status, setStatus] = useState("Đang ra") const [status, setStatus] = useState("Đang ra")
// View state
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [uploadingCover, setUploadingCover] = useState(false)
// Edit states // Edit states
const [openEdit, setOpenEdit] = useState(false) const [openEdit, setOpenEdit] = useState(false)
const [editingNovel, setEditingNovel] = useState<Novel | null>(null) const [editingNovel, setEditingNovel] = useState<Novel | null>(null)
@@ -150,14 +158,17 @@ export function NovelClient() {
const res = await fetch("/api/mod/truyen", { const res = await fetch("/api/mod/truyen", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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") if (!res.ok) throw new Error("Thêm mới thất bại")
toast.success("Đã thêm truyện thành công!") toast.success("Đã thêm truyện thành công!")
setOpenAdd(false) setOpenAdd(false)
setTitle("") setTitle("")
setOriginalTitle("")
setAuthorName("") setAuthorName("")
setOriginalAuthorName("")
setDescription("") setDescription("")
setCoverUrl("")
setStatus("Đang ra") setStatus("Đang ra")
setSelectedGenres([]) setSelectedGenres([])
fetchNovels() 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) => { const handleOpenEdit = async (novel: Novel) => {
setEditingNovel(novel) setEditingNovel(novel)
setTitle(novel.title) setTitle(novel.title)
setAuthorName(novel.authorName) setAuthorName(novel.authorName)
setStatus(novel.status) setStatus(novel.status)
setDescription("") setDescription("")
setOriginalTitle("")
setOriginalAuthorName("")
setCoverUrl(novel.coverUrl || "")
setOpenEdit(true) setOpenEdit(true)
setLoadingEditData(true) setLoadingEditData(true)
@@ -217,6 +263,8 @@ export function NovelClient() {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setDescription(data.description || "") setDescription(data.description || "")
setOriginalTitle(data.originalTitle || "")
setOriginalAuthorName(data.originalAuthorName || "")
if (data.genres && Array.isArray(data.genres)) { if (data.genres && Array.isArray(data.genres)) {
setSelectedGenres(data.genres.map((g: any) => g.genreId)) setSelectedGenres(data.genres.map((g: any) => g.genreId))
} else { } else {
@@ -247,8 +295,11 @@ export function NovelClient() {
body: JSON.stringify({ body: JSON.stringify({
id: editingNovel.id, id: editingNovel.id,
title, title,
originalTitle,
authorName, authorName,
originalAuthorName,
description, description,
coverUrl,
genreIds: selectedGenres, genreIds: selectedGenres,
status: status status: status
}), }),
@@ -298,6 +349,27 @@ export function NovelClient() {
</h1> </h1>
<div className="flex gap-3"> <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 <input
type="file" type="file"
id="epub-upload" id="epub-upload"
@@ -319,7 +391,7 @@ export function NovelClient() {
<Dialog open={openAdd} onOpenChange={(val) => { <Dialog open={openAdd} onOpenChange={(val) => {
setOpenAdd(val); setOpenAdd(val);
if (val) { if (val) {
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName(""); setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSelectedGenres([]); setNewGenreName("");
} }
}}> }}>
<DialogTrigger asChild> <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 /> <Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Phàm Nhân Tu Tiên" autoFocus />
</div> </div>
<div className="space-y-2"> <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ữ" /> <Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Ví dụ: Vong Ngữ" />
</div> </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"> <div className="space-y-2">
<label className="text-sm font-medium">Thêm thể loại</label> <label className="text-sm font-medium">Thêm thể loại</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -408,9 +503,32 @@ export function NovelClient() {
<Input value={title} onChange={(e) => setTitle(e.target.value)} required /> <Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div> </div>
<div className="space-y-2"> <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 /> <Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
</div> </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"> <div className="space-y-2">
<label className="text-sm font-medium">Cập nhật thể loại</label> <label className="text-sm font-medium">Cập nhật thể loại</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -498,6 +616,7 @@ export function NovelClient() {
</div> </div>
<div className="rounded-xl border bg-card overflow-hidden shadow-sm"> <div className="rounded-xl border bg-card overflow-hidden shadow-sm">
{viewMode === 'list' ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border"> <thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
@@ -517,7 +636,14 @@ export function NovelClient() {
) : ( ) : (
novels.map((novel) => ( novels.map((novel) => (
<tr key={novel.id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0"> <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 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 text-muted-foreground">{novel.authorName}</td>
<td className="px-5 py-4"> <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"> <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">
@@ -553,6 +679,56 @@ export function NovelClient() {
</tbody> </tbody>
</table> </table>
</div> </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>
)}
<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>
</div> </div>
) )
+23 -16
View File
@@ -17,28 +17,39 @@ const iconMap: Record<string, React.ReactNode> = {
Shield: <Shield className="h-5 w-5" />, Shield: <Shield className="h-5 w-5" />,
} }
export const dynamic = "force-dynamic"
export default async function HomePage() { export default async function HomePage() {
const popularNovels = await prisma.novel.findMany({ let popularNovels: any[] = []
take: 6, let latestNovels: any[] = []
let topRated: any[] = []
let genres: any[] = []
let featured = null
try {
popularNovels = await prisma.novel.findMany({
take: 20,
orderBy: { views: "desc" }, orderBy: { views: "desc" },
}) })
const latestNovels = await prisma.novel.findMany({ latestNovels = await prisma.novel.findMany({
take: 6, take: 20,
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
}) })
const topRated = await prisma.novel.findMany({ topRated = await prisma.novel.findMany({
take: 4, take: 4,
orderBy: { rating: "desc" }, orderBy: { rating: "desc" },
}) })
const genres = await prisma.genre.findMany({ genres = await prisma.genre.findMany({
take: 8, take: 8,
}) })
// get the most popular as featured (can be empty if DB is new) featured = popularNovels.length > 0 ? popularNovels[0] : null
const featured = popularNovels[0] } catch (error) {
console.error("Failed to fetch data for homepage during build/runtime", error)
}
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
@@ -49,12 +60,10 @@ export default async function HomePage() {
href={`/truyen/${featured.slug}`} href={`/truyen/${featured.slug}`}
className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card md:flex-row" 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`}> <img src={featured.coverUrl || "/default-cover.svg"} alt={featured.title} className="h-48 w-full object-cover md:h-auto md:w-72" />
<BookOpen className="h-16 w-16 text-background/80" />
</div>
<div className="flex flex-1 flex-col justify-center gap-3 p-6"> <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> <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} {featured.title}
</h1> </h1>
<p className="text-sm text-muted-foreground">Tác giả: {featured.authorName}</p> <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"> <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} {idx + 1}
</span> </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"}`}> <img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-12 w-9 shrink-0 rounded object-cover" />
<BookOpen className="h-4 w-4 text-background/80" />
</div>
<div className="min-w-0 flex-1"> <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> <p className="text-xs text-muted-foreground">{novel.authorName} - Ch. {novel.totalChapters}</p>
</div> </div>
<div className="flex items-center gap-1 text-sm font-semibold text-primary"> <div className="flex items-center gap-1 text-sm font-semibold text-primary">
+4 -1
View File
@@ -4,6 +4,8 @@ import { prisma } from "@/lib/prisma"
import { NovelCard } from "@/components/novel-card" import { NovelCard } from "@/components/novel-card"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
export const dynamic = "force-dynamic"
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) { export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params const { slug } = await params
@@ -25,7 +27,8 @@ export default async function GenreDetailPage({ params }: { params: Promise<{ sl
}, },
orderBy: { orderBy: {
updatedAt: "desc" updatedAt: "desc"
} },
take: 20
}) })
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed. // Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
+9 -1
View File
@@ -15,14 +15,22 @@ const iconMap: Record<string, React.ReactNode> = {
Shield: <Shield className="h-6 w-6" />, Shield: <Shield className="h-6 w-6" />,
} }
export const dynamic = "force-dynamic"
export default async function GenresPage() { export default async function GenresPage() {
const genres = await prisma.genre.findMany({ let genres: any[] = []
try {
genres = await prisma.genre.findMany({
include: { include: {
_count: { _count: {
select: { novels: true } select: { novels: true }
} }
} }
}) })
} catch (error) {
console.error("Failed to fetch genres during build/runtime", error)
}
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
+3
View File
@@ -5,6 +5,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { NovelCard } from "@/components/novel-card" import { NovelCard } from "@/components/novel-card"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
export const dynamic = "force-dynamic"
export default async function SearchPage({ export default async function SearchPage({
searchParams, searchParams,
}: { }: {
@@ -60,6 +62,7 @@ export default async function SearchPage({
const filteredNovels = await prisma.novel.findMany({ const filteredNovels = await prisma.novel.findMany({
where, where,
orderBy, orderBy,
take: 20,
}) })
const genres = await prisma.genre.findMany() const genres = await prisma.genre.findMany()
+14 -20
View File
@@ -2,14 +2,15 @@ import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { ChevronLeft, ChevronRight, List } from "lucide-react" import { ChevronLeft, ChevronRight, List } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ReadingSettings } from "@/components/reading-settings"
import { CommentSection } from "@/components/comment-section" 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 { ChapterReaderProgress } from "./chapter-reader-progress"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose" import connectToMongoDB from "@/lib/mongoose"
import { Chapter as ChapterModel } from "@/lib/models/chapter" 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 }> }) { export default async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
const { slug, chapterId } = await params const { slug, chapterId } = await params
const chapterNumber = parseInt(chapterId, 10) const chapterNumber = parseInt(chapterId, 10)
@@ -47,19 +48,14 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
username: c.user.name || "User", username: c.user.name || "User",
avatarColor: c.user.image || "bg-primary", avatarColor: c.user.image || "bg-primary",
novelId: c.novelId, novelId: c.novelId,
chapterId: c.chapterId, chapterId: c.chapterId || undefined,
content: c.content, content: c.content,
createdAt: c.createdAt.toISOString().split("T")[0] createdAt: c.createdAt.toISOString().split("T")[0]
})) }))
// Increment views quietly (fire and forget to not block render) // Increment chapter views quietly (fire and forget to not block render)
Promise.all([ ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } })
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } }), .catch(e => console.error("Error updating chapter views:", e))
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))
const hasPrev = chapterNumber > 1 const hasPrev = chapterNumber > 1
const hasNext = chapterNumber < maxChapter 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) const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
return ( 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 */} {/* Top navigation */}
<div className="mb-6 flex flex-col gap-3"> <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"> <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> </Link>
<div className="flex items-center justify-between gap-2"> <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} Chương {chapter.number}: {chapter.title}
</h1> </h1>
<div className="flex items-center gap-2">
<ReadingSettings />
</div>
</div> </div>
</div> </div>
@@ -113,7 +106,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
</div> </div>
{/* Chapter content */} {/* 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) => ( {paragraphs.map((text: string, idx: number) => (
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0"> <p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
{text} {text}
@@ -151,10 +144,11 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter._id.toString()} /> <CommentSection comments={comments} novelId={novel.id} chapterId={chapter._id.toString()} />
</section> </section>
{/* TTS Player */} {/* Floating Reader Actions & TTS Player */}
<TTSPlayer <ReaderFAB
paragraphs={paragraphs} novelId={novel.id}
novelSlug={slug} novelSlug={slug}
paragraphs={paragraphs}
currentChapter={chapterNumber} currentChapter={chapterNumber}
maxChapter={maxChapter} maxChapter={maxChapter}
chapterTitle={`Chương ${chapter.number}: ${chapter.title}`} chapterTitle={`Chương ${chapter.number}: ${chapter.title}`}
+20 -10
View File
@@ -26,26 +26,36 @@ export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: N
: "#" : "#"
return ( return (
<div className="flex flex-wrap gap-2 pt-1"> <div className="flex flex-wrap gap-3">
<Button asChild> <Button asChild className="bg-red-600 hover:bg-red-700 text-white font-bold px-6 border-0 shadow-sm">
<Link href={readLink}> <Link href={readLink}>
<BookOpen className="mr-1.5 h-4 w-4" /> <BookOpen className="mr-2 h-4 w-4" />
{progress?.lastChapterNumber ? `Đọc tiếp Ch. ${progress.lastChapterNumber}` : "Đọc Truyện"} {progress?.lastChapterNumber ? `Đọc tiếp Ch. ${progress.lastChapterNumber}` : "Đọc truyện"}
</Link> </Link>
</Button> </Button>
{user ? ( {user ? (
<Button variant={bookmarked ? "secondary" : "outline"} onClick={() => toggleBookmark(novelId)}> <Button
{bookmarked ? <BookmarkCheck className="mr-1.5 h-4 w-4" /> : <BookMarked className="mr-1.5 h-4 w-4" />} variant="outline"
{bookmarked ? "Đã Lưu" : "Lưu Truyện"} 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>
) : ( ) : (
<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"> <Link href="/dang-nhap">
<BookMarked className="mr-1.5 h-4 w-4" /> <BookMarked className="mr-2 h-4 w-4" />
Lưu Truyện Đánh dấu
</Link> </Link>
</Button> </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> </div>
) )
} }
+82 -26
View File
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import Link from "next/link"
import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react" import { BookOpen, Eye, BookMarked, User, Clock, Layers } from "lucide-react"
import { formatViews } from "@/lib/utils" import { formatViews } from "@/lib/utils"
import { GenreBadge } from "@/components/genre-badge" import { GenreBadge } from "@/components/genre-badge"
@@ -10,6 +11,8 @@ import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose" import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter" import { Chapter } from "@/lib/models/chapter"
export const dynamic = "force-dynamic"
export default async function NovelDetailPage({ export default async function NovelDetailPage({
params, params,
searchParams searchParams
@@ -36,12 +39,6 @@ export default async function NovelDetailPage({
notFound() 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 // Fetch chapters from MongoDB
await connectToMongoDB() await connectToMongoDB()
const skip = (currentPage - 1) * limit const skip = (currentPage - 1) * limit
@@ -86,6 +83,23 @@ export default async function NovelDetailPage({
createdAt: c.createdAt.toISOString().split("T")[0] 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) || [] const novelGenres = novel.genres.map(ng => ng.genre) || []
return ( return (
@@ -93,47 +107,85 @@ export default async function NovelDetailPage({
{/* Novel Header */} {/* Novel Header */}
<div className="flex flex-col gap-6 md:flex-row"> <div className="flex flex-col gap-6 md:flex-row">
{/* Cover */} {/* 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"}`}> <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" />
<BookOpen className="h-14 w-14 text-background/80" />
</div>
{/* Info */} {/* Info */}
<div className="flex flex-1 flex-col gap-3"> <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"> <div className="flex flex-col gap-1 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1"><User className="h-3.5 w-3.5" />{novel.authorName}</span> <div className="flex items-center gap-1.5">
<span className="flex items-center gap-1"><Layers className="h-3.5 w-3.5" />{novel.totalChapters} chương</span> <span>Tác giả:</span>
<span className="flex items-center gap-1"><Eye className="h-3.5 w-3.5" />{formatViews(novel.views)} lượt xem</span> <Link href={`/tim-kiem?q=${encodeURIComponent(novel.authorName)}`} className="text-red-500 font-medium hover:underline">
<span className="flex items-center gap-1"><BookMarked className="h-3.5 w-3.5" />{formatViews(novel.bookmarkCount)} bookmark</span> {novel.authorName}
<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> </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>
<div className="flex flex-col gap-3 mt-3">
<div className="flex items-center gap-2"> <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" : <span className="text-sm font-medium text-muted-foreground">Trạng thái:</span>
novel.status === "Đang ra" ? "bg-primary/10 text-primary" : <span className={`inline-block rounded-full px-4 py-1.5 text-xs font-semibold ${
"bg-muted text-muted-foreground" 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} {novel.status}
</span> </span>
</div> </div>
<StarRating rating={novel.rating} ratingCount={novel.ratingCount} novelId={novel.id} interactive /> <div className="flex flex-wrap items-center gap-2">
{novelGenres.map((g, i) => (
<div className="flex flex-wrap gap-1.5"> <Link
{novelGenres.map((g) => ( key={g.id}
<GenreBadge key={g.id} slug={g.slug} name={g.name} variant="link" /> 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>
</div>
{/* 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>
<div className="mt-4">
<NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} /> <NovelDetailActions novelId={novel.id} novelSlug={novel.slug} firstChapterNumber={formattedChapters[0]?.number} />
</div> </div>
</div> </div>
</div>
{/* Description */} {/* Description */}
<section className="mt-8"> <section className="mt-8">
<h2 className="mb-3 text-lg font-bold text-foreground">Giới Thiệu</h2> <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> </section>
{/* Chapter list */} {/* Chapter list */}
@@ -152,7 +204,11 @@ export default async function NovelDetailPage({
{/* Comments */} {/* Comments */}
<section className="mt-8"> <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> </section>
</div> </div>
) )
+3 -3
View File
@@ -54,11 +54,11 @@ export default function BookshelfPage() {
key={novel.id} key={novel.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/20" 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}`}> <Link href={`/truyen/${novel.slug}`}>
<BookOpen className="h-5 w-5 text-background/80" /> <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> </Link>
<div className="flex min-w-0 flex-1 flex-col gap-1"> <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} {novel.title}
</Link> </Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p> <p className="text-xs text-muted-foreground">{novel.authorName}</p>
+38
View File
@@ -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 })
}
}
+55 -2
View File
@@ -7,14 +7,16 @@ import { Textarea } from "@/components/ui/textarea"
import { useAuth } from "@/lib/auth-context" import { useAuth } from "@/lib/auth-context"
import type { Comment } from "@/lib/types" import type { Comment } from "@/lib/types"
import Link from "next/link" import Link from "next/link"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface CommentSectionProps { interface CommentSectionProps {
comments: Comment[] comments: Comment[]
chapterComments?: Comment[]
novelId: string novelId: string
chapterId?: string chapterId?: string
} }
export function CommentSection({ comments: initialComments, novelId, chapterId }: CommentSectionProps) { export function CommentSection({ comments: initialComments, chapterComments, novelId, chapterId }: CommentSectionProps) {
const { user } = useAuth() const { user } = useAuth()
const [comments, setComments] = useState(initialComments) const [comments, setComments] = useState(initialComments)
const [content, setContent] = useState("") const [content, setContent] = useState("")
@@ -84,7 +86,57 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
</div> </div>
)} )}
{/* Comments list */} {/* 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 bình luận nào cho truyện này. Hãy 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 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"> <div className="flex flex-col gap-4">
{comments.length === 0 ? ( {comments.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Chưa bình luận nào. Hãy người đu tiên!</p> <p className="py-8 text-center text-sm text-muted-foreground">Chưa bình luận nào. Hãy người đu tiên!</p>
@@ -105,6 +157,7 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
)) ))
)} )}
</div> </div>
)}
</div> </div>
) )
} }
+3 -3
View File
@@ -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 flex-col items-center gap-6 md:flex-row md:justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-primary" /> <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> </div>
<nav className="flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground"> <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="/" 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="/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> <Link href="/tim-kiem" className="transition-colors hover:text-foreground">Tìm Kiếm</Link>
</nav> </nav>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground text-center">
TruyenChu - Đc truyện chữ online Virtus's Reader - Đc truyện chữ online
</p> </p>
</div> </div>
</div> </div>
+28 -4
View File
@@ -3,7 +3,7 @@
import Link from "next/link" import Link from "next/link"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useState } from "react" 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet" 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"> <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"> <div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
{/* Logo */} {/* 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" /> <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> </Link>
{/* Desktop Nav */} {/* Desktop Nav */}
@@ -95,6 +95,17 @@ export function Header() {
<p className="text-xs text-muted-foreground">{user.email}</p> <p className="text-xs text-muted-foreground">{user.email}</p>
</div> </div>
<DropdownMenuSeparator /> <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> <DropdownMenuItem asChild>
<Link href="/tu-sach" className="flex items-center gap-2"> <Link href="/tu-sach" className="flex items-center gap-2">
<BookMarked className="h-4 w-4" /> <BookMarked className="h-4 w-4" />
@@ -155,13 +166,26 @@ export function Header() {
</Link> </Link>
))} ))}
{user && ( {user && (
<>
{(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 <Link
href="/tu-sach" href="/tu-sach"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary hover:text-foreground" 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 Tủ Sách
</Link> </Link>
</>
)} )}
</nav> </nav>
{!user && ( {!user && (
+8 -6
View File
@@ -8,6 +8,7 @@ export interface CardNovel {
title: string title: string
authorName: string authorName: string
coverColor: string | null coverColor: string | null
coverUrl?: string | null
rating: number rating: number
views: number views: number
totalChapters: number totalChapters: number
@@ -26,11 +27,11 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
href={`/truyen/${novel.slug}`} 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" 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}`}> <div className="relative h-16 w-12 shrink-0 rounded overflow-hidden">
<BookOpen className="h-5 w-5 text-background/80" /> <img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
</div> </div>
<div className="flex min-w-0 flex-col justify-center"> <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} {novel.title}
</h3> </h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p> <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}`} 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" 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}`}> <div className="relative h-44 w-full">
<BookOpen className="h-10 w-10 text-background/80" /> <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" && ( {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"> <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 Đang ra
@@ -60,7 +62,7 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
)} )}
</div> </div>
<div className="flex flex-1 flex-col gap-1 p-3"> <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} {novel.title}
</h3> </h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p> <p className="text-xs text-muted-foreground">{novel.authorName}</p>
+155
View File
@@ -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}
/>
</>
)
}
+153
View File
@@ -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 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>
)
}
+86 -16
View File
@@ -1,24 +1,25 @@
"use client" "use client"
import { useState } from "react" 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 { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" 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() { export function ReadingSettingsContent({
const [fontSize, setFontSize] = useState(18) fontSize, setFontSize,
const [lineHeight, setLineHeight] = useState(1.8) lineHeight, setLineHeight,
letterSpacing, setLetterSpacing
}: ReadingSettingsProps) {
return ( 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> <div>
<label className="mb-2 block text-xs font-medium text-muted-foreground">Cỡ chữ: {fontSize}px</label> <label className="mb-2 block text-xs font-medium text-muted-foreground">Cỡ chữ: {fontSize}px</label>
@@ -78,15 +79,84 @@ export function ReadingSettings() {
</Button> </Button>
</div> </div>
</div> </div>
<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> </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> </PopoverContent>
</Popover> </Popover>
{/* Inject styles */} {/* Inject styles */}
<style>{` <style>{`
.chapter-content { .chapter-content {
font-size: ${fontSize}px; font-size: ${fontSize}px !important;
line-height: ${lineHeight}; line-height: ${lineHeight} !important;
letter-spacing: ${letterSpacing}px !important;
} }
`}</style> `}</style>
</> </>
+145 -30
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"
import { Play, Pause, Square, SkipForward, SkipBack, Volume2, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react" import { Play, Pause, Square, SkipForward, SkipBack, Volume2, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider" 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" import { cn } from "@/lib/utils"
interface TTSPlayerProps { interface TTSPlayerProps {
@@ -14,16 +14,20 @@ interface TTSPlayerProps {
currentChapter: number currentChapter: number
maxChapter: number maxChapter: number
chapterTitle: string 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 router = useRouter()
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [currentParagraphIndex, setCurrentParagraphIndex] = useState(0) const [currentParagraphIndex, setCurrentParagraphIndex] = useState(0)
const [rate, setRate] = useState(1.0) const [rate, setRate] = useState(1.0)
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]) const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [selectedVoiceURI, setSelectedVoiceURI] = useState("") const [selectedVoiceURI, setSelectedVoiceURI] = useState("")
const [isExpanded, setIsExpanded] = useState(false)
const [autoNextChapter, setAutoNextChapter] = useState(true) const [autoNextChapter, setAutoNextChapter] = useState(true)
const [isSupported, setIsSupported] = useState(false) 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 // 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 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 // 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 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 // Highlight current paragraph in the DOM
useEffect(() => { useEffect(() => {
if (!isPlaying) return if (!isPlaying && !isPaused) return
const articleEl = document.querySelector(".chapter-content") const articleEl = document.querySelector(".chapter-content")
if (!articleEl) return if (!articleEl) return
@@ -128,11 +158,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
activeEl.classList.add("tts-active-paragraph") activeEl.classList.add("tts-active-paragraph")
activeEl.scrollIntoView({ behavior: "smooth", block: "center" }) 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(() => { useEffect(() => {
if (!isPlaying) { if (!isPlaying && !isPaused) {
const articleEl = document.querySelector(".chapter-content") const articleEl = document.querySelector(".chapter-content")
if (articleEl) { if (articleEl) {
articleEl.querySelectorAll("p[data-p-index]").forEach((el) => { 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( const speakParagraph = useCallback(
(index: number) => { (index: number) => {
if (index >= paragraphs.length) { if (index >= paragraphs.length) {
// Chapter finished // Chapter finished
setIsPlaying(false) setIsPlaying(false)
setIsPaused(false)
releaseWakeLock() releaseWakeLock()
if (autoNextChapter && currentChapter < maxChapter) { if (autoNextChapter && currentChapter < maxChapter) {
@@ -179,6 +212,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
console.error("TTS Playback Error:", e.error, e) console.error("TTS Playback Error:", e.error, e)
if (e.error !== "canceled" && e.error !== "interrupted") { if (e.error !== "canceled" && e.error !== "interrupted") {
setIsPlaying(false) setIsPlaying(false)
setIsPaused(false)
releaseWakeLock() releaseWakeLock()
if (e.error === "synthesis-failed" || e.error === "network") { if (e.error === "synthesis-failed" || e.error === "network") {
@@ -202,10 +236,12 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
// Pause // Pause
speechSynthesis.cancel() speechSynthesis.cancel()
setIsPlaying(false) setIsPlaying(false)
setIsPaused(true)
releaseWakeLock() releaseWakeLock()
} else { } else {
// Play / Resume // Play / Resume
setIsPlaying(true) setIsPlaying(true)
setIsPaused(false)
acquireWakeLock() acquireWakeLock()
speakParagraph(currentParagraphIndex) speakParagraph(currentParagraphIndex)
} }
@@ -214,9 +250,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
speechSynthesis.cancel() speechSynthesis.cancel()
setIsPlaying(false) setIsPlaying(false)
setIsPaused(false)
setCurrentParagraphIndex(0) setCurrentParagraphIndex(0)
releaseWakeLock() releaseWakeLock()
}, [releaseWakeLock]) onClose?.()
}, [releaseWakeLock, onClose])
const handlePrevParagraph = useCallback(() => { const handlePrevParagraph = useCallback(() => {
const newIndex = Math.max(0, currentParagraphIndex - 1) const newIndex = Math.max(0, currentParagraphIndex - 1)
@@ -236,6 +274,51 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
} }
}, [currentParagraphIndex, paragraphs.length, isPlaying, speakParagraph]) }, [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 // Auto-play TTS when coming from previous chapter auto-advance
useEffect(() => { useEffect(() => {
if (!isSupported) return if (!isSupported) return
@@ -259,7 +342,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
return ( return (
<> <>
{/* Floating TTS bar */} {/* 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 */} {/* Progress bar */}
<div className="h-0.5 w-full bg-muted"> <div className="h-0.5 w-full bg-muted">
<div <div
@@ -301,6 +384,22 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</span> </span>
</div> </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 */} {/* Speed control */}
<div className="hidden items-center gap-1 sm:flex"> <div className="hidden items-center gap-1 sm:flex">
<Button <Button
@@ -327,17 +426,17 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div> </div>
{/* Expand/Collapse */} {/* 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" />} {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button> </Button>
</div> </div>
{/* Expanded settings */} {/* Expanded settings */}
{isExpanded && ( {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 */} {/* Speed on mobile */}
<div className="flex items-center gap-3 sm:hidden"> <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 <Slider
value={[rate]} value={[rate]}
min={0.5} 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> <span className="w-10 text-right text-xs font-medium">{rate.toFixed(2)}x</span>
</div> </div>
{/* Voice selector */} {/* Voice selector (Mobile only, desktop has it on main bar) */}
{voices.length > 1 && ( <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"> <div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" /> <Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<select <Select value={selectedVoiceURI} onValueChange={setSelectedVoiceURI}>
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" <SelectTrigger className="h-9 w-full bg-background text-xs">
value={selectedVoiceURI} <SelectValue placeholder="Chọn giọng đọc..." />
onChange={(e) => setSelectedVoiceURI(e.target.value)} </SelectTrigger>
> <SelectContent>
{voices.map((v) => ( {voices.map((voice) => (
<option key={v.voiceURI} value={v.voiceURI}> <SelectItem key={voice.voiceURI} value={voice.voiceURI} className="text-xs">
{v.name} ({v.lang}) {voice.name}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
</div>
</div> </div>
)}
{/* Auto next chapter toggle */} {/* Auto next chapter toggle */}
<label className="flex cursor-pointer items-center gap-3"> <label className="flex cursor-pointer items-center gap-3">
@@ -388,7 +489,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
/> />
</div> </div>
<span className="text-xs text-muted-foreground"> <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 cui)"}
</span> </span>
</label> </label>
</div> </div>
@@ -396,8 +497,8 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div> </div>
</div> </div>
{/* Spacer so content isn't hidden behind the player bar */} {/* Spacer so content isn't hidden behind the player bar - only active when open */}
<div className={cn("h-16", isExpanded && "h-44")} /> <div className={cn("transition-all duration-300", isOpen ? (isExpanded ? "h-44" : "h-16") : "h-0")} />
{/* TTS highlight styles */} {/* TTS highlight styles */}
<style>{` <style>{`
@@ -410,6 +511,20 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
margin-right: -0.75rem; margin-right: -0.75rem;
transition: background 0.3s ease, color 0.3s ease; 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> `}</style>
</> </>
) )
+20
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ export function useAuth() {
email: (sessionUser as any).email || "", email: (sessionUser as any).email || "",
avatarUrl: (sessionUser as any).image || "", avatarUrl: (sessionUser as any).image || "",
avatarColor: "bg-blue-500", // Mặc định avatarColor: "bg-blue-500", // Mặc định
role: (sessionUser as any).role || "USER",
createdAt: new Date().toISOString().split("T")[0], createdAt: new Date().toISOString().split("T")[0],
} }
}, [sessionUser]) }, [sessionUser])
+1
View File
@@ -40,6 +40,7 @@ export interface User {
email: string email: string
avatarColor: string avatarColor: string
avatarUrl?: string avatarUrl?: string
role?: "USER" | "MOD" | "ADMIN"
createdAt: string createdAt: string
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
+1
View File
@@ -50,6 +50,7 @@
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"mammoth": "^1.11.0",
"mongoose": "^9.2.4", "mongoose": "^9.2.4",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
+174
View File
@@ -131,6 +131,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.564.0 specifier: ^0.564.0
version: 0.564.0(react@19.2.4) version: 0.564.0(react@19.2.4)
mammoth:
specifier: ^1.11.0
version: 1.11.0
mongoose: mongoose:
specifier: ^9.2.4 specifier: ^9.2.4
version: 9.2.4 version: 9.2.4
@@ -1347,10 +1350,17 @@ packages:
vue-router: vue-router:
optional: true optional: true
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
adm-zip@0.5.16: adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'} engines: {node: '>=12.0'}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
aria-hidden@1.2.6: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1365,10 +1375,16 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.9.19: baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true hasBin: true
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
bluebird@3.7.2: bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
@@ -1404,6 +1420,9 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
crlf-normalize@1.0.20: crlf-normalize@1.0.20:
resolution: {integrity: sha512-h/rBerTd3YHQGfv7tNT25mfhWvRq2BBLCZZ80GFarFxf6HQGbpW6iqDL3N+HBLpjLfAdcBXfWAzVlLfHkRUQBQ==} resolution: {integrity: sha512-h/rBerTd3YHQGfv7tNT25mfhWvRq2BBLCZZ80GFarFxf6HQGbpW6iqDL3N+HBLpjLfAdcBXfWAzVlLfHkRUQBQ==}
@@ -1478,6 +1497,9 @@ packages:
detect-node-es@1.1.0: detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -1498,6 +1520,9 @@ packages:
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'} engines: {node: '>=12'}
duck@0.1.12:
resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==}
electron-to-chromium@1.5.286: electron-to-chromium@1.5.286:
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
@@ -1558,6 +1583,12 @@ packages:
htmlparser2@8.0.2: htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} 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: input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies: peerDependencies:
@@ -1568,6 +1599,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@@ -1581,6 +1615,9 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
kareem@3.2.0: kareem@3.2.0:
resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==} resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -1588,6 +1625,9 @@ packages:
leac@0.6.0: leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} 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: lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -1669,6 +1709,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
lop@0.4.2:
resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
lru-cache@6.0.0: lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1681,6 +1724,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -1800,9 +1848,19 @@ packages:
openid-client@5.7.1: openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} 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: parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} 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: peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -1894,6 +1952,9 @@ packages:
engines: {node: '>=16.13'} engines: {node: '>=16.13'}
hasBin: true hasBin: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1976,6 +2037,9 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
recharts-scale@0.4.5: recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} 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: ^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 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: sax@1.5.0:
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
engines: {node: '>=11.0.0'} engines: {node: '>=11.0.0'}
@@ -2001,6 +2068,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
sharp@0.34.5: sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -2025,6 +2095,12 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} 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: styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2081,6 +2157,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -2115,6 +2194,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
@@ -2140,6 +2222,10 @@ packages:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
xmlbuilder@10.1.1:
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
engines: {node: '>=4.0'}
xmlbuilder@11.0.1: xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'} 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) next: 16.1.6(react-dom@19.2.4(react@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: {} adm-zip@0.5.16: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -3220,8 +3312,12 @@ snapshots:
postcss: 8.5.6 postcss: 8.5.6
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.19: {} baseline-browser-mapping@2.9.19: {}
bluebird@3.4.7: {}
bluebird@3.7.2: {} bluebird@3.7.2: {}
browserslist@4.28.1: browserslist@4.28.1:
@@ -3258,6 +3354,8 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
core-util-is@1.0.3: {}
crlf-normalize@1.0.20(ts-toolbelt@9.6.0): crlf-normalize@1.0.20(ts-toolbelt@9.6.0):
dependencies: dependencies:
ts-type: 3.0.1(ts-toolbelt@9.6.0) ts-type: 3.0.1(ts-toolbelt@9.6.0)
@@ -3320,6 +3418,8 @@ snapshots:
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
dingbat-to-unicode@1.0.1: {}
dom-helpers@5.2.1: dom-helpers@5.2.1:
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
@@ -3345,6 +3445,10 @@ snapshots:
dotenv@17.3.1: {} dotenv@17.3.1: {}
duck@0.1.12:
dependencies:
underscore: 1.13.8
electron-to-chromium@1.5.286: {} electron-to-chromium@1.5.286: {}
embla-carousel-react@8.6.0(react@19.2.4): embla-carousel-react@8.6.0(react@19.2.4):
@@ -3407,6 +3511,10 @@ snapshots:
domutils: 3.2.2 domutils: 3.2.2
entities: 4.5.0 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): input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
@@ -3414,6 +3522,8 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
isarray@1.0.0: {}
jiti@2.6.1: {} jiti@2.6.1: {}
jose@4.15.9: {} jose@4.15.9: {}
@@ -3422,10 +3532,21 @@ snapshots:
js-tokens@4.0.0: {} 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: {} kareem@3.2.0: {}
leac@0.6.0: {} leac@0.6.0: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.31.1: lightningcss-android-arm64@1.31.1:
optional: true optional: true
@@ -3481,6 +3602,12 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 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: lru-cache@6.0.0:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
@@ -3493,6 +3620,19 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@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: {} memory-pager@1.5.0: {}
mongodb-connection-string-url@7.0.1: mongodb-connection-string-url@7.0.1:
@@ -3594,11 +3734,17 @@ snapshots:
object-hash: 2.2.0 object-hash: 2.2.0
oidc-token-hash: 5.2.0 oidc-token-hash: 5.2.0
option@0.2.4: {}
pako@1.0.11: {}
parseley@0.12.1: parseley@0.12.1:
dependencies: dependencies:
leac: 0.6.0 leac: 0.6.0
peberminta: 0.9.0 peberminta: 0.9.0
path-is-absolute@1.0.1: {}
peberminta@0.9.0: {} peberminta@0.9.0: {}
pg-cloudflare@1.3.0: pg-cloudflare@1.3.0:
@@ -3683,6 +3829,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
process-nextick-args@2.0.1: {}
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -3762,6 +3910,16 @@ snapshots:
react@19.2.4: {} 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: recharts-scale@0.4.5:
dependencies: dependencies:
decimal.js-light: 2.5.1 decimal.js-light: 2.5.1
@@ -3779,6 +3937,8 @@ snapshots:
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
victory-vendor: 36.9.2 victory-vendor: 36.9.2
safe-buffer@5.1.2: {}
sax@1.5.0: {} sax@1.5.0: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}
@@ -3790,6 +3950,8 @@ snapshots:
semver@7.7.4: semver@7.7.4:
optional: true optional: true
setimmediate@1.0.5: {}
sharp@0.34.5: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.0.0 '@img/colour': 1.0.0
@@ -3837,6 +3999,12 @@ snapshots:
split2@4.2.0: {} 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): styled-jsx@5.1.6(react@19.2.4):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1
@@ -3873,6 +4041,8 @@ snapshots:
typescript@5.7.3: {} typescript@5.7.3: {}
underscore@1.13.8: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1): update-browserslist-db@1.2.3(browserslist@4.28.1):
@@ -3900,6 +4070,8 @@ snapshots:
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
util-deprecate@1.0.2: {}
uuid@8.3.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): 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 sax: 1.5.0
xmlbuilder: 11.0.1 xmlbuilder: 11.0.1
xmlbuilder@10.1.1: {}
xmlbuilder@11.0.1: {} xmlbuilder@11.0.1: {}
xtend@4.0.2: {} xtend@4.0.2: {}
+6
View File
@@ -68,18 +68,22 @@ enum Role {
model Novel { model Novel {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
originalTitle String?
slug String @unique slug String @unique
authorName String // Tên tác giả nguyên bản của truyện 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 uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload
uploader User? @relation("AuthorNovels", fields: [uploaderId], references: [id], onDelete: SetNull) uploader User? @relation("AuthorNovels", fields: [uploaderId], references: [id], onDelete: SetNull)
description String @db.Text description String @db.Text
coverColor String? coverColor String?
coverUrl String?
status String @default("Đang ra") // "Đang ra", "Hoàn thành", "Tạm ngưng" status String @default("Đang ra") // "Đang ra", "Hoàn thành", "Tạm ngưng"
totalChapters Int @default(0) totalChapters Int @default(0)
views Int @default(0) views Int @default(0)
rating Float @default(0.0) rating Float @default(0.0)
ratingCount Int @default(0) ratingCount Int @default(0)
bookmarkCount Int @default(0) bookmarkCount Int @default(0)
trashWords String[] @default([])
genres NovelGenre[] genres NovelGenre[]
comments Comment[] comments Comment[]
@@ -130,6 +134,8 @@ model Bookmark {
novelId String novelId String
lastChapterId String? lastChapterId String?
lastChapterNumber Int? lastChapterNumber Int?
readChapters Int[] @default([])
hasCountedView Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade) novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
+7
View File
@@ -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

+1 -1
View File
File diff suppressed because one or more lines are too long