"use client" import { useState, useEffect, useMemo, useRef } from "react" 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 { 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 title: string slug: string authorName: string status: string totalChapters: number coverUrl?: string series?: { id: string name: string slug: string } | null } interface Genre { id: string name: string } interface SeriesOption { id: string name: string slug: string _count?: { novels: number } } interface EpubPreviewData { fileName: string splitMode: "toc" | "regex" detectedStructureType: "standard" | "light_novel" hasCoverFromEpub?: boolean parserInfo?: { splitMode: "toc" | "regex" chapterRegexUsed?: string | null regexPreset?: string | null sourceSections: number chaptersDetected: number chaptersFinal?: number insertedMissingChapters?: number detectedMaxChapterNumber?: number } novel: { title: string authorName: string description: string detectedGenres?: string[] totalChapters: number } chaptersPreview: { number: number title: string isPlaceholder?: boolean volumeNumber: number | null volumeTitle: string | null volumeChapterNumber: number | null excerpt: string }[] } type BulkUploadStatus = "pending" | "uploading" | "success" | "failed" | "skipped" type BulkDuplicateHandling = "ask" | "replace-all" | "skip-all" interface BulkUploadProgressItem { fileKey: string displayName: string progress: number status: BulkUploadStatus message?: string } interface EpubUploadResponseData { error?: string code?: string canReplace?: boolean existingNovel?: { id: string title: string slug: string } replaced?: boolean } 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]*$", }, ] export function NovelClient() { const [novels, setNovels] = useState([]) const [loading, setLoading] = useState(true) const [openAdd, setOpenAdd] = useState(false) const [submitting, setSubmitting] = useState(false) const [uploadingEpub, setUploadingEpub] = useState(false) const [previewingEpub, setPreviewingEpub] = useState(false) const [openEpubPreview, setOpenEpubPreview] = useState(false) const [openBulkEpubImport, setOpenBulkEpubImport] = useState(false) const [epubPreviewData, setEpubPreviewData] = useState(null) const [pendingEpubFile, setPendingEpubFile] = useState(null) const [pendingEpubFiles, setPendingEpubFiles] = useState([]) const [epubTitle, setEpubTitle] = useState("") const [epubAuthorName, setEpubAuthorName] = useState("") const [epubDescription, setEpubDescription] = useState("") const [epubSeriesMode, setEpubSeriesMode] = useState<"none" | "existing" | "new">("none") const [epubSeriesId, setEpubSeriesId] = useState("") const [epubSeriesName, setEpubSeriesName] = useState("") const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("toc") const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi") const [epubCustomRegex, setEpubCustomRegex] = useState("") const epubInputRef = useRef(null) const epubFolderInputRef = useRef(null) // Form states const [title, setTitle] = useState("") const [originalTitle, setOriginalTitle] = useState("") const [authorName, setAuthorName] = useState("") const [originalAuthorName, setOriginalAuthorName] = useState("") const [description, setDescription] = useState("") const [coverUrl, setCoverUrl] = useState("") const [seriesMode, setSeriesMode] = useState<"none" | "existing" | "new">("none") const [selectedSeriesId, setSelectedSeriesId] = useState("") const [newSeriesName, setNewSeriesName] = useState("") const [status, setStatus] = useState("Đang ra") // View state const [viewMode, setViewMode] = useState<"list" | "grid">("list") const [uploadingCover, setUploadingCover] = useState(false) // Edit states const [openEdit, setOpenEdit] = useState(false) const [editingNovel, setEditingNovel] = useState(null) const [loadingEditData, setLoadingEditData] = useState(false) // Genre states const [genres, setGenres] = useState([]) const [seriesList, setSeriesList] = useState([]) const [selectedGenres, setSelectedGenres] = useState([]) const [genreQuery, setGenreQuery] = useState("") const [addingGenre, setAddingGenre] = useState(false) // Delete states const [openDelete, setOpenDelete] = useState(false) const [deletingNovelId, setDeletingNovelId] = useState(null) // Bulk states const [searchKeyword, setSearchKeyword] = useState("") const [selectedNovelIds, setSelectedNovelIds] = useState([]) const [bulkSubmitting, setBulkSubmitting] = useState(false) const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(20) const [bulkProgress, setBulkProgress] = useState>({}) const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState("ask") const [pendingAIPrefill, setPendingAIPrefill] = useState(null) const getSelectedChapterRegex = () => { if (epubRegexPreset === "custom") { return epubCustomRegex.trim() } return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern } const normalizeEpubFiles = (files: File[]) => { return files.filter((file) => file.name.toLowerCase().endsWith(".epub")) } const buildEpubFileKey = (file: File) => { const relativePath = file.webkitRelativePath || file.name return `${relativePath}::${file.size}::${file.lastModified}` } const cloneFormData = (source: FormData): FormData => { const next = new FormData() for (const [key, value] of source.entries()) { next.append(key, value) } return next } const uploadEpubRequest = async ( formData: FormData, onProgress?: (progress: number) => void ): Promise<{ status: number; ok: boolean; data: EpubUploadResponseData }> => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("POST", "/api/mod/epub") // Large EPUB imports can take several minutes when parsing + writing many chapters. xhr.timeout = 15 * 60 * 1000 xhr.upload.onprogress = (event) => { if (!onProgress || !event.lengthComputable) return const percent = Math.round((event.loaded / event.total) * 100) onProgress(Math.min(99, Math.max(0, percent))) } xhr.onerror = () => reject(new Error("Không thể kết nối tới server")) xhr.onabort = () => reject(new Error("Upload đã bị huỷ hoặc kết nối bị ngắt")) xhr.ontimeout = () => reject(new Error("Upload quá lâu và đã hết thời gian chờ")) xhr.onload = () => { let data: EpubUploadResponseData = {} try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {} } catch { data = {} } resolve({ status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, data, }) } xhr.send(formData) }) } const setBulkProgressItem = (fileKey: string, patch: Partial) => { setBulkProgress((prev) => { const current = prev[fileKey] if (!current) return prev return { ...prev, [fileKey]: { ...current, ...patch, }, } }) } const initializeBulkProgress = (files: File[]) => { const initial: Record = {} for (const file of files) { const fileKey = buildEpubFileKey(file) initial[fileKey] = { fileKey, displayName: file.webkitRelativePath || file.name, progress: 0, status: "pending", } } setBulkProgress(initial) } const mergeUniqueEpubFiles = (base: File[], incoming: File[]) => { const merged: File[] = [...base] const picked = new Set(base.map((file) => buildEpubFileKey(file))) for (const file of incoming) { const key = buildEpubFileKey(file) if (picked.has(key)) continue picked.add(key) merged.push(file) } return merged } const appendPendingBulkEpubFiles = (incomingFiles: File[]) => { const merged = mergeUniqueEpubFiles(pendingEpubFiles, incomingFiles) const addedCount = merged.length - pendingEpubFiles.length if (addedCount <= 0) { toast.info("Các file EPUB đã có sẵn trong hàng đợi") } else { toast.success(`Đã thêm ${addedCount} file EPUB vào hàng đợi import`) } setPendingEpubFile(null) setOpenEpubPreview(false) setPendingEpubFiles(merged) initializeBulkProgress(merged) setOpenBulkEpubImport(true) } const fetchNovels = async () => { try { const res = await fetch("/api/mod/truyen") if (!res.ok) throw new Error("Lấy danh sách lỗi") const data = await res.json() setNovels(data) setSelectedNovelIds((prev) => prev.filter((id) => data.some((novel: Novel) => novel.id === id))) } catch { toast.error("Không thể tải danh sách truyện") } finally { setLoading(false) } } const fetchGenres = async () => { try { const res = await fetch("/api/mod/the-loai") if (res.ok) { const data = await res.json() setGenres(data) } } catch { console.error("Failed to fetch genres") } } const fetchSeries = async () => { try { const res = await fetch("/api/mod/series") if (res.ok) { const data = await res.json() setSeriesList(data) } } catch { console.error("Failed to fetch series") } } useEffect(() => { fetchNovels() fetchGenres() 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 return novels.filter((novel) => { const searchable = [novel.title, novel.authorName, novel.series?.name || ""].join(" ").toLowerCase() return searchable.includes(keyword) }) }, [novels, searchKeyword]) const totalPages = Math.max(1, Math.ceil(filteredNovels.length / pageSize)) const pagedNovels = useMemo(() => { const start = (currentPage - 1) * pageSize return filteredNovels.slice(start, start + pageSize) }, [filteredNovels, currentPage, pageSize]) useEffect(() => { if (currentPage > totalPages) { setCurrentPage(totalPages) } }, [currentPage, totalPages]) useEffect(() => { setCurrentPage(1) }, [searchKeyword]) const normalizedGenreQuery = genreQuery.trim().toLowerCase() const matchedGenres = useMemo(() => { if (!normalizedGenreQuery) return [] return genres .filter((genre) => genre.name.toLowerCase().includes(normalizedGenreQuery)) .slice(0, 8) }, [genres, normalizedGenreQuery]) const exactMatchedGenre = useMemo(() => { if (!normalizedGenreQuery) return null return genres.find((genre) => genre.name.trim().toLowerCase() === normalizedGenreQuery) || null }, [genres, normalizedGenreQuery]) const selectedGenreItems = useMemo(() => { const byId = new Map(genres.map((genre) => [genre.id, genre])) return selectedGenres .map((id) => byId.get(id)) .filter((genre): genre is Genre => Boolean(genre)) }, [genres, selectedGenres]) const visibleNovelIds = useMemo(() => pagedNovels.map((novel) => novel.id), [pagedNovels]) const allVisibleSelected = visibleNovelIds.length > 0 && visibleNovelIds.every((id) => selectedNovelIds.includes(id)) const bulkProgressItems = useMemo(() => { return pendingEpubFiles .map((file) => bulkProgress[buildEpubFileKey(file)]) .filter((item): item is BulkUploadProgressItem => Boolean(item)) }, [pendingEpubFiles, bulkProgress]) const processedBulkCount = useMemo( () => bulkProgressItems.filter((item) => ["success", "failed", "skipped"].includes(item.status)).length, [bulkProgressItems] ) const overallBulkProgress = useMemo(() => { if (bulkProgressItems.length === 0) return 0 return Math.round((processedBulkCount / bulkProgressItems.length) * 100) }, [bulkProgressItems, processedBulkCount]) const toggleNovelSelection = (id: string) => { setSelectedNovelIds((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]) } const toggleSelectAllVisible = () => { setSelectedNovelIds((prev) => { if (allVisibleSelected) { return prev.filter((id) => !visibleNovelIds.includes(id)) } const merged = new Set([...prev, ...visibleNovelIds]) return Array.from(merged) }) } const handleBulkDelete = async () => { if (selectedNovelIds.length === 0) { toast.error("Vui lòng chọn truyện cần xóa") return } if (!confirm(`Bạn có chắc muốn xóa ${selectedNovelIds.length} truyện đã chọn?`)) return setBulkSubmitting(true) try { const res = await fetch("/api/mod/truyen/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "delete", ids: selectedNovelIds }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || "Không thể xóa hàng loạt") toast.success(`Đã xóa ${data.deletedCount || selectedNovelIds.length} truyện`) setSelectedNovelIds([]) fetchNovels() fetchSeries() } catch (error: any) { toast.error(error.message || "Lỗi khi xóa hàng loạt") } finally { setBulkSubmitting(false) } } const toggleGenre = (id: string) => { setSelectedGenres(prev => prev.includes(id) ? prev.filter(gId => gId !== id) : [...prev, id] ) } const handleAddGenre = async () => { const inputName = genreQuery.trim() if (!inputName) return const existed = genres.find((genre) => genre.name.trim().toLowerCase() === inputName.toLowerCase()) if (existed) { setSelectedGenres((prev) => prev.includes(existed.id) ? prev : [...prev, existed.id]) setGenreQuery("") return } setAddingGenre(true) try { const res = await fetch("/api/mod/the-loai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: inputName, description: "" }) }) const data = await res.json() if (!res.ok) throw new Error(data.error || "Thêm lỗi") toast.success("Thêm thể loại thành công") setGenreQuery("") fetchGenres() setSelectedGenres(prev => prev.includes(data.id) ? prev : [...prev, data.id]) } catch (error: any) { toast.error(error.message) } finally { setAddingGenre(false) } } const handleDeleteGenre = async (id: string, name: string) => { if (!confirm(`Bạn có chắc muốn xóa thể loại "${name}" khỏi hệ thống?`)) return; try { const res = await fetch(`/api/mod/the-loai?id=${id}`, { method: "DELETE" }) if (!res.ok) { const data = await res.json() throw new Error(data.error || "Xóa lỗi") } toast.success("Đã xóa thể loại thành công") fetchGenres() // Clean up from selected lists setSelectedGenres(prev => prev.filter(gId => gId !== id)) if (genreQuery.trim() && genreQuery.trim().toLowerCase() === name.trim().toLowerCase()) { setGenreQuery("") } } catch (error: any) { toast.error(error.message) } } const renderGenreSelector = (label: string) => { const actionLabel = exactMatchedGenre ? (selectedGenres.includes(exactMatchedGenre.id) ? "Đã chọn" : "Chọn") : "Tạo" return (
setGenreQuery(e.target.value)} className="flex-1" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() handleAddGenre() } }} />

Nhập tên thể loại để tìm trong hệ thống. Nếu chưa có, bấm Tạo để thêm mới.

{selectedGenreItems.map((genre) => (
{genre.name}
))} {selectedGenreItems.length === 0 && ( Chưa chọn thể loại nào )}
{genreQuery.trim().length > 0 && (

Kết quả phù hợp

{matchedGenres.map((genre) => { const isSelected = selectedGenres.includes(genre.id) return (
) })} {matchedGenres.length === 0 && ( Không có thể loại phù hợp. Bấm Tạo để thêm mới. )}
)}
) } const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!title || !authorName || !description) { toast.error("Vui lòng điền đầy đủ thông tin") return } const resolvedNewSeriesName = seriesMode === "new" ? (newSeriesName.trim() || title.trim()) : "" if (seriesMode === "existing" && !selectedSeriesId) { toast.error("Vui lòng chọn series đã có") return } if (seriesMode === "new" && !resolvedNewSeriesName) { toast.error("Vui lòng nhập tên series mới") return } setSubmitting(true) try { const res = await fetch("/api/mod/truyen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds: selectedGenres, seriesId: seriesMode === "existing" ? selectedSeriesId : undefined, seriesName: seriesMode === "new" ? resolvedNewSeriesName : undefined, }), }) 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) resetAddForm() fetchNovels() fetchSeries() } catch { toast.error("Lỗi khi thêm truyện mới") } finally { setSubmitting(false) } } const resetEpubPreviewState = () => { setOpenEpubPreview(false) setOpenBulkEpubImport(false) setEpubPreviewData(null) setPendingEpubFile(null) setPendingEpubFiles([]) setBulkProgress({}) setBulkDuplicateHandling("ask") setEpubTitle("") setEpubAuthorName("") setEpubDescription("") setEpubSeriesMode("none") setEpubSeriesId("") setEpubSeriesName("") setEpubSplitMode("toc") setEpubRegexPreset("vi_chuong_hoi") setEpubCustomRegex("") if (epubInputRef.current) { epubInputRef.current.value = "" } if (epubFolderInputRef.current) { epubFolderInputRef.current.value = "" } } const requestEpubPreview = async ( file: File, options?: { splitMode?: "toc" | "regex" regexPreset?: string regexInput?: string preserveEditedMetadata?: boolean } ) => { const splitMode = options?.splitMode || epubSplitMode const regexPreset = options?.regexPreset || epubRegexPreset const regexInput = options?.regexInput ?? (regexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex()) if (splitMode === "regex" && !regexInput) { throw new Error("Vui lòng nhập regex tách chương") } setPreviewingEpub(true) const formData = new FormData() formData.append("file", file) formData.append("preview", "true") formData.append("seriesMode", epubSeriesMode) if (epubSeriesMode === "existing") formData.append("seriesId", epubSeriesId) if (epubSeriesMode === "new") formData.append("seriesName", epubSeriesName) formData.append("splitMode", splitMode) if (splitMode === "regex") { formData.append("chapterRegexPreset", regexPreset) formData.append("chapterRegex", regexInput) } const res = await fetch("/api/mod/epub", { method: "POST", body: formData, }) const data = await res.json() if (!res.ok) { throw new Error(data.error || data.detail || "Không thể phân tích EPUB") } setEpubPreviewData(data) setEpubSplitMode(data.splitMode || splitMode) if (!options?.preserveEditedMetadata) { setEpubTitle(data.novel?.title || "") setEpubAuthorName(data.novel?.authorName || "") setEpubDescription(data.novel?.description || "") } setOpenEpubPreview(true) } const handleEpubSelect = async (e: React.ChangeEvent) => { const selectedFiles = Array.from(e.target.files || []) if (selectedFiles.length === 0) return const epubFiles = normalizeEpubFiles(selectedFiles) if (epubFiles.length === 0) { toast.error("Không tìm thấy file .epub trong lựa chọn") e.target.value = "" return } if (epubFiles.length !== selectedFiles.length) { toast.info(`Đã bỏ qua ${selectedFiles.length - epubFiles.length} file không phải EPUB`) } if (epubFiles.length > 1 || openBulkEpubImport) { appendPendingBulkEpubFiles(epubFiles) e.target.value = "" return } const file = epubFiles[0] setPendingEpubFile(file) try { await requestEpubPreview(file, { splitMode: "toc", regexPreset: "vi_chuong_hoi", regexInput: CHAPTER_REGEX_PRESETS[0].pattern, preserveEditedMetadata: false, }) } catch (err: any) { toast.error(err.message || "Có lỗi xảy ra khi xử lý file EPUB") setPendingEpubFile(null) } finally { setPreviewingEpub(false) e.target.value = "" } } const handleEpubFolderSelect = (e: React.ChangeEvent) => { const selectedFiles = Array.from(e.target.files || []) if (selectedFiles.length === 0) return const epubFiles = normalizeEpubFiles(selectedFiles) if (epubFiles.length === 0) { toast.error("Không tìm thấy file .epub trong thư mục đã chọn") e.target.value = "" return } appendPendingBulkEpubFiles(epubFiles) e.target.value = "" } const handleReparseEpub = async () => { if (!pendingEpubFile) { toast.error("Không tìm thấy file EPUB để phân tích lại") return } try { await requestEpubPreview(pendingEpubFile, { splitMode: epubSplitMode, regexPreset: epubRegexPreset, regexInput: epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex(), preserveEditedMetadata: true, }) } catch (err: any) { toast.error(err.message || "Không thể phân tích lại EPUB") } finally { setPreviewingEpub(false) } } const handleConfirmEpubUpload = async () => { if (!pendingEpubFile) { toast.error("Không tìm thấy file EPUB để tải lên") return } if (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim()) { toast.error("Vui lòng nhập regex tùy chỉnh trước khi tải lên") return } if (epubSeriesMode === "existing" && !epubSeriesId) { toast.error("Vui lòng chọn series đã có") return } if (epubSeriesMode === "new" && !epubSeriesName.trim()) { toast.error("Vui lòng nhập tên series mới") return } setUploadingEpub(true) const formData = new FormData() formData.append("file", pendingEpubFile) formData.append("title", epubTitle) formData.append("authorName", epubAuthorName) formData.append("description", epubDescription) formData.append("seriesMode", epubSeriesMode) if (epubSeriesMode === "existing") formData.append("seriesId", epubSeriesId) if (epubSeriesMode === "new") formData.append("seriesName", epubSeriesName.trim()) formData.append("splitMode", epubSplitMode) if (epubSplitMode === "regex") { const selectedRegex = epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex() formData.append("chapterRegexPreset", epubRegexPreset) formData.append("chapterRegex", selectedRegex) } try { let upload = await uploadEpubRequest(formData) if (upload.status === 409 && upload.data?.code === "DUPLICATE_TITLE") { const duplicateTitle = upload.data.existingNovel?.title || epubTitle if (upload.data.canReplace === false) { throw new Error(upload.data.error || `Truyện ${duplicateTitle} đã tồn tại và bạn không có quyền ghi đè`) } const shouldReplace = window.confirm(`Truyện "${duplicateTitle}" đã tồn tại. Bạn có muốn replace truyện này không?`) if (!shouldReplace) { toast.info("Đã hủy upload vì trùng tên truyện") return } const retryFormData = cloneFormData(formData) retryFormData.set("replaceExisting", "true") upload = await uploadEpubRequest(retryFormData) } if (!upload.ok) { throw new Error(upload.data?.error || "Lỗi khi tải lên EPUB") } if (upload.data?.replaced) { toast.success("Đã replace truyện từ EPUB thành công") } else { toast.success("Đã tải lên EPUB thành công") } resetEpubPreviewState() fetchNovels() fetchSeries() } catch (err: any) { toast.error(err.message || "Có lỗi xảy ra khi xuất bản truyện") } finally { setUploadingEpub(false) } } const handleBulkEpubUpload = async () => { if (pendingEpubFiles.length === 0) { toast.error("Không có file EPUB để tải lên") return } if (epubSeriesMode === "existing" && !epubSeriesId) { toast.error("Vui lòng chọn series đã có") return } if (epubSeriesMode === "new" && !epubSeriesName.trim()) { toast.error("Vui lòng nhập tên series mới") return } setUploadingEpub(true) initializeBulkProgress(pendingEpubFiles) let success = 0 let failed = 0 let skipped = 0 let replaced = 0 try { for (const file of pendingEpubFiles) { const fileKey = buildEpubFileKey(file) try { const formData = new FormData() formData.append("file", file) formData.append("seriesMode", epubSeriesMode) if (epubSeriesMode === "existing") formData.append("seriesId", epubSeriesId) if (epubSeriesMode === "new") formData.append("seriesName", epubSeriesName.trim()) formData.append("splitMode", "toc") setBulkProgressItem(fileKey, { status: "uploading", progress: 1, message: "Đang upload...", }) let upload = await uploadEpubRequest(formData, (progress) => { setBulkProgressItem(fileKey, { progress, status: "uploading" }) }) if (upload.status === 409 && upload.data?.code === "DUPLICATE_TITLE") { const duplicateTitle = upload.data.existingNovel?.title || file.name if (upload.data.canReplace === false) { failed += 1 setBulkProgressItem(fileKey, { status: "failed", progress: 100, message: upload.data.error || `Trùng tên ${duplicateTitle} nhưng không đủ quyền replace`, }) continue } let shouldReplace = false if (bulkDuplicateHandling === "replace-all") { shouldReplace = true } else if (bulkDuplicateHandling === "skip-all") { shouldReplace = false } else { shouldReplace = window.confirm(`File ${file.name} trùng với truyện "${duplicateTitle}". Bạn có muốn replace không?`) } if (!shouldReplace) { skipped += 1 setBulkProgressItem(fileKey, { status: "skipped", progress: 100, message: bulkDuplicateHandling === "skip-all" ? "Bỏ qua theo cấu hình" : "Đã bỏ qua do trùng tên", }) continue } const retryFormData = cloneFormData(formData) retryFormData.set("replaceExisting", "true") upload = await uploadEpubRequest(retryFormData, (progress) => { setBulkProgressItem(fileKey, { progress, status: "uploading" }) }) } if (upload.ok) { success += 1 if (upload.data?.replaced) { replaced += 1 } setBulkProgressItem(fileKey, { status: "success", progress: 100, message: upload.data?.replaced ? "Đã replace thành công" : "Upload thành công", }) } else { failed += 1 setBulkProgressItem(fileKey, { status: "failed", progress: 100, message: upload.data?.error || "Upload thất bại", }) } } catch (err: any) { failed += 1 setBulkProgressItem(fileKey, { status: "failed", progress: 100, message: err?.message || "Upload thất bại do lỗi kết nối", }) continue } } if (success > 0 && failed === 0 && skipped === 0) { toast.success(`Đã import ${success} file EPUB thành công${replaced > 0 ? ` (${replaced} file replace)` : ""}`) } else if (success > 0 || skipped > 0) { toast.warning(`Import: thành công ${success}${replaced > 0 ? ` (${replaced} replace)` : ""}, thất bại ${failed}, bỏ qua ${skipped}`) } else { toast.error("Import EPUB thất bại") } resetEpubPreviewState() fetchNovels() fetchSeries() } finally { setUploadingEpub(false) } } const handleCoverUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return if (!file.type.startsWith('image/')) { toast.error("Vui lòng chọn file hình ảnh") e.target.value = "" return } setUploadingCover(true) const formData = new FormData() formData.append("file", file) try { const res = await fetch("/api/mod/upload-cover", { method: "POST", body: formData, }) const data = await res.json() if (!res.ok) throw new Error(data.error || "Lỗi khi tải lên ảnh bìa") setCoverUrl(data.url) toast.success("Tải ảnh bìa thành công!") } catch (err: any) { toast.error(err.message || "Có lỗi xảy ra khi xử lý ảnh bìa") } finally { setUploadingCover(false) e.target.value = "" } } const handleOpenEdit = async (novel: Novel) => { setEditingNovel(novel) setTitle(novel.title) setAuthorName(novel.authorName) if (novel.series?.id) { setSeriesMode("existing") setSelectedSeriesId(novel.series.id) setNewSeriesName("") } else { setSeriesMode("none") setSelectedSeriesId("") setNewSeriesName("") } setStatus(novel.status) setDescription("") setGenreQuery("") setOriginalTitle("") setOriginalAuthorName("") setCoverUrl(novel.coverUrl || "") setOpenEdit(true) setLoadingEditData(true) try { const res = await fetch(`/api/mod/truyen/${novel.id}`) if (res.ok) { const data = await res.json() setDescription(data.description || "") setOriginalTitle(data.originalTitle || "") setOriginalAuthorName(data.originalAuthorName || "") if (data.series?.id) { setSeriesMode("existing") setSelectedSeriesId(data.series.id) setNewSeriesName("") } if (data.genres && Array.isArray(data.genres)) { setSelectedGenres(data.genres.map((g: any) => g.id)) } else { setSelectedGenres([]) } } else { toast.error("Không tải được chi tiết truyện") } } catch { toast.error("Không tải được chi tiết truyện") } finally { setLoadingEditData(false) } } const handleEditSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!editingNovel || !title || !authorName) { toast.error("Vui lòng nhập tên truyện và tác giả") return } if (seriesMode === "existing" && !selectedSeriesId) { toast.error("Vui lòng chọn series đã có") return } if (seriesMode === "new" && !newSeriesName.trim()) { toast.error("Vui lòng nhập tên series mới") return } setSubmitting(true) try { const res = await fetch("/api/mod/truyen", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: editingNovel.id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds: selectedGenres, status: status }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || "Lỗi cập nhật") toast.success("Cập nhật truyện thành công!") setOpenEdit(false) fetchNovels() fetchSeries() } catch (error: any) { toast.error(error.message) } finally { setSubmitting(false) } } const handleDeleteSubmit = async () => { if (!deletingNovelId) return setSubmitting(true) try { const res = await fetch(`/api/mod/truyen?id=${deletingNovelId}`, { method: "DELETE", }) if (!res.ok) { const data = await res.json() throw new Error(data.error || "Xóa thất bại") } toast.success("Đã xóa truyện thành công") setOpenDelete(false) fetchNovels() } catch (error: any) { toast.error(error.message) } finally { setSubmitting(false) } } return (

Quản lý truyện

{ if (!open && !uploadingEpub) { resetEpubPreviewState() } else { setOpenEpubPreview(open) } }} > Xem trước truyện từ EPUB Kiểm tra nội dung sẽ được import trước khi tải lên chính thức. {epubPreviewData ? (

File: {epubPreviewData.fileName}

Số chương: {epubPreviewData.novel.totalChapters}

{Array.isArray(epubPreviewData.novel.detectedGenres) && epubPreviewData.novel.detectedGenres.length > 0 && (

Thể loại nhận diện:{" "} {epubPreviewData.novel.detectedGenres.join(", ")}

)}

Cover từ EPUB:{" "} {epubPreviewData.hasCoverFromEpub ? "Có (sẽ tự gán làm ảnh bìa)" : "Không tìm thấy cover"}

Nhận diện cấu trúc:{" "} {epubPreviewData.detectedStructureType === "light_novel" ? "Theo quyển" : "Chuẩn"}

{epubPreviewData.parserInfo && ( <>

Parser:{" "} {epubPreviewData.parserInfo.splitMode === "regex" ? "Regex" : "TOC"}

Nguồn phân tích:{" "} {epubPreviewData.parserInfo.sourceSections} mục EPUB

{typeof epubPreviewData.parserInfo.chaptersDetected === "number" && (

Chương bắt được:{" "} {epubPreviewData.parserInfo.chaptersDetected}

)} {typeof epubPreviewData.parserInfo.insertedMissingChapters === "number" && (

Chương thiếu đã chèn:{" "} {epubPreviewData.parserInfo.insertedMissingChapters}

)} {epubPreviewData.parserInfo.chapterRegexUsed && (

Regex dùng:{" "} {epubPreviewData.parserInfo.chapterRegexUsed}

)} )}

Tùy chọn parser EPUB

{epubSplitMode === "regex" && (
)}
{epubSplitMode === "regex" && ( <> {epubRegexPreset !== "custom" ? (
{CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern}
) : (
setEpubCustomRegex(e.target.value)} placeholder="Ví dụ: ^(?:Chương|Chapter)\\s*\\d+[^\\n]*$" />
)} )}

Chỉnh sửa nhanh metadata

setEpubTitle(e.target.value)} />
setEpubAuthorName(e.target.value)} />