Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-23 11:12:56 +07:00
parent e345d9ccce
commit ffd177718f
39 changed files with 5258 additions and 520 deletions
+2 -1
View File
@@ -8,4 +8,5 @@ node_modules/
.next/
.env*.local
.DS_Store
.env
.env
test-ebook/
+5
View File
@@ -45,6 +45,11 @@ NEXTAUTH_URL="http://localhost:3000"
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
# AI Tool cho MOD (LLM + web search)
OPENAI_API_KEY="your_openai_api_key"
# Tùy chọn, mặc định: gpt-4o-mini-search-preview
OPENAI_WEB_MODEL="gpt-4o-mini-search-preview"
# Cloudflare R2 (lưu ảnh bìa)
R2_ACCOUNT_ID="your_cloudflare_account_id"
R2_ACCESS_KEY_ID="your_r2_access_key_id"
@@ -0,0 +1,174 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
type EnrichedItem = {
id: string
title: string
originalTitle: string
authorName: string
originalAuthorName: string
description: string
coverUrl?: string
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested: string[]
firstPublishYear?: number
confidence: number
source: string
sourceUrl?: string
}
function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
}
function trimText(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength - 1).trimEnd()}...`
}
function buildBatchPrompt(novels: any[]) {
const itemsText = novels.map(n => {
const shortDesc = trimText(n.description || "", 1500)
return `[ID: ${n.id}]\nTên: ${n.title}\nTên gốc: ${n.originalTitle || ""}\nTác giả: ${n.authorName}\nTác giả gốc: ${n.originalAuthorName || ""}\nThể loại: ${(n.genres || []).map((g: any) => g.genre.name).join(", ")}\nMô tả: ${shortDesc}`
}).join("\n\n---\n\n")
return [
"Bạn là biên tập viên truyện dịch tiếng Trung.",
"Nhiệm vụ: bổ sung thông tin bị thiếu (Tên gốc, Tác giả gốc, Mô tả, Thể loại) cho danh sách tác phẩm sau bằng cách tìm kiếm trên Qidian, JJWXC, v.v...",
"Trường 'description': Nếu mô tả gốc đã chi tiết, chỉ cần sửa chính tả. Nếu trống/ngắn, hãy viết mới 1 đoạn giới thiệu dài chi tiết bám sát nội dung gốc (KHÔNG tóm tắt kiểu chung chung).",
"Trạng thái (status) phải luôn là: Đang ra, Hoàn thành, hoặc Tạm ngưng.",
"Kết quả BẮT BUỘC là 1 JSON Object chứa mảng 'results'. Mỗi item trong mảng phải có key 'id' khớp với báo cáo.",
`Schema: {"results":[{"id":"","title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`,
"Dưới đây là danh sách truyện cần xử lý:\n",
itemsText
].join("\n")
}
function extractJsonCandidate(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1)
}
return trimmed
}
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: "Không có quyền truy cập" }, { status: 401 })
}
const { novelIds } = await req.json()
if (!Array.isArray(novelIds) || novelIds.length === 0 || novelIds.length > 20) {
return NextResponse.json({ error: "novelIds không hợp lệ hoặc quá lớn (tối đa 20)" }, { status: 400 })
}
const accessWhere = session.user.role === "ADMIN"
? { id: { in: novelIds } }
: {
id: { in: novelIds },
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const novels = await prisma.novel.findMany({
where: accessWhere,
select: {
id: true,
title: true,
originalTitle: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
genres: { select: { genre: { select: { name: true } } } },
}
})
if (novels.length === 0) {
return NextResponse.json({ error: "Không tìm thấy truyện nào hợp lệ" }, { status: 404 })
}
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
return NextResponse.json({ error: "Thiếu DEEPSEEK_KEY" }, { status: 400 })
}
const prompt = buildBatchPrompt(novels)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 90000) // 90 seconds for batch
const startedAt = Date.now()
try {
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: 2500,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." },
{ role: "user", content: prompt },
],
}),
signal: controller.signal,
})
clearTimeout(timeout)
if (!res.ok) {
const errorText = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${errorText.slice(0, 200)}`)
}
const data = await res.json()
const text = data.choices?.[0]?.message?.content?.trim() || ""
const jsonText = extractJsonCandidate(text)
let parsed: any = null
try {
parsed = JSON.parse(jsonText)
} catch {
throw new Error("Phản hồi JSON bị hỏng")
}
const results = Array.isArray(parsed?.results) ? parsed.results : []
return NextResponse.json({
success: true,
latencyMs: Date.now() - startedAt,
model,
count: results.length,
results,
sourceNovels: novels,
})
} catch (error: any) {
clearTimeout(timeout)
return NextResponse.json({ error: `DeepSeek Error: ${error.message}` }, { status: 500 })
}
}
+689
View File
@@ -0,0 +1,689 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
type EnrichedItem = {
title: string
originalTitle: string
authorName: string
originalAuthorName: string
description: string
coverUrl?: string
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested: string[]
firstPublishYear?: number
confidence: number
source: string
sourceUrl?: string
}
type AttemptStatus = {
provider: "google" | "openrouter" | "deepseek"
model: string
status: "success" | "failed" | "skipped"
message?: string
latencyMs?: number
}
type ProviderResult = {
provider: "google" | "openrouter" | "deepseek"
model: string
results: EnrichedItem[]
}
type GeminiResponse = {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>
}
}>
}
type ChatResponse = {
choices?: Array<{
message?: {
content?: string
}
}>
}
type OpenRouterModelsResponse = {
data?: Array<{
id?: string
}>
}
const OPENROUTER_PAUSED = (process.env.OPENROUTER_PAUSED ?? "true").toLowerCase() !== "false"
function hasMeaningfulDescription(value: string): boolean {
const normalized = normalizeText(value)
if (!normalized || normalized.length < 40) return false
const placeholders = [
"chua co gioi thieu",
"khong co gioi thieu",
"dang cap nhat",
"dang update",
"updating",
"no description",
"khong ro",
"chua ro",
]
return !placeholders.some((item) => normalized.includes(item))
}
function buildGeneratedDescription(
title: string,
authorName: string,
genres: string[],
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
): string {
const genreText = genres.length > 0 ? genres.slice(0, 3).join(", ") : "tiểu thuyết mạng"
const statusText = status === "Đang ra" ? "đang ra" : status === "Hoàn thành" ? "đã hoàn thành" : "tạm ngưng"
return trimText(
`${title} là một tác phẩm ${genreText} của ${authorName}. Câu chuyện được hệ thống tóm lược lại từ những thông tin đã tìm thấy trên web và hiện ở trạng thái ${statusText}. Đây là đoạn mô tả thay thế được tạo tự động khi mô hình chưa trả về phần giới thiệu đủ chi tiết, giúp biên tập viên có sẵn nội dung để rà soát và chỉnh sửa tiếp.`,
1200
)
}
function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
}
function trimText(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength - 1).trimEnd()}...`
}
function inferStatus(subjects: string[], description: string): "Đang ra" | "Hoàn thành" | "Tạm ngưng" {
const haystack = normalizeText(`${subjects.join(" ")} ${description}`)
if (haystack.includes("completed") || haystack.includes("finished") || haystack.includes("full text")) {
return "Hoàn thành"
}
if (haystack.includes("hiatus") || haystack.includes("on hold") || haystack.includes("paused")) {
return "Tạm ngưng"
}
return "Đang ra"
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
const num = typeof value === "number" ? value : Number(value)
if (!Number.isFinite(num)) return fallback
return Math.max(min, Math.min(max, num))
}
function toOptionalUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
if (!/^https?:\/\//i.test(trimmed)) return undefined
return trimmed
}
function coerceStatus(value: unknown, genres: string[], description: string): "Đang ra" | "Hoàn thành" | "Tạm ngưng" {
if (typeof value === "string") {
const normalized = normalizeText(value)
if (normalized.includes("hoan thanh") || normalized.includes("completed") || normalized.includes("finished")) {
return "Hoàn thành"
}
if (normalized.includes("tam") || normalized.includes("ngung") || normalized.includes("hiatus") || normalized.includes("paused")) {
return "Tạm ngưng"
}
if (normalized.includes("dang") || normalized.includes("ongoing")) {
return "Đang ra"
}
}
return inferStatus(genres, description)
}
function extractJsonCandidate(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1)
}
return trimmed
}
function toItem(raw: any, novelTitle: string): EnrichedItem | null {
const title = typeof raw?.title === "string" ? raw.title.trim() : ""
if (!title) return null
const originalTitle = typeof raw?.originalTitle === "string" && raw.originalTitle.trim()
? raw.originalTitle.trim()
: title
const authorName = typeof raw?.authorName === "string" && raw.authorName.trim()
? raw.authorName.trim()
: "Chưa rõ"
const originalAuthorName = typeof raw?.originalAuthorName === "string" && raw.originalAuthorName.trim()
? raw.originalAuthorName.trim()
: authorName
const genres = Array.isArray(raw?.genresSuggested)
? raw.genresSuggested
.map((g: unknown) => (typeof g === "string" ? g.trim() : ""))
.filter(Boolean)
.slice(0, 10)
: []
const status = coerceStatus(raw?.status, genres, typeof raw?.description === "string" ? raw.description.trim() : "")
const descriptionInput = typeof raw?.description === "string" ? raw.description.trim() : ""
const description = trimText(
hasMeaningfulDescription(descriptionInput)
? descriptionInput
: buildGeneratedDescription(title || novelTitle, authorName, genres, status),
1200
)
const year = Number(raw?.firstPublishYear)
const firstPublishYear = Number.isFinite(year) && year >= 1000 && year <= 2100 ? year : undefined
return {
title,
originalTitle,
authorName,
originalAuthorName,
description,
coverUrl: toOptionalUrl(raw?.coverUrl),
status,
genresSuggested: genres,
firstPublishYear,
confidence: clampNumber(raw?.confidence, 1, 99, 65),
source: typeof raw?.source === "string" && raw.source.trim() ? raw.source.trim() : "LLM Web Search",
sourceUrl: toOptionalUrl(raw?.sourceUrl),
}
}
function parseResultsFromText(text: string, novelTitle: string): EnrichedItem[] {
const jsonText = extractJsonCandidate(text)
if (!jsonText) return []
let parsed: any = null
try {
parsed = JSON.parse(jsonText)
} catch {
return []
}
const rows = Array.isArray(parsed?.results) ? parsed.results : []
return rows
.map((row: any) => toItem(row, novelTitle))
.filter((row: EnrichedItem | null): row is EnrichedItem => Boolean(row))
.slice(0, 6)
}
function buildFallbackResult(novelTitle: string): EnrichedItem {
return {
title: novelTitle,
originalTitle: novelTitle,
authorName: "Chưa rõ",
originalAuthorName: "Chưa rõ",
description: buildGeneratedDescription(novelTitle, "Chưa rõ", [], "Đang ra"),
coverUrl: undefined,
status: "Đang ra",
genresSuggested: [],
firstPublishYear: undefined,
confidence: 30,
source: "Fallback",
sourceUrl: undefined,
}
}
function buildPrompt(novel: {
title: string
originalTitle?: string | null
authorName: string
originalAuthorName?: string | null
description: string
genres: string[]
}) {
const shortDescription = trimText(novel.description || "", 1500)
return [
"Bạn là trợ lý biên tập truyện dịch từ truyện mạng Trung Quốc.",
"Hãy tìm thông tin trên web, ưu tiên các nguồn như Qidian, JJWXC, NovelUpdates, wiki fan và nhà xuất bản.",
"Trả về JSON thuần, không markdown, không giải thích thêm.",
"Bắt buộc viết các trường title, authorName, description, genresSuggested bằng tiếng Việt có dấu nếu đó là tên hoặc khái niệm đã có bản Việt hóa phổ biến.",
"Riêng originalTitle và originalAuthorName hãy giữ theo nguyên tác nếu tìm được.",
"Trường description là bắt buộc. Nếu 'Mô tả hiện tại' (dưới đây) đã dài và chi tiết, hãy giữ nguyên ý chính gốc và chỉ chỉnh sửa văn phong, bố cục cho mượt mà hoặc hấp dẫn hơn. TUYỆT ĐỐI KHÔNG tóm tắt ngắn đi nếu bản gốc đang đầy đủ.",
"Nếu 'Mô tả hiện tại' chống không hoặc quá sơ sài, hãy tìm kiếm nội dung về cốt truyện gốc trên mạng và viết một bài giới thiệu dài, chi tiết, bám sát các sự kiện thực tế trong truyện. KHÔNG viết chung chung kiểu 'đầy chông gai và thử thách'.",
"Trạng thái (status) phải luôn là 1 trong 3 giá trị: Đang ra, Hoàn thành, Tạm ngưng. Mảng genresSuggested chứa tối đa 10 thể loại phù hợp.",
`Schema: {"results":[{"title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`,
"Tối đa 3 kết quả, ưu tiên kết quả gần nhất với truyện hiện tại.",
`Tên hiện tại: ${novel.title}`,
`Tên gốc hiện tại: ${novel.originalTitle || ""}`,
`Tác giả hiện tại: ${novel.authorName}`,
`Tác giả gốc hiện tại: ${novel.originalAuthorName || ""}`,
`Thể loại hiện tại: ${novel.genres.join(", ")}`,
`Mô tả hiện tại: ${shortDescription}`,
].join(" ")
}
async function fetchJsonWithTimeout<T>(url: string, init: RequestInit, timeoutMs = 25000): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" })
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${text.slice(0, 220)}`)
}
return (await res.json()) as T
} finally {
clearTimeout(timeout)
}
}
function summarizeError(error: unknown): string {
if (!(error instanceof Error)) return "Lỗi không xác định"
return error.message.slice(0, 220)
}
async function tryGoogle(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise<ProviderResult | null> {
const apiKey = process.env.GOOGLE_AI_KEY?.trim()
const model = process.env.GOOGLE_AI_MODEL?.trim() || "gemini-2.0-flash"
if (!apiKey) {
attempts.push({ provider: "google", model, status: "skipped", message: "Thiếu GOOGLE_AI_KEY" })
return null
}
const startedAt = Date.now()
try {
const data = await fetchJsonWithTimeout<GeminiResponse>(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
tools: [{ googleSearch: {} }],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 1400,
},
}),
},
22000
)
const text = (data.candidates || [])
.flatMap((candidate) => candidate.content?.parts || [])
.map((part) => part.text || "")
.join("\n")
.trim()
const results = parseResultsFromText(text, novelTitle)
if (results.length === 0) {
console.log("Google parsing failed. Raw text:", text)
throw new Error("Không có kết quả JSON hợp lệ")
}
attempts.push({
provider: "google",
model,
status: "success",
message: `${results.length} kết quả`,
latencyMs: Date.now() - startedAt,
})
return { provider: "google", model, results }
} catch (error) {
console.log("Google fetch error:", error)
attempts.push({
provider: "google",
model,
status: "failed",
message: summarizeError(error),
latencyMs: Date.now() - startedAt,
})
return null
}
}
async function resolveOpenRouterFreeModels(apiKey: string): Promise<string[]> {
const envModels = (process.env.OPENROUTER_FREE_MODELS || "")
.split(",")
.map((s) => s.trim())
.filter((s) => s.endsWith(":free"))
const dynamicModels: string[] = []
try {
const data = await fetchJsonWithTimeout<OpenRouterModelsResponse>(
"https://openrouter.ai/api/v1/models",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
12000
)
for (const row of data.data || []) {
const id = (row.id || "").trim()
if (!id || !id.endsWith(":free")) continue
dynamicModels.push(id)
}
} catch {
// Ignore model-list errors and keep env list fallback.
}
const merged = Array.from(new Set([...envModels, ...dynamicModels]))
const preferredOrder = [
"google/gemma-2-9b-it:free",
"meta-llama/llama-3.1-8b-instruct:free",
"qwen/qwen2.5-7b-instruct:free",
]
merged.sort((a, b) => {
const ai = preferredOrder.findIndex((item) => item === a)
const bi = preferredOrder.findIndex((item) => item === b)
const ar = ai === -1 ? 999 : ai
const br = bi === -1 ? 999 : bi
if (ar !== br) return ar - br
return a.localeCompare(b)
})
return merged.slice(0, 10)
}
async function tryOpenRouter(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise<ProviderResult | null> {
const apiKey = process.env.OPENROUTER_KEY?.trim()
if (!apiKey) {
attempts.push({ provider: "openrouter", model: "free-chain", status: "skipped", message: "Thiếu OPENROUTER_KEY" })
return null
}
const freeModels = await resolveOpenRouterFreeModels(apiKey)
if (freeModels.length === 0) {
attempts.push({ provider: "openrouter", model: "free-chain", status: "skipped", message: "Không có model free khả dụng" })
return null
}
for (const model of freeModels) {
const startedAt = Date.now()
try {
const data = await fetchJsonWithTimeout<ChatResponse>(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": "http://localhost:3000",
"X-Title": "reader-mod-ai-tool",
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: 1400,
messages: [
{ role: "system", content: "Return only valid JSON." },
{ role: "user", content: prompt },
],
}),
},
22000
)
const text = data.choices?.[0]?.message?.content?.trim() || ""
const results = parseResultsFromText(text, novelTitle)
if (results.length === 0) {
console.log("OpenRouter parsing failed. Raw text:", text)
throw new Error("Không có kết quả JSON hợp lệ")
}
attempts.push({
provider: "openrouter",
model,
status: "success",
message: `${results.length} kết quả`,
latencyMs: Date.now() - startedAt,
})
return { provider: "openrouter", model, results }
} catch (error) {
console.log("OpenRouter fetch error:", error)
attempts.push({
provider: "openrouter",
model,
status: "failed",
message: summarizeError(error),
latencyMs: Date.now() - startedAt,
})
}
}
return null
}
async function tryDeepSeek(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise<ProviderResult | null> {
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
attempts.push({ provider: "deepseek", model, status: "skipped", message: "Thiếu DEEKSEEK_KEY/DEEPSEEK_KEY" })
return null
}
const profiles = [
{ maxTokens: 1300, timeoutMs: 60000 },
{ maxTokens: 900, timeoutMs: 45000 },
]
for (const profile of profiles) {
const startedAt = Date.now()
try {
const data = await fetchJsonWithTimeout<ChatResponse>(
"https://api.deepseek.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: profile.maxTokens,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." },
{ role: "user", content: prompt },
],
}),
},
profile.timeoutMs
)
const text = data.choices?.[0]?.message?.content?.trim() || ""
const results = parseResultsFromText(text, novelTitle)
if (results.length === 0) {
console.log("DeepSeek parsing failed. Raw text:", text)
throw new Error("Không có kết quả JSON hợp lệ")
}
attempts.push({
provider: "deepseek",
model,
status: "success",
message: `${results.length} kết quả`,
latencyMs: Date.now() - startedAt,
})
return { provider: "deepseek", model, results }
} catch (error) {
console.log("DeepSeek fetch error:", error)
attempts.push({
provider: "deepseek",
model,
status: "failed",
message: summarizeError(error),
latencyMs: Date.now() - startedAt,
})
}
}
return null
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId")?.trim() || ""
if (!novelId) {
return NextResponse.json({ error: "Thiếu novelId" }, { status: 400 })
}
const accessWhere = session.user.role === "ADMIN"
? { id: novelId }
: {
id: novelId,
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const novel = await prisma.novel.findFirst({
where: accessWhere,
select: {
id: true,
title: true,
originalTitle: true,
slug: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
updatedAt: true,
genres: {
select: {
genre: {
select: {
name: true,
},
},
},
},
},
})
if (!novel) {
return NextResponse.json({ error: "Không tìm thấy truyện hoặc bạn không có quyền truy cập" }, { status: 404 })
}
const attempts: AttemptStatus[] = []
const currentNovel = {
id: novel.id,
title: novel.title,
originalTitle: novel.originalTitle,
slug: novel.slug,
authorName: novel.authorName,
originalAuthorName: novel.originalAuthorName,
description: novel.description,
coverUrl: novel.coverUrl,
status: novel.status,
updatedAt: novel.updatedAt,
genres: (novel.genres || []).map((row) => row.genre.name),
}
const prompt = buildPrompt(currentNovel)
// Tạm thời bỏ qua Google Gemini theo yêu cầu của user
attempts.push({
provider: "google",
model: "gemini-2.0-flash",
status: "skipped",
message: "Tạm bỏ qua theo yêu cầu người dùng.",
})
/*
const google = await tryGoogle(prompt, currentNovel.title, attempts)
if (google) {
return NextResponse.json({
novel: currentNovel,
provider: google.provider,
model: google.model,
attempts,
count: google.results.length,
results: google.results,
})
}
*/
const deepseek = await tryDeepSeek(prompt, currentNovel.title, attempts)
if (deepseek) {
return NextResponse.json({
novel: currentNovel,
provider: deepseek.provider,
model: deepseek.model,
attempts,
count: deepseek.results.length,
results: deepseek.results,
})
}
if (OPENROUTER_PAUSED) {
attempts.push({
provider: "openrouter",
model: "free-chain",
status: "skipped",
message: "Tạm dừng OpenRouter theo cấu hình",
})
} else {
const openrouter = await tryOpenRouter(prompt, currentNovel.title, attempts)
if (openrouter) {
return NextResponse.json({
novel: currentNovel,
provider: openrouter.provider,
model: openrouter.model,
attempts,
count: openrouter.results.length,
results: openrouter.results,
})
}
}
return NextResponse.json({
novel: currentNovel,
provider: "fallback",
model: "none",
attempts,
count: 1,
results: [buildFallbackResult(currentNovel.title)],
warning: "Google AI và DeepSeek đều thất bại",
})
}
@@ -0,0 +1,79 @@
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) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const q = (searchParams.get("q") || "").trim()
const page = parseInt(searchParams.get("page") || "1", 10)
const take = 24
const skip = (Math.max(1, page) - 1) * take
const whereScope = session.user.role === "ADMIN"
? {}
: {
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const isMissingFilter = searchParams.get("missing") === "true"
const baseWhereOptions: any[] = [whereScope]
if (q.length > 0) {
baseWhereOptions.push({
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ originalTitle: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
{ originalAuthorName: { contains: q, mode: "insensitive" } },
{ slug: { contains: q, mode: "insensitive" } },
],
})
} else if (!isMissingFilter) {
return NextResponse.json({ novels: [], hasMore: false })
}
if (isMissingFilter) {
baseWhereOptions.push({
OR: [
{ authorName: { in: ["", "Chưa rõ"] } },
{ description: "" },
{ description: { in: ["Chưa có giới thiệu", "Không có giới thiệu", "chưa có giới thiệu", "không có", "chưa rõ", "đang cập nhật"] } }
],
})
}
const rows = await prisma.novel.findMany({
where: {
AND: baseWhereOptions,
},
select: {
id: true,
title: true,
originalTitle: true,
slug: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
updatedAt: true,
},
orderBy: [{ updatedAt: "desc" }],
take: take + 1, // Fetch one extra to know if there's a next page
skip,
})
const hasMore = rows.length > take
const returnRows = hasMore ? rows.slice(0, take) : rows
return NextResponse.json({ novels: returnRows, hasMore })
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
export async function GET() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
return NextResponse.json({ error: "Chưa cấu hình API Key cho DeepSeek (DEEKSEEK_KEY / DEEPSEEK_KEY)" }, { status: 400 })
}
const startedAt = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15000)
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.1,
max_tokens: 10,
messages: [{ role: "user", content: "Ping! Please reply with 'pong'." }],
}),
signal: controller.signal,
})
clearTimeout(timeout)
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`)
}
const data = await res.json()
const text = data.choices?.[0]?.message?.content?.trim() || ""
return NextResponse.json({
success: true,
message: `DeepSeek phản hồi thành công: "${text}"`,
latencyMs: Date.now() - startedAt,
model
})
} catch (error: any) {
return NextResponse.json({
error: `Kết nối DeepSeek thất bại: ${error.message}`
}, { status: 500 })
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { novelId, fromNumber, toNumber } = data
if (!novelId || typeof fromNumber !== "number" || typeof toNumber !== "number") {
return NextResponse.json({ error: "Dữ liệu không hợp lệ" }, { status: 400 })
}
if (fromNumber > toNumber) {
return NextResponse.json({ error: "Chương bắt đầu không được lớn hơn chương kết thúc" }, { status: 400 })
}
// Xác minh truyện thuộc về Mod này (hoặc Admin)
const novel = await prisma.novel.findUnique({
where: { id: novelId }
})
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 })
}
if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) {
return NextResponse.json({ error: "Bạn không có quyền thao tác trên truyện này" }, { status: 403 })
}
await connectToMongoDB()
// Xóa các chương trong khoảng
const deleteResult = await Chapter.deleteMany({
novelId,
number: { $gte: fromNumber, $lte: toNumber }
})
// Cập nhật lại số lượng chương
const totalChapters = await Chapter.countDocuments({ novelId })
await prisma.novel.update({
where: { id: novelId },
data: { totalChapters },
})
return NextResponse.json({
success: true,
deletedCount: deleteResult.deletedCount,
totalChapters
})
} catch (error: any) {
console.error("Bulk Delete Chapters Error:", error)
return NextResponse.json({ error: "Lỗi hệ thống khi xóa chương: " + error.message }, { status: 500 })
}
}
+262
View File
@@ -0,0 +1,262 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { EditorRecommendation } from "@/lib/models/editor-recommendation"
const MAX_RECOMMENDATIONS_PER_EDITOR = 5
function normalizeText(value: any): string {
return typeof value === "string" ? value.trim() : ""
}
function isAllowedModerator(role: string) {
return role === "MOD" || role === "ADMIN"
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions)
if (!session || !isAllowedModerator(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const q = normalizeText(url.searchParams.get("q"))
await connectToMongoDB()
const docs = (await EditorRecommendation.find({})
.sort({ createdAt: -1 })
.limit(1000)
.lean()) as Array<{
_id: any
novelId: string
editorId: string
createdAt?: Date
}>
const novelIds = Array.from(new Set(docs.map((doc) => doc.novelId).filter(Boolean)))
const editorIds = Array.from(new Set(docs.map((doc) => doc.editorId).filter(Boolean)))
const [novels, editors] = await Promise.all([
novelIds.length > 0
? prisma.novel.findMany({
where: { id: { in: novelIds } },
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
status: true,
totalChapters: true,
},
})
: Promise.resolve([]),
editorIds.length > 0
? prisma.user.findMany({
where: { id: { in: editorIds } },
select: { id: true, name: true },
})
: Promise.resolve([]),
])
const novelMap = new Map(novels.map((novel) => [novel.id, novel]))
const editorMap = new Map(editors.map((editor) => [editor.id, editor]))
const recommendationCountMap = new Map<string, number>()
for (const doc of docs) {
recommendationCountMap.set(doc.novelId, (recommendationCountMap.get(doc.novelId) || 0) + 1)
}
const items = docs
.map((doc) => {
const novel = novelMap.get(doc.novelId)
if (!novel) return null
const editor = editorMap.get(doc.editorId)
return {
id: String(doc._id),
createdAt: doc.createdAt || null,
recommendCount: recommendationCountMap.get(doc.novelId) || 0,
novel,
editor: {
id: doc.editorId,
name: editor?.name || "Biên tập viên",
},
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
const summary = Array.from(recommendationCountMap.entries())
.map(([novelId, recommendCount]) => {
const novel = novelMap.get(novelId)
if (!novel) return null
return { novel, recommendCount }
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((a, b) => b.recommendCount - a.recommendCount)
const myNovelIdSet = new Set(
docs.filter((doc) => doc.editorId === session.user.id).map((doc) => doc.novelId)
)
const myRecommendationCount = myNovelIdSet.size
const candidates = q
? await prisma.novel.findMany({
where: {
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ slug: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
],
},
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
status: true,
totalChapters: true,
},
take: 20,
orderBy: [{ updatedAt: "desc" }],
})
: []
const candidateRows = candidates.map((novel) => ({
...novel,
alreadyRecommended: myNovelIdSet.has(novel.id),
recommendCount: recommendationCountMap.get(novel.id) || 0,
}))
return NextResponse.json({
items,
summary,
candidates: candidateRows,
myNovelIds: Array.from(myNovelIdSet),
currentUser: {
id: session.user.id,
role: session.user.role,
recommendationCount: myRecommendationCount,
maxRecommendationCount: MAX_RECOMMENDATIONS_PER_EDITOR,
},
})
} catch (error) {
console.error("Failed to fetch editor recommendations", error)
return NextResponse.json({ error: "Failed to fetch recommendations" }, { status: 500 })
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || !isAllowedModerator(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await req.json()
const novelId = normalizeText(body?.novelId)
if (!novelId) {
return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
}
const existedNovel = await prisma.novel.findUnique({
where: { id: novelId },
select: { id: true },
})
if (!existedNovel) {
return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 })
}
await connectToMongoDB()
const existing = (await EditorRecommendation.findOne({
novelId,
editorId: session.user.id,
})
.select({ _id: 1 })
.lean()) as { _id: any } | null
if (existing) {
return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 })
}
const myRecommendationCount = await EditorRecommendation.countDocuments({
editorId: session.user.id,
})
if (myRecommendationCount >= MAX_RECOMMENDATIONS_PER_EDITOR) {
return NextResponse.json(
{ error: `Mỗi biên tập viên chỉ được đề cử tối đa ${MAX_RECOMMENDATIONS_PER_EDITOR} truyện` },
{ status: 400 }
)
}
try {
const created = await EditorRecommendation.create({
novelId,
editorId: session.user.id,
})
return NextResponse.json(
{
id: String(created._id),
novelId,
editorId: session.user.id,
},
{ status: 201 }
)
} catch (error: any) {
if (error?.code === 11000) {
return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 })
}
throw error
}
} catch (error) {
console.error("Failed to create editor recommendation", error)
return NextResponse.json({ error: "Failed to create recommendation" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions)
if (!session || !isAllowedModerator(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const id = normalizeText(url.searchParams.get("id"))
if (!id) {
return NextResponse.json({ error: "Thiếu ID đề cử" }, { status: 400 })
}
await connectToMongoDB()
const existed = (await EditorRecommendation.findById(id).lean()) as {
_id: any
editorId: string
} | null
if (!existed) {
return NextResponse.json({ error: "Đề cử không tồn tại" }, { status: 404 })
}
if (session.user.role !== "ADMIN" && existed.editorId !== session.user.id) {
return NextResponse.json({ error: "Bạn không thể xóa đề cử của người khác" }, { status: 403 })
}
await EditorRecommendation.deleteOne({ _id: id })
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete editor recommendation", error)
return NextResponse.json({ error: "Failed to delete recommendation" }, { status: 500 })
}
}
+212 -151
View File
@@ -116,18 +116,23 @@ function isRequestAbortedError(error: unknown): boolean {
}
const CHAPTER_REGEX_PRESETS: Record<string, string> = {
vi_chuong: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
en_chapter: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
mix_chapter: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
bracket_chapter: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$",
vi_chuong_hoi: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
mix_chapter: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
numeric_only: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$",
}
const LEGACY_CHAPTER_REGEX_PRESET_ALIASES: Record<string, keyof typeof CHAPTER_REGEX_PRESETS> = {
vi_chuong: "vi_chuong_hoi",
en_chapter: "mix_chapter",
bracket_chapter: "mix_chapter",
}
const NOISE_TITLE_REGEX = /^(?:mục lục|table of contents|toc|cover|bìa|copyright)$/i
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*:?$/i
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.|hồi|tiết|phần|thứ|quyển)\s*\d+(?:\.\d+)?\s*:?$/i
const GENERIC_SECTION_TITLE_REGEX = /^(?:m[uụ]c|section|sec\.?|part|ph[aầ]n)\s*[0-9ivxlcdm]+$/i
const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i
const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i
const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.|hồi|tiết|phần|thứ|quyển)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i
const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.|hồi|tiết|phần|thứ|quyển)?\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i
const GENERIC_GENRE_TOKENS = new Set([
"book",
"books",
@@ -555,11 +560,14 @@ function resolveRegexPattern(formData: FormData): { regexInput: string; regexPre
return { regexInput: custom, regexPreset: preset || "custom" }
}
if (preset && CHAPTER_REGEX_PRESETS[preset]) {
return { regexInput: CHAPTER_REGEX_PRESETS[preset], regexPreset: preset }
if (preset) {
const normalizedPreset = (CHAPTER_REGEX_PRESETS[preset] ? preset : LEGACY_CHAPTER_REGEX_PRESET_ALIASES[preset]) || null
if (normalizedPreset && CHAPTER_REGEX_PRESETS[normalizedPreset]) {
return { regexInput: CHAPTER_REGEX_PRESETS[normalizedPreset], regexPreset: normalizedPreset }
}
}
return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong, regexPreset: "vi_chuong" }
return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong_hoi, regexPreset: "vi_chuong_hoi" }
}
function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: string } {
@@ -579,6 +587,7 @@ function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: s
const flagSet = new Set(flags.split(""))
flagSet.add("g")
flagSet.add("m")
flagSet.add("i")
const normalizedFlags = Array.from(flagSet).join("")
const regex = new RegExp(pattern, normalizedFlags)
@@ -740,6 +749,33 @@ function buildChaptersFromRegexSections(sections: EpubSection[], regex: RegExp):
return enrichVolumeMetadata(parsed)
}
function trimLeadingBeforeChapterOne(chapters: ParsedChapter[]): {
chapters: ParsedChapter[]
trimmedCount: number
} {
if (chapters.length === 0) {
return { chapters, trimmedCount: 0 }
}
const firstChapterOneIndex = chapters.findIndex((chapter) => {
const detected =
chapter.detectedChapterNumber ??
extractStrictChapterNumber(chapter.title) ??
extractChapterNumber(chapter.title)
return detected === 1
})
if (firstChapterOneIndex <= 0) {
return { chapters, trimmedCount: 0 }
}
return {
chapters: chapters.slice(firstChapterOneIndex),
trimmedCount: firstChapterOneIndex,
}
}
function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
chapters: ParsedChapter[]
insertedCount: number
@@ -763,7 +799,7 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
let canUseDetected =
detectedNumber !== null &&
detectedNumber > currentNumber &&
detectedNumber - currentNumber <= MAX_ALLOWED_GAP
(currentNumber === 0 || detectedNumber - currentNumber <= MAX_ALLOWED_GAP)
// Recover from noisy leading TOC entries such as "Mục 1" that shift numbering.
if (!canUseDetected && detectedNumber !== null && detectedNumber > 0 && detectedNumber <= currentNumber) {
@@ -778,22 +814,24 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
currentNumber = Math.max(0, currentNumber - 1)
}
canUseDetected = detectedNumber > currentNumber && detectedNumber - currentNumber <= MAX_ALLOWED_GAP
canUseDetected = detectedNumber > currentNumber && (currentNumber === 0 || detectedNumber - currentNumber <= MAX_ALLOWED_GAP)
}
if (canUseDetected && detectedNumber !== null) {
for (let missing = currentNumber + 1; missing < detectedNumber; missing++) {
insertedCount += 1
normalized.push({
title: `Chương ${missing} (Thiếu)`,
content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`,
detectedChapterNumber: missing,
finalNumber: missing,
volumeNumber: null,
volumeTitle: null,
volumeChapterNumber: null,
isPlaceholder: true,
})
if (currentNumber > 0) {
for (let missing = currentNumber + 1; missing < detectedNumber; missing++) {
insertedCount += 1
normalized.push({
title: `Chương ${missing} (Thiếu)`,
content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`,
detectedChapterNumber: missing,
finalNumber: missing,
volumeNumber: null,
volumeTitle: null,
volumeChapterNumber: null,
isPlaceholder: true,
})
}
}
detectedNumberAssignments += 1
@@ -971,6 +1009,7 @@ export async function POST(req: Request) {
const seriesIdInput = readFormText(formData, "seriesId")
const seriesNameInput = readFormText(formData, "seriesName")
const replaceExisting = String(formData.get("replaceExisting") || "").toLowerCase() === "true"
const appendTargetNovelId = String(formData.get("appendTargetNovelId") || "").trim()
if (!epubFile) {
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 })
@@ -1020,7 +1059,8 @@ export async function POST(req: Request) {
}
}
const gapFilled = withMissingChapterPlaceholders(chapters)
const leadingTrimmed = trimLeadingBeforeChapterOne(chapters)
const gapFilled = withMissingChapterPlaceholders(leadingTrimmed.chapters)
parsedData = {
metadata,
@@ -1033,6 +1073,7 @@ export async function POST(req: Request) {
regexPreset,
sourceSections: sections.length,
chaptersDetected: chapters.length,
trimmedBeforeChapterOne: leadingTrimmed.trimmedCount,
chaptersFinal: gapFilled.chapters.length,
insertedMissingChapters: gapFilled.insertedCount,
detectedMaxChapterNumber: gapFilled.detectedMax,
@@ -1086,150 +1127,170 @@ export async function POST(req: Request) {
})
}
const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle)
const canReplaceDuplicated = duplicatedNovel
? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel)
: false
if (duplicatedNovel && !replaceExisting) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: `Truyện \"${duplicatedNovel.title}\" đã tồn tại`,
canReplace: canReplaceDuplicated,
existingNovel: {
id: duplicatedNovel.id,
title: duplicatedNovel.title,
slug: duplicatedNovel.slug,
},
}, { status: 409 })
}
if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: "Bạn không có quyền replace truyện đã tồn tại",
canReplace: false,
existingNovel: {
id: duplicatedNovel.id,
title: duplicatedNovel.title,
slug: duplicatedNovel.slug,
},
}, { status: 403 })
}
const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true)
const selectedSeriesId = await resolveSeriesIdForEpubImport({
mode: seriesMode,
seriesId: seriesIdInput,
seriesName: seriesNameInput,
userRole: session.user.role,
userId: session.user.id,
})
const coverUrl = await saveCoverBufferToR2(cover)
let targetNovelId = duplicatedNovel?.id || ""
let targetNovelId = ""
let responseStatus = 201
let replaced = false
let isAppending = !!appendTargetNovelId
let finalCoverUrl: string | null = null
if (duplicatedNovel && replaceExisting) {
const updatedNovel = await prisma.$transaction(async (tx) => {
await tx.novel.update({
where: { id: duplicatedNovel.id },
data: {
title: novelTitle,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
totalChapters: chapters.length,
...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}),
},
})
if (isAppending) {
const targetNovel = await prisma.novel.findUnique({ where: { id: appendTargetNovelId } })
if (!targetNovel || !canReplaceNovelByRole(session.user.role as UserRole, session.user.id, targetNovel)) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền thao tác báo cáo bổ sung." }, { status: 403 })
}
targetNovelId = targetNovel.id
responseStatus = 200
finalCoverUrl = targetNovel.coverUrl
} else {
const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle)
const canReplaceDuplicated = duplicatedNovel ? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel) : false
await tx.novelGenre.deleteMany({
where: { novelId: duplicatedNovel.id },
})
if (duplicatedNovel && !replaceExisting) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: `Truyện "${duplicatedNovel.title}" đã tồn tại`,
canReplace: canReplaceDuplicated,
existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug },
}, { status: 409 })
}
if (resolvedGenreIds.length > 0) {
await tx.novelGenre.createMany({
data: resolvedGenreIds.map((genreId) => ({
novelId: duplicatedNovel.id,
genreId,
})),
skipDuplicates: true,
})
}
if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: "Bạn không có quyền replace truyện đã tồn tại",
canReplace: false,
existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug },
}, { status: 403 })
}
return tx.novel.findUnique({ where: { id: duplicatedNovel.id } })
const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true)
const selectedSeriesId = await resolveSeriesIdForEpubImport({
mode: seriesMode,
seriesId: seriesIdInput,
seriesName: seriesNameInput,
userRole: session.user.role,
userId: session.user.id,
})
if (!updatedNovel) {
throw new Error("Không thể replace truyện đã tồn tại")
}
const coverUrl = await saveCoverBufferToR2(cover)
finalCoverUrl = coverUrl
targetNovelId = duplicatedNovel?.id || ""
targetNovelId = updatedNovel.id
responseStatus = 200
replaced = true
if (duplicatedNovel && replaceExisting) {
const updatedNovel = await prisma.$transaction(async (tx) => {
await tx.novel.update({
where: { id: duplicatedNovel.id },
data: {
title: novelTitle,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
totalChapters: chapters.length,
...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}),
},
})
await tx.novelGenre.deleteMany({ where: { novelId: duplicatedNovel.id } })
if (resolvedGenreIds.length > 0) {
await tx.novelGenre.createMany({
data: resolvedGenreIds.map((genreId) => ({ novelId: duplicatedNovel.id, genreId })),
skipDuplicates: true,
})
}
return tx.novel.findUnique({ where: { id: duplicatedNovel.id } })
})
if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) {
await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { })
}
} else {
// Generate base slug
const baseSlug = slugify(novelTitle)
let slug = baseSlug
let slugCounter = 1
if (!updatedNovel) throw new Error("Không thể replace truyện đã tồn tại")
// Đảm bảo slug là duy nhất
while (await prisma.novel.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${slugCounter}`
slugCounter++
}
targetNovelId = updatedNovel.id
responseStatus = 200
replaced = true
const createData: any = {
title: novelTitle,
slug,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
uploaderId: session.user.id,
totalChapters: chapters.length,
}
if (resolvedGenreIds.length > 0) {
createData.genres = {
create: resolvedGenreIds.map((genreId) => ({
genre: { connect: { id: genreId } },
})),
if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) {
await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { })
}
} else {
const baseSlug = slugify(novelTitle)
let slug = baseSlug
let slugCounter = 1
while (await prisma.novel.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${slugCounter}`
slugCounter++
}
}
const createdNovel = await prisma.novel.create({ data: createData })
targetNovelId = createdNovel.id
const createData: any = {
title: novelTitle,
slug,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
uploaderId: session.user.id,
totalChapters: chapters.length,
}
if (resolvedGenreIds.length > 0) {
createData.genres = { create: resolvedGenreIds.map((genreId) => ({ genre: { connect: { id: genreId } } })) }
}
const createdNovel = await prisma.novel.create({ data: createData })
targetNovelId = createdNovel.id
}
}
// Lưu chapters xuống MongoDB
await connectToMongoDB()
await Chapter.deleteMany({ novelId: targetNovelId })
let insertedCount = 0
let updatedCount = 0
const chapterDocs = chapters.map((ch: any, i: number) => ({
novelId: targetNovelId,
number: ch.finalNumber || (i + 1),
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
views: 0,
}))
if (!isAppending) {
await Chapter.deleteMany({ novelId: targetNovelId })
const finalChapterDocs = chapters.map((ch: any, i: number) => ({
novelId: targetNovelId,
number: ch.finalNumber || (i + 1),
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
views: 0,
}))
if (finalChapterDocs.length > 0) {
await Chapter.insertMany(finalChapterDocs)
insertedCount = finalChapterDocs.length
}
} else {
const bulkOps = chapters.map((ch: any) => {
const candidateNumber = ch.detectedChapterNumber || ch.finalNumber
return {
updateOne: {
filter: { novelId: targetNovelId, number: candidateNumber },
update: {
$set: {
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
},
},
upsert: true,
}
}
})
if (bulkOps.length > 0) {
const writeResult = await Chapter.bulkWrite(bulkOps)
insertedCount = writeResult.upsertedCount || 0
updatedCount = writeResult.modifiedCount || 0
}
if (chapterDocs.length > 0) {
await Chapter.insertMany(chapterDocs)
const totalAfterMerge = await Chapter.countDocuments({ novelId: targetNovelId })
await prisma.novel.update({
where: { id: targetNovelId },
data: { totalChapters: totalAfterMerge },
})
}
const novelAfterWrite = await prisma.novel.findUnique({ where: { id: targetNovelId } })
@@ -1240,7 +1301,7 @@ export async function POST(req: Request) {
return NextResponse.json({
...novelAfterWrite,
parserInfo,
hasCoverFromEpub: !!coverUrl,
hasCoverFromEpub: !!finalCoverUrl,
detectedGenres: detectedGenreNames,
replaced,
}, { status: responseStatus })
+44 -24
View File
@@ -148,6 +148,13 @@ export async function PUT(req: Request) {
try {
const data = await req.json()
const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data
if (!id) {
return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
}
const hasField = (field: string) => Object.prototype.hasOwnProperty.call(data, field)
const targetNovel = await prisma.novel.findFirst({
where: session.user.role === "ADMIN"
? { id }
@@ -169,6 +176,18 @@ export async function PUT(req: Request) {
const fixedSeriesId = targetNovel.seriesId
if (fixedSeriesId) {
const sharedData: Record<string, unknown> = {}
if (hasField("originalTitle")) sharedData.originalTitle = originalTitle
if (hasField("authorName")) sharedData.authorName = authorName
if (hasField("originalAuthorName")) sharedData.originalAuthorName = originalAuthorName
if (hasField("description")) sharedData.description = description
if (hasField("status")) sharedData.status = status
const ownData: Record<string, unknown> = {}
if (hasField("title")) ownData.title = title
if (hasField("coverUrl")) ownData.coverUrl = coverUrl
if (session.user.role === "MOD") ownData.uploaderId = session.user.id
const seriesNovels = await prisma.novel.findMany({
where: { seriesId: fixedSeriesId },
select: { id: true },
@@ -177,16 +196,12 @@ export async function PUT(req: Request) {
const updatedNovel = await prisma.$transaction(async (tx) => {
// Sync shared metadata for all novels in the same series.
await tx.novel.updateMany({
where: { id: { in: seriesNovelIds } },
data: {
originalTitle,
authorName,
originalAuthorName,
description,
status,
},
})
if (Object.keys(sharedData).length > 0) {
await tx.novel.updateMany({
where: { id: { in: seriesNovelIds } },
data: sharedData,
})
}
if (genreIds !== undefined) {
await tx.novelGenre.deleteMany({
@@ -203,31 +218,36 @@ export async function PUT(req: Request) {
}
// Only current novel keeps its own title and cover.
if (Object.keys(ownData).length === 0) {
return tx.novel.findUnique({ where: { id } })
}
return tx.novel.update({
where: { id },
data: {
title,
coverUrl,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
},
data: ownData,
})
})
return NextResponse.json(updatedNovel)
}
const updateData: Record<string, unknown> = {
seriesId: fixedSeriesId,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
}
if (hasField("title")) updateData.title = title
if (hasField("originalTitle")) updateData.originalTitle = originalTitle
if (hasField("authorName")) updateData.authorName = authorName
if (hasField("originalAuthorName")) updateData.originalAuthorName = originalAuthorName
if (hasField("description")) updateData.description = description
if (hasField("coverUrl")) updateData.coverUrl = coverUrl
if (hasField("status")) updateData.status = status
const updatedNovel = await prisma.novel.update({
where: { id },
data: {
title,
originalTitle,
authorName,
originalAuthorName,
description,
coverUrl,
status,
seriesId: fixedSeriesId,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
...updateData,
...(genreIds !== undefined && {
genres: {
deleteMany: {},
+170
View File
@@ -0,0 +1,170 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { UserRecommendation } from "@/lib/models/user-recommendation"
function normalizeText(value: unknown): string {
return typeof value === "string" ? value.trim() : ""
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
await connectToMongoDB()
const docs = (await UserRecommendation.find({ userId: session.user.id })
.sort({ createdAt: -1 })
.limit(1000)
.lean()) as Array<{
_id: any
novelId: string
createdAt?: Date
}>
const novelIds = Array.from(new Set(docs.map((doc) => doc.novelId).filter(Boolean)))
const novels = novelIds.length
? await prisma.novel.findMany({
where: { id: { in: novelIds } },
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
status: true,
totalChapters: true,
},
})
: []
const novelMap = new Map(novels.map((novel) => [novel.id, novel]))
const items = docs
.map((doc) => {
const novel = novelMap.get(doc.novelId)
if (!novel) return null
return {
id: String(doc._id),
novelId: doc.novelId,
createdAt: doc.createdAt || null,
novel,
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
return NextResponse.json(items)
} catch (error) {
console.error("Failed to fetch user recommendations", error)
return NextResponse.json({ error: "Failed to fetch recommendations" }, { status: 500 })
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await req.json()
const novelId = normalizeText(body?.novelId)
if (!novelId) {
return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
}
const novel = await prisma.novel.findUnique({ where: { id: novelId }, select: { id: true } })
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 })
}
await connectToMongoDB()
try {
const existing = (await UserRecommendation.findOne({
userId: session.user.id,
novelId,
})
.select({ _id: 1 })
.lean()) as { _id: any } | null
if (existing) {
return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 })
}
const created = await UserRecommendation.create({
userId: session.user.id,
novelId,
})
await prisma.novel.update({
where: { id: novelId },
data: { bookmarkCount: { increment: 1 } },
})
return NextResponse.json(
{
id: String(created._id),
novelId,
},
{ status: 201 }
)
} catch (error: any) {
if (error?.code === 11000) {
return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 })
}
throw error
}
} catch (error) {
console.error("Failed to create user recommendation", error)
return NextResponse.json({ error: "Failed to create recommendation" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const url = new URL(req.url)
const novelId = normalizeText(url.searchParams.get("novelId"))
if (!novelId) {
return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
}
await connectToMongoDB()
const existing = (await UserRecommendation.findOne({
userId: session.user.id,
novelId,
})
.select({ _id: 1 })
.lean()) as { _id: any } | null
if (!existing) {
return NextResponse.json({ error: "Bạn chưa đề cử truyện này" }, { status: 404 })
}
await UserRecommendation.deleteOne({ _id: existing._id })
await prisma.novel.update({
where: { id: novelId },
data: { bookmarkCount: { decrement: 1 } },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete user recommendation", error)
return NextResponse.json({ error: "Failed to delete recommendation" }, { status: 500 })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function GET() {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const settings = await prisma.userSetting.findUnique({
where: { userId: session.user.id }
})
return NextResponse.json(settings || {})
} catch (error) {
console.error("GET User Settings Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await req.json()
const { fontSize, lineHeight, letterSpacing, fontFamily } = body
const updateData: any = {}
if (fontSize !== undefined) updateData.fontSize = Number(fontSize)
if (lineHeight !== undefined) updateData.lineHeight = Number(lineHeight)
if (letterSpacing !== undefined) updateData.letterSpacing = Number(letterSpacing)
if (fontFamily !== undefined) updateData.fontFamily = String(fontFamily)
const createData = {
userId: session.user.id,
fontSize: fontSize !== undefined ? Number(fontSize) : 18,
lineHeight: lineHeight !== undefined ? Number(lineHeight) : 1.8,
letterSpacing: letterSpacing !== undefined ? Number(letterSpacing) : 0,
fontFamily: fontFamily !== undefined ? String(fontFamily) : "font-serif",
}
const settings = await prisma.userSetting.upsert({
where: { userId: session.user.id },
update: updateData,
create: createData
})
return NextResponse.json(settings)
} catch (error) {
console.error("POST User Settings Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+10 -5
View File
@@ -3,8 +3,10 @@ import { Be_Vietnam_Pro } from 'next/font/google'
import { ThemeProvider } from '@/components/theme-provider'
import { AuthProvider } from '@/lib/auth-context'
import { BookmarkProvider } from '@/lib/bookmark-context'
import { RecommendationProvider } from '@/lib/recommendation-context'
import { Header } from '@/components/header'
import { Footer } from '@/components/footer'
import { Toaster } from 'sonner'
import './globals.css'
const beVietnam = Be_Vietnam_Pro({
@@ -54,14 +56,17 @@ export default function RootLayout({
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AuthProvider>
<BookmarkProvider>
<div className="flex min-h-svh flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
<RecommendationProvider>
<div className="flex min-h-svh flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</RecommendationProvider>
</BookmarkProvider>
</AuthProvider>
</ThemeProvider>
<Toaster richColors position="top-right" />
</body>
</html>
)
File diff suppressed because it is too large Load Diff
+450 -48
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, Suspense, useRef } from "react"
import { useState, useEffect, Suspense, useRef, Fragment } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -15,12 +15,34 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search } from "lucide-react"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
// @ts-ignore
import * as mammoth from "mammoth"
const CHAPTER_REGEX_PRESETS = [
{
id: "vi_chuong_hoi",
name: "VN - Chương/Hồi/Tiết/Phần 1: ...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "mix_chapter",
name: "Mixed - Chương/Hồi/Chapter...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "numeric_only",
name: "Chỉ có số (1. ...)",
pattern: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$",
},
]
interface Chapter {
_id: string
number: number
@@ -32,6 +54,33 @@ interface Chapter {
createdAt: string
}
interface EpubPreviewData {
preview: true
fileName: string
splitMode: "toc" | "regex"
detectedStructureType: "light_novel" | "standard"
parserInfo?: {
splitMode: string
chapterRegexUsed?: string
regexPreset?: string
sourceSections: number
chaptersDetected: number
chaptersFinal: number
insertedMissingChapters: number
detectedMaxChapterNumber: number
detectedNumberAssignments: number
}
chaptersPreview: Array<{
number: number
title: string
isPlaceholder: boolean
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
excerpt: string
}>
}
const generatePagination = (currentPage: number, totalPages: number) => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
@@ -77,7 +126,10 @@ function ChapterManager() {
// Delete states
const [openDelete, setOpenDelete] = useState(false)
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
const [openBulkDelete, setOpenBulkDelete] = useState(false)
const [bulkDeleteFrom, setBulkDeleteFrom] = useState("")
const [bulkDeleteTo, setBulkDeleteTo] = useState("")
const [bulkDeleting, setBulkDeleting] = useState(false)
// Form states
const [number, setNumber] = useState("")
@@ -93,6 +145,135 @@ function ChapterManager() {
const [uploadProgress, setUploadProgress] = useState(0)
const [totalUpload, setTotalUpload] = useState(0)
// EPUB append states
const [openEpubAppend, setOpenEpubAppend] = useState(false)
const [epubFile, setEpubFile] = useState<File | null>(null)
const epubInputRef = useRef<HTMLInputElement>(null)
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("regex")
const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi")
const [epubCustomRegex, setEpubCustomRegex] = useState("")
const [appendingEpub, setAppendingEpub] = useState(false)
const [epubPreviewData, setEpubPreviewData] = useState<EpubPreviewData | null>(null)
const getEpubSourceRegex = () => {
if (epubRegexPreset === "custom") {
return epubCustomRegex.trim()
}
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
}
const handleEpubAppendPreview = async () => {
if (!epubFile || !novelId) return
setAppendingEpub(true)
setEpubPreviewData(null)
const toastId = toast.loading("Đang đọc và phân tích EPUB...")
try {
const formData = new FormData()
formData.append("file", epubFile)
formData.append("title", "Append Ebook")
formData.append("splitMode", epubSplitMode)
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("appendTargetNovelId", novelId)
formData.append("preview", "true")
const xhr = new XMLHttpRequest()
const result = await new Promise<{ status: number; ok: boolean; data: any }>((resolve, reject) => {
xhr.open("POST", "/api/mod/epub")
xhr.timeout = 250000
xhr.onload = () => resolve({ status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText || "{}") })
xhr.onerror = () => reject(new Error("Lỗi mạng khi phân tích EPUB"))
xhr.ontimeout = () => reject(new Error("Quá thời gian chờ khi xử lý EPUB"))
xhr.send(formData)
})
if (!result.ok) {
throw new Error(result.data.error || "Phân tích EPUB thất bại")
}
setEpubPreviewData(result.data)
toast.success("Phân tích thành công. Vui lòng kiểm tra lại trước khi chèn.", { id: toastId })
} catch (error: any) {
console.error(error)
toast.error(error.message || "Lỗi khi phân tích file EPUB", { id: toastId })
} finally {
setAppendingEpub(false)
}
}
const handleEpubAppendSubmit = async () => {
if (!epubFile || !novelId) return
setAppendingEpub(true)
const toastId = toast.loading("Đang đọc và tách chương từ EPUB...")
try {
const formData = new FormData()
formData.append("file", epubFile)
formData.append("title", "Append Ebook")
formData.append("splitMode", epubSplitMode)
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("appendTargetNovelId", novelId)
const xhr = new XMLHttpRequest()
const result = await new Promise<{ status: number; ok: boolean; data: any }>((resolve, reject) => {
xhr.open("POST", "/api/mod/epub")
xhr.timeout = 250000
xhr.onload = () => resolve({ status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText || "{}") })
xhr.onerror = () => reject(new Error("Lỗi mạng khi upload EPUB"))
xhr.ontimeout = () => reject(new Error("Quá thời gian chờ khi xử lý EPUB"))
xhr.send(formData)
})
if (!result.ok) {
throw new Error(result.data.error || "Nhập EPUB thất bại")
}
toast.success(`Nhập EPUB thành công! Đã chêm thêm ${result.data.parserInfo?.chaptersFinal || result.data.totalChapters} chương.`, { id: toastId })
setEpubPreviewData(null)
setOpenEpubAppend(false)
setEpubFile(null)
fetchChapters()
} catch (error: any) {
toast.error(error.message, { id: toastId })
} finally {
setAppendingEpub(false)
if (epubInputRef.current) epubInputRef.current.value = ""
}
}
const handleBulkDeleteSubmit = async () => {
if (!bulkDeleteFrom || !bulkDeleteTo || !novelId) return
setBulkDeleting(true)
const toastId = toast.loading("Đang xóa hàng loạt chương...")
try {
const res = await fetch("/api/mod/chuong/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
novelId,
fromNumber: Number(bulkDeleteFrom),
toNumber: Number(bulkDeleteTo)
})
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Xóa thất bại")
toast.success(`Đã xóa thành công ${data.deletedCount} chương!`, { id: toastId })
setOpenBulkDelete(false)
setBulkDeleteFrom("")
setBulkDeleteTo("")
fetchChapters()
} catch (error: any) {
toast.error(error.message, { id: toastId })
} finally {
setBulkDeleting(false)
}
}
const fetchChapters = async (pageToFetch = 1) => {
if (!novelId) return
setLoading(true)
@@ -397,6 +578,164 @@ function ChapterManager() {
<Wand2 className="h-4 w-4" /> Tối ưu hóa
</Button>
<Button variant="destructive" className="gap-2" onClick={() => setOpenBulkDelete(true)} disabled={chapters.length === 0}>
<Trash2 className="h-4 w-4" /> Xóa theo khoảng
</Button>
<input
type="file"
ref={epubInputRef}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
setEpubPreviewData(null)
setEpubSplitMode("regex")
setEpubFile(file)
setOpenEpubAppend(true)
}
if (e.target) e.target.value = ""
}}
accept=".epub"
className="hidden"
/>
<Button variant="secondary" className="gap-2" onClick={() => epubInputRef.current?.click()}>
<BookOpen className="h-4 w-4" /> Nhập từ EPUB
</Button>
<Dialog open={openEpubAppend} onOpenChange={(val) => {
setOpenEpubAppend(val)
if (!val) {
setEpubPreviewData(null)
if (epubInputRef.current) epubInputRef.current.value = ""
}
}}>
<DialogContent className="sm:max-w-[750px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bổ sung chương từ file EPUB</DialogTitle>
<DialogDescription>
Đc trích xuất hàng loạt chương từ file EPUB vào truyện này.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-4 flex-1 overflow-y-auto pr-2 pb-4">
<div className="space-y-2 rounded-md border p-4 bg-muted/50">
<h3 className="font-semibold text-sm">Chế đ ghi đè thông minh (Smart Merge Overwrite)</h3>
<p className="text-sm text-muted-foreground">
Hệ thống sẽ tự đng ghép nối các dải chương. Những chương thiếu sẽ đưc điền khuyết (Insert), những chương mới sẽ đưc chêm vào. Các chương cùng số thứ tự sẽ bị <strong>Ghi đè</strong> bằng nội dung lấy từ EPUB đ đm bảo đ chính xác.
</p>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm border-b pb-2">Giải Thuật Tách Chương</h3>
<div className="flex items-center gap-4">
<Label className="w-1/4">Phiên bản Text</Label>
<RadioGroup value={epubSplitMode} onValueChange={(v: "toc" | "regex") => {
setEpubSplitMode(v)
setEpubPreviewData(null)
}} className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="toc" id="toc_mode_a" />
<Label htmlFor="toc_mode_a" className="cursor-pointer font-normal">Mục lục (TOC)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="regex" id="regex_mode_a" />
<Label htmlFor="regex_mode_a" className="cursor-pointer font-normal">Quy tắc (Regex)</Label>
</div>
</RadioGroup>
</div>
{epubSplitMode === "regex" && (
<div className="space-y-3 pt-2">
<div className="flex flex-col gap-2">
<Label>Mẫu Regex sẵn</Label>
<Select value={epubRegexPreset} onValueChange={(v) => {
setEpubRegexPreset(v)
setEpubPreviewData(null)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHAPTER_REGEX_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
<SelectItem value="custom">Nâng cao (Tùy chỉnh Regex)...</SelectItem>
</SelectContent>
</Select>
</div>
{epubRegexPreset === "custom" && (
<div className="flex flex-col gap-2">
<Label>Regex Tùy Chỉnh</Label>
<Input
value={epubCustomRegex}
onChange={(e) => {
setEpubCustomRegex(e.target.value)
setEpubPreviewData(null)
}}
placeholder="Vd: /^(Chương|Hồi) \d+/gm"
/>
</div>
)}
</div>
)}
</div>
{epubPreviewData && epubPreviewData.parserInfo && (
<div className="space-y-4 border-t pt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="font-semibold text-sm">Kết quả trích xuất dự kiến (30 mục đu)</h3>
<div className="text-xs space-y-1 text-right">
<p><span className="text-muted-foreground">Flow file HTML:</span> {epubPreviewData.parserInfo.sourceSections}</p>
<p><span className="text-muted-foreground">Phát hiện:</span> {epubPreviewData.parserInfo.chaptersDetected} chương</p>
</div>
</div>
{epubPreviewData.chaptersPreview.length === 0 ? (
<div className="p-4 text-center border border-dashed rounded bg-muted/40 text-muted-foreground text-sm">
Không tách đưc chương nào. Xem lại Tùy chọn Tách / Mẫu Regex.
</div>
) : (
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-2 bg-card">
{epubPreviewData.chaptersPreview.map((chapter) => (
<div key={`preview-${chapter.number}`} className="flex border rounded overflow-hidden shadow-sm text-sm">
<div className="w-16 bg-muted border-r flex flex-col justify-center items-center shrink-0">
<span className="text-xs text-muted-foreground">Ch.</span>
<span className="font-semibold">{chapter.number}</span>
</div>
<div className="p-3 flex-1 min-w-0">
<h4 className="font-semibold mb-1 truncate text-foreground flex items-center gap-2">
{chapter.title}
{chapter.isPlaceholder && (
<span className="bg-destructive/10 text-destructive text-[10px] px-1.5 py-0.5 rounded uppercase font-bold">Lỗi tách chờ XL</span>
)}
</h4>
<p className="text-xs text-muted-foreground line-clamp-2">{chapter.excerpt}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter className="mt-auto pt-4 border-t">
<Button variant="outline" onClick={() => setOpenEpubAppend(false)} disabled={appendingEpub}>Huỷ</Button>
{!epubPreviewData ? (
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub}>
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xem trước dữ liệu
</Button>
) : (
<Button onClick={handleEpubAppendSubmit} disabled={appendingEpub || epubPreviewData.chaptersPreview.length === 0}>
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xác nhận ghi đè
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<input
type="file"
ref={fileInputRef}
@@ -424,30 +763,34 @@ function ChapterManager() {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
<div className="grid grid-cols-6 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 className="grid grid-cols-6 gap-4 border-b pb-4">
<div className="space-y-2 col-span-2">
<label className="text-sm font-medium text-primary">Chương số</label>
<Input type="number" step="any" value={number} onChange={(e) => setNumber(e.target.value)} required />
</div>
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Quyển số</label>
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
</div>
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Chương trong quyển</label>
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} placeholder="VD: 5" />
</div>
<div className="space-y-2 col-span-3">
<label className="text-sm font-medium">Tên quyển (Tuỳ chọn)</label>
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2 col-span-4">
<label className="text-sm font-medium">Tên chương</label>
<label className="text-sm font-medium text-primary">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
</div>
</div>
<details className="group border rounded-lg [&_summary::-webkit-details-marker]:hidden">
<summary className="flex cursor-pointer items-center justify-between px-4 py-2 bg-muted/30 font-medium">
<span className="text-sm">Tùy chọn nâng cao (Quyển / Tập)</span>
<span className="transition duration-300 group-open:-rotate-180">
<svg fill="none" height="18" shape-rendering="geometricPrecision" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" viewBox="0 0 24 24" width="18"><path d="M6 9l6 6 6-6"></path></svg>
</span>
</summary>
<div className="grid grid-cols-6 gap-4 p-4 text-muted-foreground bg-card">
<div className="space-y-2 col-span-2">
<label className="text-xs font-medium">Quyển số</label>
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
</div>
<div className="space-y-2 col-span-4">
<label className="text-xs font-medium">Tên quyển</label>
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Khởi đầu mới" />
</div>
</div>
</details>
<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 (Hỗ trợ xuống dòng)</label>
<Textarea
@@ -468,6 +811,38 @@ function ChapterManager() {
</DialogContent>
</Dialog>
<Dialog open={openBulkDelete} onOpenChange={setOpenBulkDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2">
<Trash2 className="h-5 w-5" /> Xóa hàng loạt chương
</DialogTitle>
<DialogDescription>
Cẩn thận! Thao tác này sẽ xóa vĩnh viễn các chương nằm trong khoảng bạn chọn. Không thể khôi phục!
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Từ chương số</Label>
<Input type="number" value={bulkDeleteFrom} onChange={(e) => setBulkDeleteFrom(e.target.value)} placeholder="Ví dụ: 10" />
</div>
<div className="space-y-2">
<Label>Đến chương số</Label>
<Input type="number" value={bulkDeleteTo} onChange={(e) => setBulkDeleteTo(e.target.value)} placeholder="Ví dụ: 20" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenBulkDelete(false)} disabled={bulkDeleting}>Huỷ</Button>
<Button variant="destructive" onClick={handleBulkDeleteSubmit} disabled={bulkDeleting || !bulkDeleteFrom || !bulkDeleteTo}>
{bulkDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xác nhận Xóa
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
@@ -577,7 +952,7 @@ function ChapterManager() {
</div>
<div className="rounded-xl border bg-card shadow-sm">
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-hidden">
<table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr>
@@ -594,32 +969,59 @@ function ChapterManager() {
) : chapters.length === 0 ? (
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr>
) : (
chapters.map((ch) => (
<tr key={ch._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">Chương {ch.number}</td>
<td className="px-5 py-4 text-muted-foreground">
{ch.volumeNumber || ch.volumeTitle
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
: "-"}
</td>
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<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
chapters.map((ch, index) => (
<Fragment key={ch._id}>
<tr className="border-b border-border hover:bg-muted/30 transition-colors last:border-0 relative group">
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
<td className="px-5 py-4 text-muted-foreground">
{ch.volumeNumber || ch.volumeTitle
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
: "-"}
</td>
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link href={`/mod/chuong/${ch._id}`}>
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50">
<Edit className="w-4 h-4 mr-1" /> Sửa
</Button>
</Link>
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
setDeletingChapterId(ch._id)
setOpenDelete(true)
}}>
<Trash2 className="w-4 h-4 mr-1" /> Xóa
</Button>
</Link>
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
setDeletingChapterId(ch._id)
setOpenDelete(true)
}}>
<Trash2 className="w-4 h-4 mr-1" /> Xóa
</Button>
</div>
</td>
</tr>
</div>
</td>
</tr>
<tr className="group/insert">
<td colSpan={5} className="p-0 border-none relative">
<div className="h-2 flex items-center justify-center opacity-0 group-hover/insert:opacity-100 transition-opacity -my-1 z-10">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-dashed border-primary/50"></div></div>
<Button
size="sm"
variant="outline"
className="h-6 rounded-full px-2 text-primary relative z-20 bg-background hover:bg-primary hover:text-primary-foreground text-xs"
onClick={() => {
const nextCh = chapters[index + 1]
let suggestedNum = ch.number + 1
if (nextCh && nextCh.number <= ch.number + 1) {
suggestedNum = Number((ch.number + 0.1).toFixed(2))
}
setNumber(suggestedNum.toString())
setTitle("")
setContent("")
setOpenAdd(true)
}}
>
<Plus className="w-3 h-3 mr-1" /> Chèn chương đây
</Button>
</div>
</td>
</tr>
</Fragment>
))
)}
</tbody>
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { AlertTriangle, BookOpen, Home, Sparkles, Star, ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
export function CollapsibleSidebar() {
const pathname = usePathname()
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
const saved = localStorage.getItem("mod-sidebar-collapsed")
if (saved === "true") setCollapsed(true)
}, [])
const toggle = () => {
const next = !collapsed
setCollapsed(next)
localStorage.setItem("mod-sidebar-collapsed", String(next))
}
if (!mounted) {
return <aside className="w-64 border-r bg-background p-4 hidden md:block transition-all duration-300"></aside>
}
const navItems = [
{ href: "/mod", label: "Tổng quan", icon: Home },
{ href: "/mod/truyen", label: "Quản lý truyện", icon: BookOpen },
{ href: "/mod/thieu-thong-tin", label: "Truyện thiếu dữ liệu", icon: AlertTriangle },
{ href: "/mod/de-cu", label: "Quản lý đề cử", icon: Star },
{ href: "/mod/ai-tool", label: "AI Tool", icon: Sparkles },
]
return (
<aside className={cn(
"border-r bg-background hidden md:flex flex-col relative transition-all duration-300",
collapsed ? "w-16 items-center py-4" : "w-64 p-4"
)}>
<button
onClick={toggle}
className="absolute -right-3 top-6 bg-background border rounded-full p-1 hover:bg-muted text-muted-foreground hover:text-foreground z-10 transition-transform shadow-sm"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
{!collapsed && <h2 className="mb-6 px-2 text-lg font-bold whitespace-nowrap overflow-hidden">Mod Dashboard</h2>}
{collapsed && <div className="mb-6 h-7" />}
<nav className="flex flex-col gap-2 w-full">
{navItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center rounded-md font-medium transition-colors hover:bg-muted group relative",
collapsed ? "justify-center p-2 mx-auto w-10 h-10" : "px-3 py-2 gap-3 text-sm",
isActive ? "bg-primary/10 text-primary hover:bg-primary/20" : "text-muted-foreground"
)}
title={collapsed ? item.label : undefined}
>
<Icon className={cn("shrink-0", collapsed ? "h-5 w-5" : "h-4 w-4")} />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{item.label}</span>}
{collapsed && (
<div className="absolute left-full ml-3 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible whitespace-nowrap z-50 shadow-md border">
{item.label}
</div>
)}
</Link>
)
})}
</nav>
</aside>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { RecommendationClient } from "./recommendation-client"
export default async function ModRecommendationPage() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <RecommendationClient />
}
+365
View File
@@ -0,0 +1,365 @@
"use client"
import Link from "next/link"
import { FormEvent, useEffect, useMemo, useState } from "react"
import { Loader2, Search, Star, Trash2, UserRoundCheck } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type NovelLite = {
id: string
title: string
slug: string
authorName: string
coverUrl: string | null
status: string
totalChapters: number
}
type RecommendationItem = {
id: string
createdAt: string | null
recommendCount: number
novel: NovelLite
editor: {
id: string
name: string
}
}
type SummaryItem = {
novel: NovelLite
recommendCount: number
}
type CandidateNovel = NovelLite & {
alreadyRecommended: boolean
recommendCount: number
}
type RecommendationResponse = {
items: RecommendationItem[]
summary: SummaryItem[]
candidates: CandidateNovel[]
myNovelIds: string[]
currentUser: {
id: string
role: string
recommendationCount: number
maxRecommendationCount: number
}
}
function formatRelativeTime(value: string | null): string {
if (!value) return "Vừa đề cử"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "Vừa đề cử"
const diff = Date.now() - date.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return "Vừa xong"
if (diff < hour) return `${Math.floor(diff / minute)} phút trước`
if (diff < day) return `${Math.floor(diff / hour)} giờ trước`
if (diff < day * 30) return `${Math.floor(diff / day)} ngày trước`
return date.toLocaleDateString("vi-VN")
}
export function RecommendationClient() {
const [loading, setLoading] = useState(true)
const [searching, setSearching] = useState(false)
const [submittingNovelId, setSubmittingNovelId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [keyword, setKeyword] = useState("")
const [activeQuery, setActiveQuery] = useState("")
const [items, setItems] = useState<RecommendationItem[]>([])
const [summary, setSummary] = useState<SummaryItem[]>([])
const [candidates, setCandidates] = useState<CandidateNovel[]>([])
const [currentUser, setCurrentUser] = useState<{
id: string
role: string
recommendationCount: number
maxRecommendationCount: number
} | null>(null)
const fetchData = async (query: string, initial = false) => {
if (initial) {
setLoading(true)
} else {
setSearching(true)
}
try {
const url = query.trim() ? `/api/mod/de-cu?q=${encodeURIComponent(query.trim())}` : "/api/mod/de-cu"
const res = await fetch(url)
const data = (await res.json()) as RecommendationResponse & { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể tải dữ liệu đề cử")
}
setItems(data.items || [])
setSummary(data.summary || [])
setCandidates(data.candidates || [])
setCurrentUser(data.currentUser || null)
setActiveQuery(query.trim())
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể tải dữ liệu đề cử"
toast.error(message)
} finally {
setLoading(false)
setSearching(false)
}
}
useEffect(() => {
fetchData("", true)
}, [])
const handleSearch = async (e: FormEvent) => {
e.preventDefault()
await fetchData(keyword)
}
const handleRecommend = async (novelId: string) => {
setSubmittingNovelId(novelId)
try {
const res = await fetch("/api/mod/de-cu", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể thêm đề cử")
}
toast.success("Đã thêm đề cử")
await fetchData(activeQuery)
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể thêm đề cử"
toast.error(message)
} finally {
setSubmittingNovelId(null)
}
}
const handleDelete = async (id: string) => {
if (!confirm("Bạn có chắc muốn xóa đề cử này?")) return
setDeletingId(id)
try {
const res = await fetch(`/api/mod/de-cu?id=${encodeURIComponent(id)}`, { method: "DELETE" })
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể xóa đề cử")
}
toast.success("Đã xóa đề cử")
await fetchData(activeQuery)
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể xóa đề cử"
toast.error(message)
} finally {
setDeletingId(null)
}
}
const canDelete = (item: RecommendationItem) => {
if (!currentUser) return false
return currentUser.role === "ADMIN" || currentUser.id === item.editor.id
}
const rankedSummary = useMemo(() => summary.slice(0, 12), [summary])
const hasReachedLimit =
Boolean(currentUser) &&
(currentUser?.recommendationCount || 0) >= (currentUser?.maxRecommendationCount || 0)
return (
<div className="space-y-6">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<UserRoundCheck className="h-6 w-6 text-primary" /> Quản đ cử biên tập
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Mỗi biên tập viên thể đ cử truyện thủ công. Trang chủ sẽ lấy dữ liệu từ danh sách này.
</p>
{currentUser && (
<p className="mt-2 text-xs text-primary">
Bạn đang dùng {currentUser.recommendationCount}/{currentUser.maxRecommendationCount} đ cử.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="space-y-4 xl:col-span-1">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h2 className="mb-3 text-base font-semibold">Tìm truyện đ đ cử</h2>
<form onSubmit={handleSearch} className="flex gap-2">
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Nhập tên truyện, tác giả hoặc slug"
/>
<Button type="submit" variant="outline" className="gap-2" disabled={searching}>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Tìm
</Button>
</form>
<div className="mt-3 space-y-2">
{searching ? (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Đang tìm...
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">Nhập từ khóa rồi bấm tìm đ hiện danh sách truyện.</p>
) : (
candidates.map((novel) => (
<div key={novel.id} className="rounded-lg border border-border bg-background/60 p-2.5">
<div className="flex items-start gap-2">
<img
src={novel.coverUrl || "/default-cover.svg"}
alt={novel.title}
className="h-14 w-10 rounded border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<Link href={`/truyen/${novel.slug}`} className="line-clamp-1 text-sm font-semibold hover:text-primary">
{novel.title}
</Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<p className="text-xs text-muted-foreground">{novel.recommendCount} đ cử</p>
</div>
</div>
<Button
type="button"
size="sm"
className="mt-2 w-full gap-2"
disabled={
novel.alreadyRecommended ||
submittingNovelId === novel.id ||
(hasReachedLimit && !novel.alreadyRecommended)
}
onClick={() => handleRecommend(novel.id)}
>
{submittingNovelId === novel.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Star className="h-4 w-4" />}
{novel.alreadyRecommended
? "Bạn đã đề cử"
: hasReachedLimit
? "Đã đạt giới hạn 5 truyện"
: "Đề cử truyện này"}
</Button>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h2 className="mb-3 text-base font-semibold">Top theo số lượng đ cử</h2>
<div className="space-y-2">
{rankedSummary.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa dữ liệu đ cử.</p>
) : (
rankedSummary.map((item, index) => (
<Link
key={item.novel.id}
href={`/truyen/${item.novel.slug}`}
className="flex items-center gap-2 rounded-lg border border-border bg-background/60 p-2.5 hover:border-primary/40"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{index + 1}
</span>
<img
src={item.novel.coverUrl || "/default-cover.svg"}
alt={item.novel.title}
className="h-12 w-9 rounded border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<p className="line-clamp-1 text-sm font-medium">{item.novel.title}</p>
<p className="text-xs text-muted-foreground">{item.recommendCount} đ cử</p>
</div>
</Link>
))
)}
</div>
</div>
</div>
<div className="rounded-xl border bg-card shadow-sm xl:col-span-2">
<div className="border-b border-border p-4">
<h2 className="text-base font-semibold">Danh sách đ cử hiện tại</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-4 py-3 font-semibold">Truyện</th>
<th className="px-4 py-3 font-semibold">Biên tập viên</th>
<th className="px-4 py-3 font-semibold">Tổng đ cử</th>
<th className="px-4 py-3 font-semibold">Thời gian</th>
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Chưa đ cử nào
</td>
</tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link href={`/truyen/${item.novel.slug}`} className="font-medium hover:text-primary">
{item.novel.title}
</Link>
<p className="text-xs text-muted-foreground">{item.novel.authorName}</p>
</td>
<td className="px-4 py-3">{item.editor.name}</td>
<td className="px-4 py-3">{item.recommendCount}</td>
<td className="px-4 py-3 text-muted-foreground">{formatRelativeTime(item.createdAt)}</td>
<td className="px-4 py-3 text-right">
{canDelete(item) ? (
<Button
variant="outline"
size="sm"
className="gap-1.5 text-red-600 border-red-200 hover:bg-red-50"
onClick={() => handleDelete(item.id)}
disabled={deletingId === item.id}
>
{deletingId === item.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
Xóa
</Button>
) : (
<span className="text-xs text-muted-foreground">Không quyền xóa</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
+2 -17
View File
@@ -1,8 +1,7 @@
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import Link from "next/link"
import { AlertTriangle, BookOpen, Home } from "lucide-react"
import { CollapsibleSidebar } from "./collapsible-sidebar"
export default async function ModLayout({
children,
@@ -18,21 +17,7 @@ export default async function ModLayout({
return (
<div className="flex min-h-[calc(100vh-3.5rem)] bg-muted/20">
{/* Sidebar */}
<aside className="w-64 border-r bg-background p-4 hidden md:block">
<h2 className="mb-6 px-2 text-lg font-bold">Mod Dashboard</h2>
<nav className="flex flex-col gap-2">
<Link href="/mod" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<Home className="h-4 w-4" /> Tổng quan
</Link>
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<BookOpen className="h-4 w-4" /> Quản truyện
</Link>
<Link href="/mod/thieu-thong-tin" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<AlertTriangle className="h-4 w-4" /> Truyện thiếu dữ liệu
</Link>
</nav>
</aside>
<CollapsibleSidebar />
{/* Main Content */}
<main className="flex-1 p-6">
+18
View File
@@ -1,6 +1,8 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import Link from "next/link"
import { Sparkles } from "lucide-react"
export default async function ModDashboardPage() {
const session = await getServerSession(authOptions)
@@ -65,6 +67,22 @@ export default async function ModDashboardPage() {
<p className="text-3xl font-bold mt-2">{seriesCount}</p>
</div>
</div>
<div className="mt-6 rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Tool
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Công cụ AI hỗ trợ tìm kiếm tự bổ sung thông tin truyện vào form quản .
</p>
<Link
href="/mod/ai-tool"
className="mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Mở AI Tool
</Link>
</div>
</div>
)
}
+118 -33
View File
@@ -13,11 +13,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon, Search, FileText, X, Check, FolderOpen, ChevronLeft, ChevronRight } from "lucide-react"
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon, Search, FileText, X, Check, FolderOpen, ChevronLeft, ChevronRight, WandSparkles } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
import { Progress } from "@/components/ui/progress"
import { MOD_AI_PREFILL_STORAGE_KEY, type AINovelPrefillPayload } from "@/lib/mod-ai-tools"
interface Novel {
id: string
@@ -106,24 +107,19 @@ interface EpubUploadResponseData {
const CHAPTER_REGEX_PRESETS = [
{
id: "vi_chuong",
name: "VN - Chương 1: ...",
pattern: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "en_chapter",
name: "EN - Chapter 1: ...",
pattern: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
id: "vi_chuong_hoi",
name: "VN - Chương/Hồi/Tiết/Phần 1: ...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "mix_chapter",
name: "Mixed - Chương/Chapter",
pattern: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
name: "Mixed - Chương/Hồi/Chapter...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "bracket_chapter",
name: "[Chương 01] ...",
pattern: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$",
id: "numeric_only",
name: "Chỉ có số (1. ...)",
pattern: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$",
},
]
@@ -146,7 +142,7 @@ export function NovelClient() {
const [epubSeriesId, setEpubSeriesId] = useState("")
const [epubSeriesName, setEpubSeriesName] = useState("")
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("toc")
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong")
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong_hoi")
const [epubCustomRegex, setEpubCustomRegex] = useState("")
const epubInputRef = useRef<HTMLInputElement>(null)
const epubFolderInputRef = useRef<HTMLInputElement>(null)
@@ -191,6 +187,7 @@ export function NovelClient() {
const [pageSize, setPageSize] = useState(20)
const [bulkProgress, setBulkProgress] = useState<Record<string, BulkUploadProgressItem>>({})
const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState<BulkDuplicateHandling>("ask")
const [pendingAIPrefill, setPendingAIPrefill] = useState<AINovelPrefillPayload | null>(null)
const getSelectedChapterRegex = () => {
if (epubRegexPreset === "custom") {
@@ -360,6 +357,99 @@ export function NovelClient() {
fetchSeries()
}, [])
const normalizeGenreName = (value: string) => value.trim().toLowerCase()
const resetAddForm = () => {
setTitle("")
setOriginalTitle("")
setAuthorName("")
setOriginalAuthorName("")
setDescription("")
setCoverUrl("")
setSeriesMode("none")
setSelectedSeriesId("")
setNewSeriesName("")
setStatus("Đang ra")
setSelectedGenres([])
setGenreQuery("")
}
const applyAIPrefillToAddForm = (prefill: AINovelPrefillPayload) => {
const nextTitle = prefill.title?.trim() || ""
const nextAuthor = prefill.authorName?.trim() || ""
setTitle(nextTitle)
setOriginalTitle((prefill.originalTitle || nextTitle).trim())
setAuthorName(nextAuthor)
setOriginalAuthorName((prefill.originalAuthorName || nextAuthor).trim())
setDescription((prefill.description || "").trim())
setCoverUrl((prefill.coverUrl || "").trim())
setSeriesMode("none")
setSelectedSeriesId("")
setNewSeriesName("")
setGenreQuery("")
const validStatus = ["Đang ra", "Hoàn thành", "Tạm ngưng"].includes(prefill.status || "")
? (prefill.status as "Đang ra" | "Hoàn thành" | "Tạm ngưng")
: "Đang ra"
setStatus(validStatus)
const suggestedGenres = Array.isArray(prefill.genresSuggested) ? prefill.genresSuggested : []
if (suggestedGenres.length === 0 || genres.length === 0) {
setSelectedGenres([])
return
}
const byName = new Map(genres.map((genre) => [normalizeGenreName(genre.name), genre.id]))
const pickedIds: string[] = []
const missing: string[] = []
for (const name of suggestedGenres) {
const id = byName.get(normalizeGenreName(name))
if (!id) {
missing.push(name)
continue
}
if (!pickedIds.includes(id)) pickedIds.push(id)
}
setSelectedGenres(pickedIds)
if (missing.length > 0) {
toast.info(`The loai goi y chua co san: ${missing.slice(0, 4).join(", ")}`)
}
}
useEffect(() => {
if (typeof window === "undefined") return
const raw = window.localStorage.getItem(MOD_AI_PREFILL_STORAGE_KEY)
if (!raw) return
window.localStorage.removeItem(MOD_AI_PREFILL_STORAGE_KEY)
try {
const parsed = JSON.parse(raw) as AINovelPrefillPayload
setPendingAIPrefill(parsed)
setOpenAdd(true)
} catch {
toast.error("Du lieu AI Tool khong hop le")
}
}, [])
useEffect(() => {
if (!pendingAIPrefill) return
const needsGenres = Array.isArray(pendingAIPrefill.genresSuggested)
&& pendingAIPrefill.genresSuggested.length > 0
&& genres.length === 0
if (needsGenres) return
applyAIPrefillToAddForm(pendingAIPrefill)
setPendingAIPrefill(null)
toast.success("Da nap du lieu de xuat tu AI Tool")
}, [pendingAIPrefill, genres])
const filteredNovels = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
if (!keyword) return novels
@@ -671,18 +761,7 @@ export function NovelClient() {
if (!res.ok) throw new Error("Thêm mới thất bại")
toast.success("Đã thêm truyện thành công!")
setOpenAdd(false)
setTitle("")
setOriginalTitle("")
setAuthorName("")
setOriginalAuthorName("")
setDescription("")
setCoverUrl("")
setSeriesMode("none")
setSelectedSeriesId("")
setNewSeriesName("")
setStatus("Đang ra")
setSelectedGenres([])
setGenreQuery("")
resetAddForm()
fetchNovels()
fetchSeries()
} catch {
@@ -707,7 +786,7 @@ export function NovelClient() {
setEpubSeriesId("")
setEpubSeriesName("")
setEpubSplitMode("toc")
setEpubRegexPreset("vi_chuong")
setEpubRegexPreset("vi_chuong_hoi")
setEpubCustomRegex("")
if (epubInputRef.current) {
epubInputRef.current.value = ""
@@ -799,7 +878,7 @@ export function NovelClient() {
try {
await requestEpubPreview(file, {
splitMode: "toc",
regexPreset: "vi_chuong",
regexPreset: "vi_chuong_hoi",
regexInput: CHAPTER_REGEX_PRESETS[0].pattern,
preserveEditedMetadata: false,
})
@@ -1273,6 +1352,12 @@ export function NovelClient() {
<FolderOpen className="h-4 w-4" />
Chọn thư mục EPUB
</Button>
<Button variant="outline" className="gap-2" asChild>
<Link href="/mod/ai-tool">
<WandSparkles className="h-4 w-4" />
AI Tool
</Link>
</Button>
<Dialog
open={openEpubPreview}
@@ -1706,13 +1791,13 @@ export function NovelClient() {
</Dialog>
<Dialog open={openAdd} onOpenChange={(val) => {
setOpenAdd(val);
if (val) {
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSeriesMode("none"); setSelectedSeriesId(""); setNewSeriesName(""); setSelectedGenres([]); setGenreQuery("");
setOpenAdd(val)
if (!val) {
resetAddForm()
}
}}>
<DialogTrigger asChild>
<Button className="gap-2">
<Button className="gap-2" onClick={resetAddForm}>
<Plus className="h-4 w-4" /> Thêm truyện
</Button>
</DialogTrigger>
+278 -144
View File
@@ -1,9 +1,12 @@
import Link from "next/link"
import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Star, Trophy } from "lucide-react"
import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Trophy } from "lucide-react"
import { formatViews } from "@/lib/utils"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
import { EditorRecommendation } from "@/lib/models/editor-recommendation"
import { UserRecommendation } from "@/lib/models/user-recommendation"
import { HomeHotCarousel, type HotCarouselItem } from "@/components/home-hot-carousel"
import { HomeRecommendationBoards } from "@/components/home-recommendation-boards"
import { prisma } from "@/lib/prisma"
@@ -24,6 +27,21 @@ type HomeNovel = {
bookmarkCount: number
seriesId: string | null
updatedAt: Date
uploader?: {
name: string | null
role: "USER" | "MOD" | "ADMIN"
} | null
}
type EditorRecommendedItem = {
novel: HomeNovel
editorName: string
recommendCount: number
}
type RecommendedByCountItem = {
novel: HomeNovel
recommendCount: number
}
type RankingEntry = {
@@ -67,6 +85,12 @@ const BASE_NOVEL_SELECT = {
bookmarkCount: true,
seriesId: true,
updatedAt: true,
uploader: {
select: {
name: true,
role: true,
},
},
} as const
function toUTCDateOnly(value: Date): Date {
@@ -147,6 +171,25 @@ function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(
return output
}
function withTimeout<T>(promise: Promise<T>, label: string, timeoutMs = 4000): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
}, timeoutMs)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(error) => {
clearTimeout(timer)
reject(error)
}
)
})
}
async function fetchRankingByDailyViews(options?: { since?: Date; take?: number }): Promise<RankingEntry[]> {
const delegate = (prisma as any).novelViewDaily
if (!delegate || typeof delegate.groupBy !== "function") {
@@ -209,6 +252,107 @@ function toHotCarouselItems(rows: Array<RankingEntry & { source: HotCarouselItem
}))
}
async function fetchManualRecommendationData(): Promise<{
recommendedByCountItems: RecommendedByCountItem[]
editorRecommendedItems: EditorRecommendedItem[]
}> {
try {
await connectToMongoDB()
const editorDocs = (await EditorRecommendation.find({}).sort({ createdAt: -1 }).limit(2000).lean()) as Array<{
novelId: string
editorId: string
createdAt?: Date
}>
const userDocs = (await UserRecommendation.find({}).sort({ createdAt: -1 }).limit(5000).lean()) as Array<{
novelId: string
createdAt?: Date
}>
if (editorDocs.length === 0 && userDocs.length === 0) {
return {
recommendedByCountItems: [],
editorRecommendedItems: [],
}
}
const novelIds = Array.from(
new Set(
[...editorDocs.map((doc) => doc.novelId), ...userDocs.map((doc) => doc.novelId)].filter(Boolean)
)
)
const editorIds = Array.from(new Set(editorDocs.map((doc) => doc.editorId).filter(Boolean)))
const [novels, editors] = await Promise.all([
prisma.novel.findMany({
where: { id: { in: novelIds } },
select: BASE_NOVEL_SELECT,
}),
prisma.user.findMany({
where: { id: { in: editorIds } },
select: { id: true, name: true },
}),
])
const novelMap = new Map(novels.map((novel) => [novel.id, novel as HomeNovel]))
const editorMap = new Map(editors.map((editor) => [editor.id, editor]))
const recommendCountMap = new Map<string, number>()
for (const doc of editorDocs) {
recommendCountMap.set(doc.novelId, (recommendCountMap.get(doc.novelId) || 0) + 1)
}
for (const doc of userDocs) {
recommendCountMap.set(doc.novelId, (recommendCountMap.get(doc.novelId) || 0) + 1)
}
const recommendedByCountItems = Array.from(recommendCountMap.entries())
.map(([novelId, recommendCount]) => ({
novel: novelMap.get(novelId),
recommendCount,
}))
.filter((row): row is { novel: HomeNovel; recommendCount: number } => Boolean(row.novel))
.sort((a, b) => {
if (b.recommendCount !== a.recommendCount) return b.recommendCount - a.recommendCount
if (b.novel.rating !== a.novel.rating) return b.novel.rating - a.novel.rating
return b.novel.views - a.novel.views
})
const editorRecommendedItems = editorDocs
.map((doc) => {
const novel = novelMap.get(doc.novelId)
if (!novel) return null
return {
novel,
editorName: editorMap.get(doc.editorId)?.name || "Biên tập viên",
recommendCount: recommendCountMap.get(doc.novelId) || 0,
createdAt: doc.createdAt ? new Date(doc.createdAt).getTime() : 0,
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((a, b) => {
if (b.recommendCount !== a.recommendCount) return b.recommendCount - a.recommendCount
return b.createdAt - a.createdAt
})
.map((item) => ({
novel: item.novel,
editorName: item.editorName,
recommendCount: item.recommendCount,
}))
return {
recommendedByCountItems,
editorRecommendedItems,
}
} catch (error) {
console.warn("Homepage manual recommendation query failed", error)
return {
recommendedByCountItems: [],
editorRecommendedItems: [],
}
}
}
function RankingBoard({
title,
entries,
@@ -256,7 +400,8 @@ function RankingBoard({
export default async function HomePage() {
let hotSlides: HotCarouselItem[] = []
let randomNovels: HomeNovel[] = []
let recommendedNovels: HomeNovel[] = []
let recommendedByCountItems: RecommendedByCountItem[] = []
let editorRecommendedItems: EditorRecommendedItem[] = []
let latestNovels: HomeNovel[] = []
let recentComments: RecentCommentItem[] = []
let weeklyRanking: RankingEntry[] = []
@@ -277,34 +422,24 @@ export default async function HomePage() {
allTimeResult,
popularFallbackResult,
randomPoolResult,
recommendedPoolResult,
latestPoolResult,
recommendationResult,
commentsResult,
] = await Promise.allSettled([
fetchRankingByDailyViews({ since: weekStart, take: 600 }),
fetchRankingByDailyViews({ since: monthStart, take: 600 }),
fetchRankingByDailyViews({ take: 800 }),
prisma.novel.findMany({
withTimeout(fetchRankingByDailyViews({ since: weekStart, take: 600 }), "Homepage weekly ranking"),
withTimeout(fetchRankingByDailyViews({ since: monthStart, take: 600 }), "Homepage monthly ranking"),
withTimeout(fetchRankingByDailyViews({ take: 800 }), "Homepage all-time ranking"),
withTimeout(prisma.novel.findMany({
take: 400,
select: BASE_NOVEL_SELECT,
orderBy: [{ views: "desc" }, { updatedAt: "desc" }],
}),
prisma.novel.findMany({
}), "Homepage popular fallback"),
withTimeout(prisma.novel.findMany({
take: 420,
select: BASE_NOVEL_SELECT,
orderBy: [{ updatedAt: "desc" }],
}),
prisma.novel.findMany({
take: 220,
select: BASE_NOVEL_SELECT,
orderBy: [{ rating: "desc" }, { bookmarkCount: "desc" }, { views: "desc" }],
}),
prisma.novel.findMany({
take: 180,
select: BASE_NOVEL_SELECT,
orderBy: [{ updatedAt: "desc" }],
}),
prisma.comment.findMany({
}), "Homepage random pool"),
withTimeout(fetchManualRecommendationData(), "Homepage recommendations"),
withTimeout(prisma.comment.findMany({
take: 10,
orderBy: { createdAt: "desc" },
select: {
@@ -314,7 +449,7 @@ export default async function HomePage() {
user: { select: { name: true } },
novel: { select: { slug: true, title: true } },
},
}),
}), "Homepage comments"),
])
if (weeklyResult.status === "rejected") console.warn("Homepage weekly ranking query failed", weeklyResult.reason)
@@ -322,8 +457,7 @@ export default async function HomePage() {
if (allTimeResult.status === "rejected") console.warn("Homepage all-time ranking query failed", allTimeResult.reason)
if (popularFallbackResult.status === "rejected") console.warn("Homepage popular fallback query failed", popularFallbackResult.reason)
if (randomPoolResult.status === "rejected") console.warn("Homepage random pool query failed", randomPoolResult.reason)
if (recommendedPoolResult.status === "rejected") console.warn("Homepage recommended pool query failed", recommendedPoolResult.reason)
if (latestPoolResult.status === "rejected") console.warn("Homepage latest pool query failed", latestPoolResult.reason)
if (recommendationResult.status === "rejected") console.warn("Homepage recommendation query failed", recommendationResult.reason)
if (commentsResult.status === "rejected") console.warn("Homepage comments query failed", commentsResult.reason)
const weeklyRaw = weeklyResult.status === "fulfilled" ? weeklyResult.value : []
@@ -331,8 +465,9 @@ export default async function HomePage() {
const allTimeRaw = allTimeResult.status === "fulfilled" ? allTimeResult.value : []
const popularFallbackRaw = popularFallbackResult.status === "fulfilled" ? popularFallbackResult.value : []
const randomPoolRaw = randomPoolResult.status === "fulfilled" ? randomPoolResult.value : []
const recommendedPoolRaw = recommendedPoolResult.status === "fulfilled" ? recommendedPoolResult.value : []
const latestPoolRaw = latestPoolResult.status === "fulfilled" ? latestPoolResult.value : []
const recommendationData = recommendationResult.status === "fulfilled"
? recommendationResult.value
: { recommendedByCountItems: [], editorRecommendedItems: [] }
const commentsPool = commentsResult.status === "fulfilled" ? commentsResult.value : []
const popularFallbackRows: RankingEntry[] = (popularFallbackRaw as HomeNovel[]).map((novel) => ({
@@ -342,9 +477,9 @@ export default async function HomePage() {
aggregatedViews: novel.views,
}))
weeklyRanking = fillUniqueRows(collapseSeriesRows(weeklyRaw), collapseSeriesRows(popularFallbackRows), 10)
monthlyRanking = fillUniqueRows(collapseSeriesRows(monthlyRaw), collapseSeriesRows(popularFallbackRows), 10)
allTimeRanking = fillUniqueRows(collapseSeriesRows(allTimeRaw), collapseSeriesRows(popularFallbackRows), 10)
weeklyRanking = fillUniqueRows(collapseSeriesRows(weeklyRaw), collapseSeriesRows(popularFallbackRows), 5)
monthlyRanking = fillUniqueRows(collapseSeriesRows(monthlyRaw), collapseSeriesRows(popularFallbackRows), 5)
allTimeRanking = fillUniqueRows(collapseSeriesRows(allTimeRaw), collapseSeriesRows(popularFallbackRows), 5)
const hotWeekly = weeklyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "week" as const }))
const hotMonthly = monthlyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "month" as const }))
@@ -361,42 +496,72 @@ export default async function HomePage() {
const randomCandidates = collapseSeriesRows(shuffleRows(randomPool)).filter((item) => !usedHotIds.has(item.id))
randomNovels = fillUniqueRows(randomCandidates, shuffleRows(randomPool), 12)
recommendedNovels = fillUniqueRows(
collapseSeriesRows(recommendedPoolRaw as HomeNovel[]),
collapseSeriesRows(popularFallbackRaw as HomeNovel[]),
10,
)
recommendedByCountItems = recommendationData.recommendedByCountItems
editorRecommendedItems = recommendationData.editorRecommendedItems
latestNovels = fillUniqueRows(
collapseSeriesRows(latestPoolRaw as HomeNovel[]),
collapseSeriesRows(popularFallbackRaw as HomeNovel[]),
12,
)
recentComments = commentsPool as RecentCommentItem[]
const latestIds = latestNovels.map((item) => item.id)
if (latestIds.length > 0) {
await connectToMongoDB()
const rows = await Chapter.aggregate([
{ $match: { novelId: { $in: latestIds } } },
{ $sort: { novelId: 1, createdAt: -1, number: -1 } },
{
$group: {
_id: "$novelId",
chapterNumber: { $first: "$number" },
chapterTitle: { $first: "$title" },
chapterCreatedAt: { $first: "$createdAt" },
},
},
])
try {
// Latest-updated list is based only on newly created chapters, not on novel metadata edits.
// Sampling recent inserts by Mongo _id keeps this query on the default index and avoids scanning the whole collection.
await withTimeout(connectToMongoDB(), "Homepage chapter Mongo connect")
const recentChapterRows = await withTimeout(
Chapter.find(
{},
{
novelId: 1,
number: 1,
title: 1,
createdAt: 1,
}
)
.sort({ _id: -1 })
.limit(400)
.lean() as Promise<Array<{
novelId?: string
number?: number
title?: string
createdAt?: Date
}>>,
"Homepage recent chapters"
)
for (const row of rows) {
latestChapterMap.set(String(row._id), {
chapterNumber: typeof row.chapterNumber === "number" ? row.chapterNumber : null,
chapterTitle: typeof row.chapterTitle === "string" ? row.chapterTitle : null,
chapterCreatedAt: row.chapterCreatedAt ? new Date(row.chapterCreatedAt) : null,
const latestNovelIdsByChapter: string[] = []
const latestSeenNovelIds = new Set<string>()
for (const row of recentChapterRows) {
const novelId = String(row.novelId || "").trim()
if (!novelId || latestSeenNovelIds.has(novelId)) continue
latestSeenNovelIds.add(novelId)
latestNovelIdsByChapter.push(novelId)
latestChapterMap.set(novelId, {
chapterNumber: typeof row.number === "number" ? row.number : null,
chapterTitle: typeof row.title === "string" ? row.title : null,
chapterCreatedAt: row.createdAt ? new Date(row.createdAt) : null,
})
if (latestNovelIdsByChapter.length >= 500) break
}
if (latestNovelIdsByChapter.length > 0) {
const latestNovelPool = await withTimeout(
prisma.novel.findMany({
where: { id: { in: latestNovelIdsByChapter } },
select: BASE_NOVEL_SELECT,
}),
"Homepage latest novel pool"
)
const latestNovelMap = new Map(latestNovelPool.map((novel) => [novel.id, novel as HomeNovel]))
const orderedLatestByChapter = latestNovelIdsByChapter
.map((id) => latestNovelMap.get(id))
.filter((row): row is HomeNovel => Boolean(row))
latestNovels = collapseSeriesRows(orderedLatestByChapter).slice(0, 5)
}
} catch (error) {
console.warn("Homepage latest chapter section skipped", error)
}
} catch (error) {
console.error("Failed to fetch data for homepage during build/runtime", error)
@@ -456,41 +621,64 @@ export default async function HomePage() {
</div>
</section>
<section className="grid gap-6 lg:grid-cols-5">
<div className="rounded-2xl border border-border/70 bg-card/70 p-4 lg:col-span-3">
<h2 className="mb-4 text-xl font-bold text-foreground">Top truyện đ cử</h2>
<HomeRecommendationBoards topItems={recommendedByCountItems} editorItems={editorRecommendedItems} pageSize={5} />
<section>
<div className="mb-4">
<h2 className="text-xl font-bold text-foreground">Bảng xếp hạng đ hot</h2>
<p className="text-sm text-muted-foreground">So sánh đ nóng theo tuần, tháng toàn thời gian.</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<RankingBoard title="Hot theo tuần" entries={weeklyRanking} emptyText="Tuần này chưa có dữ liệu nổi bật." />
<RankingBoard title="Hot theo tháng" entries={monthlyRanking} emptyText="Tháng này chưa có dữ liệu nổi bật." />
<RankingBoard title="Hot toàn thời gian" entries={allTimeRanking} emptyText="Chưa có dữ liệu toàn thời gian." />
</div>
</section>
<section className="grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-card/70 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="inline-flex items-center gap-2 text-xl font-bold text-foreground"><Clock3 className="h-5 w-5 text-primary" />Truyện mới cập nhật</h2>
<Link href="/tim-kiem?sort=latest" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="space-y-2">
{recommendedNovels.length > 0 ? recommendedNovels.map((novel, index) => (
<Link
key={novel.id}
href={`/truyen/${novel.slug}`}
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-2.5 transition hover:border-primary/40"
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{index + 1}
</span>
<img
src={novel.coverUrl || "/default-cover.svg"}
alt={novel.title}
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{novel.title}</h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<p className="mt-1 text-xs text-muted-foreground">{formatViews(novel.bookmarkCount)} theo dõi</p>
</div>
<div className="inline-flex items-center gap-1 text-xs font-semibold text-primary">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
{novel.rating.toFixed(1)}
</div>
</Link>
)) : (
<p className="text-sm text-muted-foreground">Chưa truyện đ cử.</p>
{latestNovels.length > 0 ? latestNovels.map((novel) => {
const chapter = latestChapterMap.get(novel.id)
const chapterLabel = chapter?.chapterNumber ? `Chương ${chapter.chapterNumber}` : "Chưa có chương"
const chapterTitle = chapter?.chapterTitle ? compactLine(chapter.chapterTitle, 100) : "Đang cập nhật nội dung chương"
const updatedTime = formatRelativeTime(chapter?.chapterCreatedAt || novel.updatedAt)
return (
<Link
key={novel.id}
href={`/truyen/${novel.slug}`}
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-3 transition hover:border-primary/40"
>
<img
src={novel.coverUrl || "/default-cover.svg"}
alt={novel.title}
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{novel.title}</h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<p className="mt-1 text-xs text-primary">{chapterLabel}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">{chapterTitle}</p>
</div>
<div className="text-right text-[11px] text-muted-foreground">{updatedTime}</div>
</Link>
)
}) : (
<p className="text-sm text-muted-foreground">Chưa truyện mới cập nhật.</p>
)}
</div>
</div>
<aside className="rounded-2xl border border-border/70 bg-card/70 p-4 lg:col-span-2">
<aside className="rounded-2xl border border-border/70 bg-card/70 p-4">
<h2 className="mb-4 inline-flex items-center gap-2 text-xl font-bold text-foreground"><MessageSquare className="h-5 w-5 text-primary" />Bình luận mới</h2>
<div className="space-y-2">
@@ -513,60 +701,6 @@ export default async function HomePage() {
</div>
</aside>
</section>
<section className="rounded-2xl border border-border/70 bg-card/70 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="inline-flex items-center gap-2 text-xl font-bold text-foreground"><Clock3 className="h-5 w-5 text-primary" />Truyện mới cập nhật</h2>
<Link href="/tim-kiem?sort=latest" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
Xem tất cả <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="space-y-2">
{latestNovels.length > 0 ? latestNovels.map((novel) => {
const chapter = latestChapterMap.get(novel.id)
const chapterLabel = chapter?.chapterNumber ? `Chương ${chapter.chapterNumber}` : "Chưa có chương"
const chapterTitle = chapter?.chapterTitle ? compactLine(chapter.chapterTitle, 100) : "Đang cập nhật nội dung chương"
const updatedTime = formatRelativeTime(chapter?.chapterCreatedAt || novel.updatedAt)
return (
<Link
key={novel.id}
href={`/truyen/${novel.slug}`}
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-3 transition hover:border-primary/40"
>
<img
src={novel.coverUrl || "/default-cover.svg"}
alt={novel.title}
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{novel.title}</h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<p className="mt-1 text-xs text-primary">{chapterLabel}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">{chapterTitle}</p>
</div>
<div className="text-right text-[11px] text-muted-foreground">{updatedTime}</div>
</Link>
)
}) : (
<p className="text-sm text-muted-foreground">Chưa truyện mới cập nhật.</p>
)}
</div>
</section>
<section>
<div className="mb-4">
<h2 className="text-xl font-bold text-foreground">Bảng xếp hạng đ hot</h2>
<p className="text-sm text-muted-foreground">So sánh đ nóng theo tuần, tháng toàn thời gian.</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<RankingBoard title="Hot theo tuần" entries={weeklyRanking} emptyText="Tuần này chưa có dữ liệu nổi bật." />
<RankingBoard title="Hot theo tháng" entries={monthlyRanking} emptyText="Tháng này chưa có dữ liệu nổi bật." />
<RankingBoard title="Hot toàn thời gian" entries={allTimeRanking} emptyText="Chưa có dữ liệu toàn thời gian." />
</div>
</section>
</div>
)
}
+40 -5
View File
@@ -5,6 +5,8 @@ import { BookOpen, BookMarked, BookmarkCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context"
import { useRecommendations } from "@/lib/recommendation-context"
import { toast } from "sonner"
interface NovelDetailActionsProps {
novelId: string
@@ -15,8 +17,10 @@ interface NovelDetailActionsProps {
export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: NovelDetailActionsProps) {
const { user } = useAuth()
const { isBookmarked, toggleBookmark, getProgress } = useBookmarks()
const { isRecommended, toggleRecommendation } = useRecommendations()
const bookmarked = isBookmarked(novelId)
const recommended = isRecommended(novelId)
const progress = getProgress(novelId)
const readLink = progress?.lastChapterNumber
@@ -25,6 +29,25 @@ export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: N
? `/truyen/${novelSlug}/${firstChapterNumber}`
: "#"
const handleRecommend = async () => {
try {
const result = await toggleRecommendation(novelId)
if (result.status === "removed") {
toast.success("Đã bỏ đề cử")
return
}
if (result.status === "exists") {
toast.info("Bạn đã đề cử truyện này rồi")
return
}
toast.success("Đã đề cử truyện")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể đề cử truyện")
}
}
return (
<div className="flex flex-wrap gap-3">
<Button asChild className="bg-red-600 hover:bg-red-700 text-white font-bold px-6 border-0 shadow-sm">
@@ -51,11 +74,23 @@ export function NovelDetailActions({ novelId, novelSlug, firstChapterNumber }: N
</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>
{user ? (
<Button
variant="outline"
className="font-semibold px-4 border-transparent bg-[#334155] hover:bg-[#475569] text-white"
onClick={handleRecommend}
>
<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>
{recommended ? "Bỏ đề cử" : "Đề cử"}
</Button>
) : (
<Button variant="outline" asChild className="font-semibold px-4 border-transparent bg-[#334155] hover:bg-[#475569] text-white">
<Link href="/dang-nhap">
<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ử
</Link>
</Button>
)}
</div>
)
}
+262 -62
View File
@@ -1,16 +1,102 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { BookOpen, BookMarked, Trash2 } from "lucide-react"
import { BookOpen, BookMarked, Star, Trash2, CheckCircle2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context"
import { useBookmarks } from "@/lib/bookmark-context"
import { useRecommendations } from "@/lib/recommendation-context"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
import { cn } from "@/lib/utils"
type Tab = "dang-doc" | "danh-dau" | "da-doc" | "de-cu"
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "dang-doc", label: "Đang đọc", icon: <BookOpen className="h-4 w-4" /> },
{ id: "danh-dau", label: "Đánh dấu", icon: <BookMarked className="h-4 w-4" /> },
{ id: "da-doc", label: "Đã đọc", icon: <CheckCircle2 className="h-4 w-4" /> },
{ id: "de-cu", label: "Đề cử", icon: <Star className="h-4 w-4" /> },
]
function NovelRow({
coverUrl,
title,
slug,
authorName,
status,
extra,
readLink,
readLabel,
onRemove,
removeLabel,
}: {
coverUrl?: string
title: string
slug: string
authorName: string
status: string
extra?: React.ReactNode
readLink: string
readLabel: string
onRemove: () => void
removeLabel: string
}) {
return (
<div className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/20">
<Link href={`/truyen/${slug}`}>
<img
src={coverUrl || "/default-cover.svg"}
alt={title}
className="h-16 w-12 shrink-0 rounded-md bg-muted object-contain hover:opacity-90"
/>
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link
title={title}
href={`/truyen/${slug}`}
className="truncate text-sm font-semibold text-foreground hover:text-primary transition-colors"
>
{title}
</Link>
<p className="text-xs text-muted-foreground">{authorName}</p>
<span className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(status)}`}>
{status}
</span>
{extra}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" asChild>
<Link href={readLink}>{readLabel}</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={onRemove}
aria-label={removeLabel}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<BookOpen className="mb-3 h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
)
}
export default function BookshelfPage() {
const { user } = useAuth()
const { bookmarks, toggleBookmark } = useBookmarks()
const { recommendations, toggleRecommendation } = useRecommendations()
const [activeTab, setActiveTab] = useState<Tab>("dang-doc")
if (!user) {
return (
@@ -25,76 +111,190 @@ export default function BookshelfPage() {
)
}
const bookmarkedNovels = bookmarks.filter(b => b.novel).map(b => ({
novel: b.novel as any,
bookmark: b
}))
const withNovel = bookmarks.filter((b) => b.novel).map((b) => ({ novel: b.novel as any, bookmark: b }))
const dangDocList = withNovel.filter(({ bookmark, novel }) => bookmark.lastChapterNumber && bookmark.lastChapterNumber < (novel.totalChapters ?? Infinity))
const daDanhDauList = withNovel
const daDocList = withNovel.filter(({ bookmark, novel }) => bookmark.lastChapterNumber && bookmark.lastChapterNumber >= (novel.totalChapters ?? 0) && novel.totalChapters > 0)
const deCuList = recommendations.filter((r) => r.novel)
const counts: Record<Tab, number> = {
"dang-doc": dangDocList.length,
"danh-dau": daDanhDauList.length,
"da-doc": daDocList.length,
"de-cu": deCuList.length,
}
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold text-foreground">Tủ Sách</h1>
{bookmarkedNovels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookOpen className="mb-3 h-10 w-10 text-muted-foreground/40" />
<p className="text-lg font-medium text-muted-foreground">Chưa truyện nào</p>
<p className="text-sm text-muted-foreground">Hãy thêm truyện yêu thích vào tủ sách của bạn.</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/">Khám phá truyện</Link>
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{bookmarkedNovels.map(({ novel, bookmark }) => {
const readLink = bookmark.lastChapterNumber
? `/truyen/${novel.slug}/${bookmark.lastChapterNumber}`
: `/truyen/${novel.slug}/1`
return (
<div
key={novel.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/20"
<div className="flex gap-6">
{/* Sidebar */}
<aside className="hidden w-44 shrink-0 sm:block">
<nav className="flex flex-col gap-1">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors text-left",
activeTab === tab.id
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Link href={`/truyen/${novel.slug}`}>
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-16 w-12 shrink-0 rounded-md bg-muted object-contain hover:opacity-90" />
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link title={novel.title} href={`/truyen/${novel.slug}`} className="truncate text-sm font-semibold text-foreground hover:text-primary transition-colors">
{novel.title}
</Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
{novel.status}
</span>
</div>
{bookmark.lastChapterNumber && (
<p className="text-xs text-muted-foreground">
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" asChild>
<Link href={readLink}>
{bookmark.lastChapterNumber ? "Đọc tiếp" : "Đọc"}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => toggleBookmark(novel.id)}
aria-label="Xóa khỏi tủ sách"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{tab.icon}
<span className="flex-1">{tab.label}</span>
{counts[tab.id] > 0 && (
<span className={cn(
"rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none",
activeTab === tab.id ? "bg-primary/20 text-primary" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{counts[tab.id]}
</span>
)}
</button>
))}
</nav>
</aside>
{/* Mobile tab bar */}
<div className="mb-4 flex w-full gap-1 sm:hidden">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-2 text-[11px] font-medium transition-colors",
activeTab === tab.id
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted"
)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{activeTab === "dang-doc" && (
dangDocList.length === 0 ? (
<EmptyState message="Bạn chưa bắt đầu đọc truyện nào." />
) : (
<div className="flex flex-col gap-3">
{dangDocList.map(({ novel, bookmark }) => (
<NovelRow
key={novel.id}
coverUrl={novel.coverUrl}
title={novel.title}
slug={novel.slug}
authorName={novel.authorName}
status={novel.status}
readLink={`/truyen/${novel.slug}/${bookmark.lastChapterNumber}`}
readLabel="Đọc tiếp"
extra={
<p className="text-xs text-muted-foreground">
Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
</p>
}
onRemove={() => toggleBookmark(novel.id)}
removeLabel="Xóa khỏi tủ sách"
/>
))}
</div>
)
})}
)}
{activeTab === "danh-dau" && (
daDanhDauList.length === 0 ? (
<EmptyState message="Bạn chưa đánh dấu truyện nào." />
) : (
<div className="flex flex-col gap-3">
{daDanhDauList.map(({ novel, bookmark }) => {
const readLink = bookmark.lastChapterNumber
? `/truyen/${novel.slug}/${bookmark.lastChapterNumber}`
: `/truyen/${novel.slug}/1`
return (
<NovelRow
key={novel.id}
coverUrl={novel.coverUrl}
title={novel.title}
slug={novel.slug}
authorName={novel.authorName}
status={novel.status}
readLink={readLink}
readLabel={bookmark.lastChapterNumber ? "Đọc tiếp" : "Đọc"}
extra={
bookmark.lastChapterNumber ? (
<p className="text-xs text-muted-foreground">
Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
</p>
) : undefined
}
onRemove={() => toggleBookmark(novel.id)}
removeLabel="Xóa khỏi tủ sách"
/>
)
})}
</div>
)
)}
{activeTab === "da-doc" && (
daDocList.length === 0 ? (
<EmptyState message="Bạn chưa hoàn thành truyện nào." />
) : (
<div className="flex flex-col gap-3">
{daDocList.map(({ novel, bookmark }) => (
<NovelRow
key={novel.id}
coverUrl={novel.coverUrl}
title={novel.title}
slug={novel.slug}
authorName={novel.authorName}
status={novel.status}
readLink={`/truyen/${novel.slug}/${bookmark.lastChapterNumber}`}
readLabel="Đọc lại"
extra={
<p className="text-xs text-muted-foreground">
Đã đc {novel.totalChapters} chương
</p>
}
onRemove={() => toggleBookmark(novel.id)}
removeLabel="Xóa khỏi tủ sách"
/>
))}
</div>
)
)}
{activeTab === "de-cu" && (
deCuList.length === 0 ? (
<EmptyState message="Bạn chưa đề cử truyện nào." />
) : (
<div className="flex flex-col gap-3">
{deCuList.map((item) => (
<NovelRow
key={item.id}
coverUrl={item.novel.coverUrl ?? undefined}
title={item.novel.title}
slug={item.novel.slug}
authorName={item.novel.authorName}
status={item.novel.status}
readLink={`/truyen/${item.novel.slug}`}
readLabel="Xem truyện"
onRemove={() => void toggleRecommendation(item.novelId)}
removeLabel="Bỏ đề cử"
/>
))}
</div>
)
)}
</div>
)}
</div>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Star } from "lucide-react"
import { Button } from "@/components/ui/button"
type RecommendationNovel = {
id: string
slug: string
title: string
authorName: string
coverUrl: string | null
rating: number
}
type TopRecommendationItem = {
novel: RecommendationNovel
recommendCount: number
}
type EditorRecommendationItem = {
novel: RecommendationNovel
editorName: string
recommendCount: number
}
interface HomeRecommendationBoardsProps {
topItems: TopRecommendationItem[]
editorItems: EditorRecommendationItem[]
pageSize?: number
}
function BoardHeader({
title,
page,
totalPages,
onPrev,
onNext,
}: {
title: string
page: number
totalPages: number
onPrev: () => void
onNext: () => void
}) {
return (
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-foreground">{title}</h2>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onPrev}
disabled={totalPages <= 1 || page === 0}
aria-label="Trang trước"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onNext}
disabled={totalPages <= 1 || page >= totalPages - 1}
aria-label="Trang sau"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
export function HomeRecommendationBoards({ topItems, editorItems, pageSize = 5 }: HomeRecommendationBoardsProps) {
const [topPage, setTopPage] = useState(0)
const [editorPage, setEditorPage] = useState(0)
const sortedTopItems = useMemo(() => {
return [...topItems].sort((a, b) => b.recommendCount - a.recommendCount)
}, [topItems])
const sortedEditorItems = useMemo(() => {
return [...editorItems].sort((a, b) => {
if (b.recommendCount !== a.recommendCount) return b.recommendCount - a.recommendCount
return a.editorName.localeCompare(b.editorName, "vi")
})
}, [editorItems])
const totalTopPages = Math.max(1, Math.ceil(sortedTopItems.length / pageSize))
const totalEditorPages = Math.max(1, Math.ceil(sortedEditorItems.length / pageSize))
const topPageStart = topPage * pageSize
const editorPageStart = editorPage * pageSize
const visibleTopItems = sortedTopItems.slice(topPageStart, topPageStart + pageSize)
const visibleEditorItems = sortedEditorItems.slice(editorPageStart, editorPageStart + pageSize)
return (
<section className="grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-card/70 p-4">
<BoardHeader
title="Top đề cử"
page={topPage}
totalPages={totalTopPages}
onPrev={() => setTopPage((prev) => Math.max(0, prev - 1))}
onNext={() => setTopPage((prev) => Math.min(totalTopPages - 1, prev + 1))}
/>
<div className="space-y-2">
{visibleTopItems.length > 0 ? visibleTopItems.map((item, index) => (
<Link
key={item.novel.id}
href={`/truyen/${item.novel.slug}`}
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-2.5 transition hover:border-primary/40"
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{topPageStart + index + 1}
</span>
<img
src={item.novel.coverUrl || "/default-cover.svg"}
alt={item.novel.title}
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{item.novel.title}</h3>
<p className="text-xs text-muted-foreground">{item.novel.authorName}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.recommendCount} đ cử</p>
</div>
<div className="inline-flex items-center gap-1 text-xs font-semibold text-primary">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
{item.novel.rating.toFixed(1)}
</div>
</Link>
)) : (
<p className="text-sm text-muted-foreground">Chưa truyện đ cử.</p>
)}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-card/70 p-4">
<BoardHeader
title="Biên tập viên đề cử"
page={editorPage}
totalPages={totalEditorPages}
onPrev={() => setEditorPage((prev) => Math.max(0, prev - 1))}
onNext={() => setEditorPage((prev) => Math.min(totalEditorPages - 1, prev + 1))}
/>
<div className="space-y-2">
{visibleEditorItems.length > 0 ? visibleEditorItems.map((item, index) => (
<Link
key={`${item.novel.id}-${item.editorName}-${editorPageStart + index}`}
href={`/truyen/${item.novel.slug}`}
className="group flex items-center gap-3 rounded-xl border border-border bg-background/80 p-2.5 transition hover:border-primary/40"
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{editorPageStart + index + 1}
</span>
<img
src={item.novel.coverUrl || "/default-cover.svg"}
alt={item.novel.title}
className="h-20 w-14 shrink-0 rounded-md border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary">{item.novel.title}</h3>
<p className="text-xs text-muted-foreground">{item.novel.authorName}</p>
<p className="mt-1 text-xs text-muted-foreground">Biên tập viên: {item.editorName}</p>
<p className="text-xs text-muted-foreground">{item.recommendCount} đ cử</p>
</div>
<div className="inline-flex items-center gap-1 text-xs font-semibold text-primary">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
{item.novel.rating.toFixed(1)}
</div>
</Link>
)) : (
<p className="text-sm text-muted-foreground">Chưa đ cử từ biên tập viên.</p>
)}
</div>
</div>
</section>
)
}
+54 -1
View File
@@ -68,6 +68,55 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
const [fontSize, setFontSize] = useState(18)
const [lineHeight, setLineHeight] = useState(1.8)
const [letterSpacing, setLetterSpacing] = useState(0)
const [fontFamily, setFontFamily] = useState("font-serif")
useEffect(() => {
// Dùng local storage chạy tạm thời gian đầu để khỏi giật màn hình
const savedFontSize = localStorage.getItem("reader_fontSize")
const savedLineHeight = localStorage.getItem("reader_lineHeight")
const savedLetterSpacing = localStorage.getItem("reader_letterSpacing")
const savedFontFamily = localStorage.getItem("reader_fontFamily")
if (savedFontSize) setFontSize(Number(savedFontSize))
if (savedLineHeight) setLineHeight(Number(savedLineHeight))
if (savedLetterSpacing) setLetterSpacing(Number(savedLetterSpacing))
if (savedFontFamily) setFontFamily(savedFontFamily)
// Đồng bộ Settings từ DB về (Ghi đè nếu có)
fetch("/api/user/settings")
.then(res => res.json())
.then(data => {
if (data && !data.error && data.fontSize) {
setFontSize(data.fontSize)
setLineHeight(data.lineHeight)
setLetterSpacing(data.letterSpacing)
setFontFamily(data.fontFamily)
localStorage.setItem("reader_fontSize", data.fontSize.toString())
localStorage.setItem("reader_lineHeight", data.lineHeight.toString())
localStorage.setItem("reader_letterSpacing", data.letterSpacing.toString())
localStorage.setItem("reader_fontFamily", data.fontFamily)
}
})
.catch(() => {})
}, [])
useEffect(() => {
localStorage.setItem("reader_fontSize", fontSize.toString())
localStorage.setItem("reader_lineHeight", lineHeight.toString())
localStorage.setItem("reader_letterSpacing", letterSpacing.toString())
localStorage.setItem("reader_fontFamily", fontFamily)
const timer = setTimeout(() => {
fetch("/api/user/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fontSize, lineHeight, letterSpacing, fontFamily })
}).catch(() => {})
}, 1000)
return () => clearTimeout(timer)
}, [fontSize, lineHeight, letterSpacing, fontFamily])
return (
<>
@@ -134,6 +183,7 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
fontSize={fontSize} setFontSize={setFontSize}
lineHeight={lineHeight} setLineHeight={setLineHeight}
letterSpacing={letterSpacing} setLetterSpacing={setLetterSpacing}
fontFamily={fontFamily} setFontFamily={setFontFamily}
/>
</PopoverContent>
</Popover>
@@ -158,10 +208,13 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
{/* Inject styles OUTSIDE the popover so it survives */}
<style>{`
.chapter-content {
.chapter-content, .chapter-content p {
font-size: ${fontSize}px !important;
line-height: ${lineHeight} !important;
letter-spacing: ${letterSpacing}px !important;
font-family: ${fontFamily === 'font-serif' ? 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif' :
fontFamily === 'font-sans' ? 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif' :
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'} !important;
}
`}</style>
+90 -3
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { Minus, Plus, ALargeSmall, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
@@ -11,12 +11,15 @@ interface ReadingSettingsProps {
setLineHeight: (v: number) => void
letterSpacing: number
setLetterSpacing: (v: number) => void
fontFamily: string
setFontFamily: (v: string) => void
}
export function ReadingSettingsContent({
fontSize, setFontSize,
lineHeight, setLineHeight,
letterSpacing, setLetterSpacing
letterSpacing, setLetterSpacing,
fontFamily, setFontFamily
}: ReadingSettingsProps) {
return (
<>
@@ -109,6 +112,36 @@ export function ReadingSettingsContent({
</div>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-muted-foreground">Phông chữ</label>
<div className="grid grid-cols-3 gap-2">
<Button
variant={fontFamily === "font-serif" ? "default" : "outline"}
size="sm"
className="font-serif text-xs"
onClick={() => setFontFamily("font-serif")}
>
Serif
</Button>
<Button
variant={fontFamily === "font-sans" ? "default" : "outline"}
size="sm"
className="font-sans text-xs"
onClick={() => setFontFamily("font-sans")}
>
Sans
</Button>
<Button
variant={fontFamily === "font-mono" ? "default" : "outline"}
size="sm"
className="font-mono text-xs"
onClick={() => setFontFamily("font-mono")}
>
Mono
</Button>
</div>
</div>
<div className="pt-2">
<Button
variant="ghost"
@@ -118,6 +151,7 @@ export function ReadingSettingsContent({
setFontSize(18)
setLineHeight(1.8)
setLetterSpacing(0)
setFontFamily("font-serif")
}}
>
<RotateCcw className="mr-2 h-3 w-3" />
@@ -133,6 +167,55 @@ export function ReadingSettings() {
const [fontSize, setFontSize] = useState(18)
const [lineHeight, setLineHeight] = useState(1.8)
const [letterSpacing, setLetterSpacing] = useState(0)
const [fontFamily, setFontFamily] = useState("font-serif")
useEffect(() => {
// Dùng local storage chạy tạm thời gian đầu để khỏi giật màn hình
const savedFontSize = localStorage.getItem("reader_fontSize")
const savedLineHeight = localStorage.getItem("reader_lineHeight")
const savedLetterSpacing = localStorage.getItem("reader_letterSpacing")
const savedFontFamily = localStorage.getItem("reader_fontFamily")
if (savedFontSize) setFontSize(Number(savedFontSize))
if (savedLineHeight) setLineHeight(Number(savedLineHeight))
if (savedLetterSpacing) setLetterSpacing(Number(savedLetterSpacing))
if (savedFontFamily) setFontFamily(savedFontFamily)
// Đồng bộ Settings từ DB về (Ghi đè nếu có)
fetch("/api/user/settings")
.then(res => res.json())
.then(data => {
if (data && !data.error && data.fontSize) {
setFontSize(data.fontSize)
setLineHeight(data.lineHeight)
setLetterSpacing(data.letterSpacing)
setFontFamily(data.fontFamily)
localStorage.setItem("reader_fontSize", data.fontSize.toString())
localStorage.setItem("reader_lineHeight", data.lineHeight.toString())
localStorage.setItem("reader_letterSpacing", data.letterSpacing.toString())
localStorage.setItem("reader_fontFamily", data.fontFamily)
}
})
.catch(() => {})
}, [])
useEffect(() => {
localStorage.setItem("reader_fontSize", fontSize.toString())
localStorage.setItem("reader_lineHeight", lineHeight.toString())
localStorage.setItem("reader_letterSpacing", letterSpacing.toString())
localStorage.setItem("reader_fontFamily", fontFamily)
const timer = setTimeout(() => {
fetch("/api/user/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fontSize, lineHeight, letterSpacing, fontFamily })
}).catch(() => {})
}, 1000)
return () => clearTimeout(timer)
}, [fontSize, lineHeight, letterSpacing, fontFamily])
return (
<>
@@ -148,15 +231,19 @@ export function ReadingSettings() {
fontSize={fontSize} setFontSize={setFontSize}
lineHeight={lineHeight} setLineHeight={setLineHeight}
letterSpacing={letterSpacing} setLetterSpacing={setLetterSpacing}
fontFamily={fontFamily} setFontFamily={setFontFamily}
/>
</PopoverContent>
</Popover>
{/* Inject styles */}
<style>{`
.chapter-content {
.chapter-content, .chapter-content p {
font-size: ${fontSize}px !important;
line-height: ${lineHeight} !important;
letter-spacing: ${letterSpacing}px !important;
font-family: ${fontFamily === 'font-serif' ? 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif' :
fontFamily === 'font-sans' ? 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif' :
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'} !important;
}
`}</style>
</>
+40 -24
View File
@@ -7,8 +7,8 @@ import { useAuth } from "./auth-context"
interface BookmarkContextType {
bookmarks: Bookmark[]
isBookmarked: (novelId: string) => boolean
toggleBookmark: (novelId: string) => void
updateProgress: (novelId: string, chapterId: string, chapterNumber: number) => void
toggleBookmark: (novelId: string) => Promise<void>
updateProgress: (novelId: string, chapterId: string, chapterNumber: number) => Promise<void>
getProgress: (novelId: string) => Bookmark | undefined
}
@@ -18,27 +18,27 @@ export function BookmarkProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
useEffect(() => {
let mounted = true
const fetchBookmarks = async () => {
if (!user) {
setBookmarks([])
return
}
try {
const res = await fetch("/api/user/bookmarks")
if (res.ok) {
const data = await res.json()
if (mounted) setBookmarks(data)
}
} catch (e) {
console.error("Failed to fetch bookmarks", e)
}
const fetchBookmarks = useCallback(async () => {
if (!user) {
setBookmarks([])
return
}
try {
const res = await fetch("/api/user/bookmarks")
if (!res.ok) return
const data = await res.json()
setBookmarks(Array.isArray(data) ? data : [])
} catch (e) {
console.error("Failed to fetch bookmarks", e)
}
fetchBookmarks()
return () => { mounted = false }
}, [user])
useEffect(() => {
fetchBookmarks()
}, [fetchBookmarks])
const toggleBookmark = useCallback(async (novelId: string) => {
if (!user) return
@@ -52,14 +52,22 @@ export function BookmarkProvider({ children }: { children: ReactNode }) {
})
try {
await fetch("/api/user/bookmarks", {
const res = await fetch("/api/user/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "toggle", novelId })
})
if (!res.ok) {
throw new Error("Không thể cập nhật đánh dấu")
}
await fetchBookmarks()
} catch (e) {
console.error(e)
await fetchBookmarks()
}
}, [user])
}, [fetchBookmarks, user])
const updateProgress = useCallback(async (novelId: string, chapterId: string, chapterNumber: number) => {
if (!user) return
@@ -74,14 +82,22 @@ export function BookmarkProvider({ children }: { children: ReactNode }) {
})
try {
await fetch("/api/user/bookmarks", {
const res = await fetch("/api/user/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "updateProgress", novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber })
})
if (!res.ok) {
throw new Error("Không thể cập nhật tiến độ")
}
await fetchBookmarks()
} catch (e) {
console.error(e)
await fetchBookmarks()
}
}, [user])
}, [fetchBookmarks, user])
const getProgress = useCallback((novelId: string) => {
return bookmarks.find((b) => b.novelId === novelId)
+25
View File
@@ -0,0 +1,25 @@
export const MOD_AI_PREFILL_STORAGE_KEY = "mod:ai-tool:novel-prefill"
export const MOD_AI_MODEL_STORAGE_KEY = "mod:ai-tool:model"
export const MOD_AI_WEB_DEFAULT_MODEL = "gpt-4o-mini-search-preview"
export const MOD_AI_WEB_MODEL_OPTIONS = [
{
value: "gpt-4o-mini-search-preview",
label: "gpt-4o-mini-search-preview (nhanh)",
},
{
value: "gpt-4o-search-preview",
label: "gpt-4o-search-preview (chat luong cao)",
},
] as const
export type AINovelPrefillPayload = {
title?: string
originalTitle?: string
authorName?: string
originalAuthorName?: string
description?: string
coverUrl?: string
status?: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested?: string[]
}
+1
View File
@@ -25,5 +25,6 @@ const ChapterSchema: Schema = new Schema({
})
ChapterSchema.index({ novelId: 1, number: 1 }, { unique: true })
ChapterSchema.index({ createdAt: -1, novelId: 1 })
export const Chapter = mongoose.models.Chapter || mongoose.model<IChapter>("Chapter", ChapterSchema)
+25
View File
@@ -0,0 +1,25 @@
import mongoose, { Schema, Document } from "mongoose"
export interface IEditorRecommendation extends Document {
novelId: string
editorId: string
createdAt: Date
updatedAt: Date
}
const EditorRecommendationSchema: Schema = new Schema(
{
novelId: { type: String, required: true, index: true },
editorId: { type: String, required: true, index: true },
},
{
timestamps: true,
}
)
EditorRecommendationSchema.index({ novelId: 1, editorId: 1 }, { unique: true })
EditorRecommendationSchema.index({ createdAt: -1 })
export const EditorRecommendation =
mongoose.models.EditorRecommendation ||
mongoose.model<IEditorRecommendation>("EditorRecommendation", EditorRecommendationSchema)
+25
View File
@@ -0,0 +1,25 @@
import mongoose, { Document, Schema } from "mongoose"
export interface IUserRecommendation extends Document {
userId: string
novelId: string
createdAt: Date
updatedAt: Date
}
const UserRecommendationSchema: Schema = new Schema(
{
userId: { type: String, required: true, index: true },
novelId: { type: String, required: true, index: true },
},
{
timestamps: true,
}
)
UserRecommendationSchema.index({ userId: 1, novelId: 1 }, { unique: true })
UserRecommendationSchema.index({ createdAt: -1 })
export const UserRecommendation =
mongoose.models.UserRecommendation ||
mongoose.model<IUserRecommendation>("UserRecommendation", UserRecommendationSchema)
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"
import { useAuth } from "@/lib/auth-context"
type UserRecommendedNovel = {
id: string
title: string
slug: string
authorName: string
coverUrl: string | null
status: string
totalChapters: number
}
type UserRecommendationItem = {
id: string
novelId: string
createdAt: string | null
novel: UserRecommendedNovel
}
type RecommendationContextType = {
recommendations: UserRecommendationItem[]
isRecommended: (novelId: string) => boolean
toggleRecommendation: (novelId: string) => Promise<{ status: "added" | "removed" | "exists" }>
}
const RecommendationContext = createContext<RecommendationContextType | undefined>(undefined)
export function RecommendationProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [recommendations, setRecommendations] = useState<UserRecommendationItem[]>([])
const fetchRecommendations = useCallback(async () => {
if (!user) {
setRecommendations([])
return
}
try {
const res = await fetch("/api/user/recommendations")
if (!res.ok) {
setRecommendations([])
return
}
const data = await res.json()
setRecommendations(Array.isArray(data) ? data : [])
} catch (error) {
console.error("Failed to fetch recommendations", error)
}
}, [user])
useEffect(() => {
fetchRecommendations()
}, [fetchRecommendations])
const isRecommended = useCallback(
(novelId: string) => recommendations.some((item) => item.novelId === novelId),
[recommendations]
)
const toggleRecommendation = useCallback(
async (novelId: string) => {
if (!user) throw new Error("Unauthorized")
if (!novelId) throw new Error("Missing novel id")
const existed = recommendations.some((item) => item.novelId === novelId)
if (existed) {
const res = await fetch(`/api/user/recommendations?novelId=${encodeURIComponent(novelId)}`, {
method: "DELETE",
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể bỏ đề cử")
}
await fetchRecommendations()
return { status: "removed" as const }
}
const res = await fetch("/api/user/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
if (res.status === 409) {
await fetchRecommendations()
return { status: "exists" as const }
}
throw new Error(data.error || "Không thể đề cử truyện")
}
await fetchRecommendations()
return { status: "added" as const }
},
[fetchRecommendations, recommendations, user]
)
return (
<RecommendationContext.Provider
value={{
recommendations,
isRecommended,
toggleRecommendation,
}}
>
{children}
</RecommendationContext.Provider>
)
}
export function useRecommendations() {
const context = useContext(RecommendationContext)
if (!context) throw new Error("useRecommendations must be used within RecommendationProvider")
return context
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+12
View File
@@ -48,6 +48,7 @@ model User {
comments Comment[]
bookmarks Bookmark[]
novels Novel[] @relation("AuthorNovels") // Truyện do người này (MOD/ADMIN) đăng
settings UserSetting?
}
model VerificationToken {
@@ -58,6 +59,17 @@ model VerificationToken {
@@unique([identifier, token])
}
model UserSetting {
id String @id @default(cuid())
userId String @unique
fontSize Float @default(18)
lineHeight Float @default(1.8)
letterSpacing Float @default(0)
fontFamily String @default("font-serif")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// Custom App Schema
enum Role {
USER
+68
View File
@@ -0,0 +1,68 @@
require('dotenv').config({ path: '.env' });
async function fetchJsonWithTimeout(url, init, timeoutMs = 25000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${text.slice(0, 500)}`);
}
return await res.json();
} finally {
clearTimeout(timeout);
}
}
const prompt = "Please reply with { \"ready\": true } in JSON";
async function tryGoogle() {
const apiKey = process.env.GOOGLE_AI_KEY;
if (!apiKey) return console.log("Google: No key");
try {
const data = await fetchJsonWithTimeout(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
tools: [{ googleSearch: {} }],
generationConfig: { temperature: 0.2, maxOutputTokens: 1400 },
}),
}
);
console.log("Google Success:", JSON.stringify(data).slice(0, 100));
} catch(e) { console.log("Google Error:", e.stack || e.message); }
}
async function tryDeepSeek() {
const apiKey = process.env.DEEKSEEK_KEY || process.env.DEEPSEEK_KEY;
if (!apiKey) return console.log("DeepSeek: No key");
try {
const data = await fetchJsonWithTimeout(
"https://api.deepseek.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
temperature: 0.2,
max_tokens: 500,
messages: [{ role: "user", content: prompt }],
}),
}
);
console.log("DeepSeek Success:", JSON.stringify(data).slice(0,100));
} catch(e) { console.log("DeepSeek Error:", e.stack || e.message); }
}
async function run() {
await tryGoogle();
await tryDeepSeek();
}
run();
+17
View File
@@ -0,0 +1,17 @@
const { MongoClient } = require('mongodb');
async function check() {
const uri = process.env.MONGODB_URI || "mongodb://localhost:27017/reader";
const client = new MongoClient(uri);
try {
await client.connect();
const db = client.db();
// Just find the latest 5 chapters inserted
const docs = await db.collection('Chapter').find({}).sort({ _id: -1 }).limit(10).toArray();
console.log("Recent chapters:");
docs.forEach(d => console.log(d.novelId, d.number, d.title));
} finally {
await client.close();
}
}
check();
+30
View File
@@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const isMissingFilter = true
const baseWhereOptions: any[] = [{}]
if (isMissingFilter) {
baseWhereOptions.push({
OR: [
{ authorName: { in: ["", "Chưa rõ"] } },
{ description: "" },
{ description: { in: ["Chưa có giới thiệu", "Không có giới thiệu", "chưa có giới thiệu", "không có", "chưa rõ", "đang cập nhật"] } }
],
})
}
const rows = await prisma.novel.findMany({
where: {
AND: baseWhereOptions,
},
select: { id: true },
orderBy: [{ updatedAt: "desc" }],
take: 25,
skip: 0,
})
console.log("Returned rows length:", rows.length)
}
main().finally(() => prisma.$disconnect())
+1 -1
View File
File diff suppressed because one or more lines are too long