"use client" 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" import { Textarea } from "@/components/ui/textarea" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react" import { toast } from "sonner" import { appendEpubSplitFormFields, DEFAULT_EPUB_CHAPTER_TAG, EPUB_HTML_TAG_PRESETS, type EpubSplitMode, } from "@/lib/epub-split" 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 _id: string number: number volumeNumber?: number | null volumeTitle?: string | null volumeChapterNumber?: number | null title: string views: number createdAt: string } interface EpubPreviewData { preview: true fileName: string splitMode: EpubSplitMode detectedStructureType: "light_novel" | "standard" parserInfo?: { splitMode: string chapterRegexUsed?: string chapterTagUsed?: 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) } if (currentPage <= 3) { return [1, 2, 3, 4, '...', totalPages] } if (currentPage >= totalPages - 2) { return [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages] } return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages] } function ChapterManager() { const searchParams = useSearchParams() const novelId = searchParams.get("novelId") const [chapters, setChapters] = useState([]) const [loading, setLoading] = useState(true) const [openAdd, setOpenAdd] = useState(false) const [submitting, setSubmitting] = useState(false) // Pagination states const [currentPage, setCurrentPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [totalChapters, setTotalChapters] = useState(0) // Optimization states const [openOptimize, setOpenOptimize] = useState(false) const [previewMode, setPreviewMode] = useState(false) const [optimizing, setOptimizing] = useState(false) const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false) const [optRemovePrefix, setOptRemovePrefix] = useState(true) const [optRenumber, setOptRenumber] = useState(true) const [optNormalizeTitles, setOptNormalizeTitles] = useState(true) const [optNormalizeGenericOnly, setOptNormalizeGenericOnly] = useState(true) const [optimizedChapters, setOptimizedChapters] = useState([]) const [optimizeSourceChapters, setOptimizeSourceChapters] = useState([]) // Edit states const [openEdit, setOpenEdit] = useState(false) const [editingChapterId, setEditingChapterId] = useState(null) const [loadingEditData, setLoadingEditData] = useState(false) // Delete states const [openDelete, setOpenDelete] = useState(false) const [deletingChapterId, setDeletingChapterId] = useState(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("") const [title, setTitle] = useState("") const [content, setContent] = useState("") // Multi-upload states const fileInputRef = useRef(null) const [uploadingMulti, setUploadingMulti] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) const [totalUpload, setTotalUpload] = useState(0) // EPUB append states const [openEpubAppend, setOpenEpubAppend] = useState(false) const [epubFile, setEpubFile] = useState(null) const epubInputRef = useRef(null) const [epubSplitMode, setEpubSplitMode] = useState("regex") const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi") const [epubCustomRegex, setEpubCustomRegex] = useState("") const [epubTagPreset, setEpubTagPreset] = useState("a") const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG) const [appendingEpub, setAppendingEpub] = useState(false) const [epubPreviewData, setEpubPreviewData] = useState(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 getEpubSourceTag = () => { if (epubTagPreset === "custom") { return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG } return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG } 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) appendEpubSplitFormFields(formData, epubSplitMode, { chapterRegex: getEpubSourceRegex(), chapterTag: getEpubSourceTag(), }) 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 || result.data.detail || "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) appendEpubSplitFormFields(formData, epubSplitMode, { chapterRegex: getEpubSourceRegex(), chapterTag: getEpubSourceTag(), }) 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 || result.data.detail || "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 || data.detail || "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) try { const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${pageToFetch}&limit=50`) if (!res.ok) throw new Error("Lỗi fetch") const data = await res.json() setChapters(data.chapters) setTotalPages(data.totalPages) setCurrentPage(data.currentPage) setTotalChapters(data.totalChapters) if (data.chapters.length > 0) { setNumber((data.chapters[data.chapters.length - 1].number + 1).toString()) } else { setNumber("1") } } catch { toast.error("Không tải được danh sách chương") } finally { setLoading(false) } } useEffect(() => { if (novelId) { fetchChapters(currentPage) } }, [novelId, currentPage]) const fetchAllChaptersForOptimize = async (): Promise => { if (!novelId) return [] const limit = 100 let page = 1 let total = 1 let expectedTotal = 0 const all: Chapter[] = [] while (page <= total) { const res = await fetch(`/api/mod/chuong?novelId=${novelId}&page=${page}&limit=${limit}`) if (!res.ok) { throw new Error("Không thể tải toàn bộ chương để tối ưu") } const data = await res.json() if (page === 1) { expectedTotal = Number(data.totalChapters || 0) } all.push(...(data.chapters || [])) total = data.totalPages || 1 page++ } if (expectedTotal > 0 && all.length < expectedTotal) { throw new Error(`Chỉ tải được ${all.length}/${expectedTotal} chương. Vui lòng thử lại để tối ưu toàn bộ truyện.`) } return all.sort((a, b) => a.number - b.number) } const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!number || !title || !content || !novelId) { toast.error("Vui lòng điền đầy đủ") return } setSubmitting(true) try { const res = await fetch("/api/mod/chuong", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ novelId, number: parseInt(number), title, content, }), }) const resData = await res.json() if (!res.ok) throw new Error(resData.error || resData.detail || "Thêm mới thất bại") toast.success("Đã đăng chương mới thành công!") setOpenAdd(false) setTitle("") setContent("") setNumber((parseInt(number) + 1).toString()) fetchChapters() } catch (error: any) { toast.error(error.message) } finally { setSubmitting(false) } } const handleMultiFileUpload = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) if (files.length === 0 || !novelId) return setUploadingMulti(true) setTotalUpload(files.length) setUploadProgress(0) // Sort files by name to ensure order (e.g. Chapter 1, Chapter 2) files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })) try { let currentNumber = parseInt(number) || 1 for (let i = 0; i < files.length; i++) { const file = files[i] let content = "" if (file.name.endsWith(".txt")) { content = await file.text() } else if (file.name.endsWith(".docx")) { const arrayBuffer = await file.arrayBuffer() const result = await mammoth.extractRawText({ arrayBuffer }) content = result.value } else { continue } if (!content.trim()) continue // Bỏ qua file rỗng let fileTitle = file.name.replace(/\.[^/.]+$/, "") // Loại bỏ "Chương X: " khỏi file title nếu cần thiết let cleanedTitle = fileTitle.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "") if (!cleanedTitle) cleanedTitle = fileTitle const res = await fetch("/api/mod/chuong", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ novelId, number: currentNumber, title: cleanedTitle, content }), }) if (!res.ok) { const data = await res.json() throw new Error(data.error || `Lỗi khi lưu file ${file.name}`) } currentNumber++ setUploadProgress(i + 1) } toast.success(`Đã tải lên thành công ${files.length} chương!`) fetchChapters() } catch (error: any) { toast.error(error.message) } finally { setUploadingMulti(false) if (fileInputRef.current) fileInputRef.current.value = "" } } const handlePreviewOptimize = async () => { if (!novelId) return if (!optRemovePrefix && !optRenumber && !optNormalizeTitles) { toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa") return } setLoadingOptimizeSource(true) try { const allChapters = await fetchAllChaptersForOptimize() if (allChapters.length === 0) { toast.info("Truyện này chưa có chương để tối ưu") return } setOptimizeSourceChapters(allChapters) let newChapters = [...allChapters] if (optNormalizeTitles) { const previewRes = await fetch("/api/mod/chuong/normalize-titles/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ novelId, overwriteGenericOnly: optNormalizeGenericOnly, }), }) const previewData = await previewRes.json().catch(() => ({})) if (!previewRes.ok) { throw new Error(previewData.detail || previewData.error || "Không thể tạo gợi ý tiêu đề chương") } const titleById = new Map( (previewData.items || []).map((item: { id: string; suggestedTitle: string }) => [ item.id, item.suggestedTitle, ]), ) newChapters = newChapters.map((ch) => { const key = ch._id || ch.id const suggested = key ? titleById.get(key) : undefined return suggested ? { ...ch, title: suggested } : ch }) const changeCount = Number(previewData.changeCount || 0) if (changeCount === 0) { toast.info("Không tìm thấy tiêu đề chương nào cần chuẩn hóa theo bộ lọc hiện tại") } } if (optRenumber) { newChapters.sort((a, b) => a.number - b.number) newChapters = newChapters.map((ch, idx) => ({ ...ch, number: idx + 1 })) } if (optRemovePrefix) { newChapters = newChapters.map((ch) => { let newTitle = ch.title.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "") if (!newTitle) newTitle = `Chương ${ch.number}` return { ...ch, title: newTitle } }) } setOptimizedChapters(newChapters) setPreviewMode(true) toast.success(`Đã tạo xem trước cho toàn bộ ${newChapters.length} chương`) } catch (error: any) { toast.error(error.message || "Không thể tạo bản xem trước") } finally { setLoadingOptimizeSource(false) } } const handleApplyOptimize = async () => { if (optimizedChapters.length === 0) return setOptimizing(true) try { const sourceById = new Map( optimizeSourceChapters .map((ch) => { const key = ch._id || ch.id return key ? [key, ch] : null }) .filter((row): row is [string, Chapter] => !!row) ) const updates = optimizedChapters .filter((ch) => { const key = ch._id || ch.id if (!key) return false const old = sourceById.get(key) return !!old && (old.number !== ch.number || old.title !== ch.title) }) .map((ch) => ({ id: ch._id || ch.id, title: ch.title, number: ch.number })) .filter((item): item is { id: string; title: string; number: number } => !!item.id) if (updates.length === 0) { toast.info("Không có thay đổi nào cần lưu") setOptimizing(false) return } const res = await fetch("/api/mod/chuong/optimize", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ novelId, updates }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || "Lỗi tối ưu hóa") toast.success(`Đã tối ưu ${data.modifiedCount} chương trên toàn bộ truyện!`) setOpenOptimize(false) setPreviewMode(false) setOptimizedChapters([]) setOptimizeSourceChapters([]) fetchChapters(currentPage) } catch (error: any) { toast.error(error.message) } finally { setOptimizing(false) } } // handleOpenEdit has been removed because edit is now via dedicated page // handleDelete remains the same const handleDelete = async () => { if (!deletingChapterId || !novelId) return setSubmitting(true) try { const res = await fetch(`/api/mod/chuong?id=${deletingChapterId}&novelId=${novelId}`, { method: "DELETE", }) if (!res.ok) { const data = await res.json() throw new Error(data.error || data.detail || "Xóa thất bại") } toast.success("Đã xóa chương thành công") setOpenDelete(false) fetchChapters() } catch (error: any) { toast.error(error.message) } finally { setSubmitting(false) } } if (!novelId) { return (
Vui lòng chọn một truyện từ mục Quản lý truyện để xem danh sách chương.
) } const optimizeSourceMap = new Map(optimizeSourceChapters.map((source) => [source._id, source])) return (

Quản lý chương

{ 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" /> { setOpenEpubAppend(val) if (!val) { setEpubPreviewData(null) if (epubInputRef.current) epubInputRef.current.value = "" } }}> Bổ sung chương từ file EPUB Đọc và trích xuất hàng loạt chương từ file EPUB vào truyện này.

Chế độ ghi đè thông minh (Smart Merge Overwrite)

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ị Ghi đè bằng nội dung lấy từ EPUB để đảm bảo độ chính xác.

Giải Thuật Tách Chương

{ setEpubSplitMode(v) setEpubPreviewData(null) }} className="flex flex-wrap items-center gap-4">
{epubSplitMode === "tag" && (
{epubTagPreset === "custom" && (
{ setEpubCustomTag(e.target.value) setEpubPreviewData(null) }} placeholder="a" />
)}
)} {epubSplitMode === "regex" && (
{epubRegexPreset === "custom" && (
{ setEpubCustomRegex(e.target.value) setEpubPreviewData(null) }} placeholder="Vd: /^(Chương|Hồi) \d+/gm" />
)}
)}
{epubPreviewData && epubPreviewData.parserInfo && (

Kết quả trích xuất dự kiến (30 mục đầu)

Flow file HTML: {epubPreviewData.parserInfo.sourceSections}

Phát hiện: {epubPreviewData.parserInfo.chaptersDetected} chương

{epubPreviewData.parserInfo.chapterTagUsed && (

Thẻ HTML: <{epubPreviewData.parserInfo.chapterTagUsed}>

)}
{epubPreviewData.chaptersPreview.length === 0 ? (
Không tách được chương nào. Xem lại chế độ tách (TOC / Regex / Thẻ HTML).
) : (
{epubPreviewData.chaptersPreview.map((chapter) => (
Ch. {chapter.number}

{chapter.title} {chapter.isPlaceholder && ( Lỗi tách chờ XL )}

{chapter.excerpt}

))}
)}
)}
{!epubPreviewData ? ( ) : ( )}
Đăng Chương Mới Thêm nội dung một chương truyện để gửi đến độc giả.
setNumber(e.target.value)} required />
setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />