Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -9,3 +9,4 @@ node_modules/
|
||||
.env*.local
|
||||
.DS_Store
|
||||
.env
|
||||
test-ebook/
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+121
-60
@@ -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,10 +814,11 @@ 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) {
|
||||
if (currentNumber > 0) {
|
||||
for (let missing = currentNumber + 1; missing < detectedNumber; missing++) {
|
||||
insertedCount += 1
|
||||
normalized.push({
|
||||
@@ -795,6 +832,7 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
|
||||
isPlaceholder: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
detectedNumberAssignments += 1
|
||||
currentNumber = detectedNumber
|
||||
@@ -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,21 +1127,30 @@ export async function POST(req: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
let targetNovelId = ""
|
||||
let responseStatus = 201
|
||||
let replaced = false
|
||||
let isAppending = !!appendTargetNovelId
|
||||
let finalCoverUrl: string | null = null
|
||||
|
||||
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
|
||||
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`,
|
||||
error: `Truyện "${duplicatedNovel.title}" đã tồn tại`,
|
||||
canReplace: canReplaceDuplicated,
|
||||
existingNovel: {
|
||||
id: duplicatedNovel.id,
|
||||
title: duplicatedNovel.title,
|
||||
slug: duplicatedNovel.slug,
|
||||
},
|
||||
existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug },
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
@@ -1109,16 +1159,11 @@ export async function POST(req: Request) {
|
||||
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,
|
||||
},
|
||||
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,
|
||||
@@ -1128,10 +1173,8 @@ export async function POST(req: Request) {
|
||||
})
|
||||
|
||||
const coverUrl = await saveCoverBufferToR2(cover)
|
||||
|
||||
let targetNovelId = duplicatedNovel?.id || ""
|
||||
let responseStatus = 201
|
||||
let replaced = false
|
||||
finalCoverUrl = coverUrl
|
||||
targetNovelId = duplicatedNovel?.id || ""
|
||||
|
||||
if (duplicatedNovel && replaceExisting) {
|
||||
const updatedNovel = await prisma.$transaction(async (tx) => {
|
||||
@@ -1148,27 +1191,17 @@ export async function POST(req: Request) {
|
||||
...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
await tx.novelGenre.deleteMany({
|
||||
where: { novelId: duplicatedNovel.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,
|
||||
})),
|
||||
data: resolvedGenreIds.map((genreId) => ({ novelId: duplicatedNovel.id, genreId })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
return tx.novel.findUnique({ where: { id: duplicatedNovel.id } })
|
||||
})
|
||||
|
||||
if (!updatedNovel) {
|
||||
throw new Error("Không thể replace truyện đã tồn tại")
|
||||
}
|
||||
if (!updatedNovel) throw new Error("Không thể replace truyện đã tồn tại")
|
||||
|
||||
targetNovelId = updatedNovel.id
|
||||
responseStatus = 200
|
||||
@@ -1178,12 +1211,9 @@ export async function POST(req: Request) {
|
||||
await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { })
|
||||
}
|
||||
} else {
|
||||
// Generate base slug
|
||||
const baseSlug = slugify(novelTitle)
|
||||
let slug = baseSlug
|
||||
let slugCounter = 1
|
||||
|
||||
// Đảm bảo slug là duy nhất
|
||||
while (await prisma.novel.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${slugCounter}`
|
||||
slugCounter++
|
||||
@@ -1200,24 +1230,23 @@ export async function POST(req: Request) {
|
||||
uploaderId: session.user.id,
|
||||
totalChapters: chapters.length,
|
||||
}
|
||||
|
||||
if (resolvedGenreIds.length > 0) {
|
||||
createData.genres = {
|
||||
create: resolvedGenreIds.map((genreId) => ({
|
||||
genre: { connect: { id: genreId } },
|
||||
})),
|
||||
}
|
||||
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 })
|
||||
|
||||
const chapterDocs = chapters.map((ch: any, i: number) => ({
|
||||
let insertedCount = 0
|
||||
let updatedCount = 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,
|
||||
@@ -1227,9 +1256,41 @@ export async function POST(req: Request) {
|
||||
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 (chapterDocs.length > 0) {
|
||||
await Chapter.insertMany(chapterDocs)
|
||||
if (bulkOps.length > 0) {
|
||||
const writeResult = await Chapter.bulkWrite(bulkOps)
|
||||
insertedCount = writeResult.upsertedCount || 0
|
||||
updatedCount = writeResult.modifiedCount || 0
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
+41
-21
@@ -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.
|
||||
if (Object.keys(sharedData).length > 0) {
|
||||
await tx.novel.updateMany({
|
||||
where: { id: { in: seriesNovelIds } },
|
||||
data: {
|
||||
originalTitle,
|
||||
authorName,
|
||||
originalAuthorName,
|
||||
description,
|
||||
status,
|
||||
},
|
||||
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: {},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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
@@ -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 và 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ó 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 có 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,8 +969,9 @@ function ChapterManager() {
|
||||
) : chapters.length === 0 ? (
|
||||
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa có 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">
|
||||
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
|
||||
@@ -620,6 +996,32 @@ function ChapterManager() {
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 lý đề cử biên tập
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mỗi biên tập viên có 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 có 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ó đề 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 có quyền xóa</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-17
@@ -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 lý 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">
|
||||
|
||||
@@ -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 và tự bổ sung thông tin truyện vào form quản lý.
|
||||
</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
@@ -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>
|
||||
|
||||
+259
-125
@@ -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 } },
|
||||
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(
|
||||
{},
|
||||
{
|
||||
$group: {
|
||||
_id: "$novelId",
|
||||
chapterNumber: { $first: "$number" },
|
||||
chapterTitle: { $first: "$title" },
|
||||
chapterCreatedAt: { $first: "$createdAt" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
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,
|
||||
})
|
||||
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"
|
||||
)
|
||||
|
||||
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,65 +621,23 @@ 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>
|
||||
<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 có truyện đề cử.</p>
|
||||
)}
|
||||
</div>
|
||||
<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 và toàn thời gian.</p>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-2xl border border-border/70 bg-card/70 p-4 lg:col-span-2">
|
||||
<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">
|
||||
{recentComments.length > 0 ? recentComments.map((comment) => (
|
||||
<Link
|
||||
key={comment.id}
|
||||
href={`/truyen/${comment.novel.slug}`}
|
||||
className="group block rounded-lg border border-border bg-background/80 p-3 transition hover:border-primary/40"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{comment.user.name || "Người dùng"}</span>
|
||||
<span>{formatRelativeTime(comment.createdAt)}</span>
|
||||
<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>
|
||||
<p className="truncate text-xs font-medium text-primary">{comment.novel.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-foreground/90">{compactLine(comment.content, 120)}</p>
|
||||
</Link>
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">Chưa có bình luận mới.</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/70 bg-card/70 p-4">
|
||||
<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">
|
||||
@@ -553,19 +676,30 @@ export default async function HomePage() {
|
||||
<p className="text-sm text-muted-foreground">Chưa có 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 và 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." />
|
||||
<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">
|
||||
{recentComments.length > 0 ? recentComments.map((comment) => (
|
||||
<Link
|
||||
key={comment.id}
|
||||
href={`/truyen/${comment.novel.slug}`}
|
||||
className="group block rounded-lg border border-border bg-background/80 p-3 transition hover:border-primary/40"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{comment.user.name || "Người dùng"}</span>
|
||||
<span>{formatRelativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p className="truncate text-xs font-medium text-primary">{comment.novel.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-foreground/90">{compactLine(comment.content, 120)}</p>
|
||||
</Link>
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">Chưa có bình luận mới.</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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.")}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
+252
-52
@@ -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 có 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 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"
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
{bookmarkedNovels.map(({ novel, bookmark }) => {
|
||||
{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 (
|
||||
<div
|
||||
<NovelRow
|
||||
key={novel.id}
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/20"
|
||||
>
|
||||
<Link href={`/truyen/${novel.slug}`}>
|
||||
<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 && (
|
||||
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">
|
||||
Đang đọc: Chương {bookmark.lastChapterNumber} / {novel.totalChapters}
|
||||
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>
|
||||
</div>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 có 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ó đề cử từ biên tập viên.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
+31
-15
@@ -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 () => {
|
||||
const fetchBookmarks = useCallback(async () => {
|
||||
if (!user) {
|
||||
setBookmarks([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/user/bookmarks")
|
||||
if (res.ok) {
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json()
|
||||
if (mounted) setBookmarks(data)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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())
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user