"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 } from "lucide-react" import { toast } from "sonner" import Link from "next/link" import { getNovelStatusBadgeClass } from "@/lib/novel-status" 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 totalChapters: number } chaptersPreview: { number: number title: string isPlaceholder?: boolean volumeNumber: number | null volumeTitle: string | null volumeChapterNumber: number | null excerpt: string }[] } 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: "mix_chapter", name: "Mixed - Chương/Chapter", pattern: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$", }, { id: "bracket_chapter", name: "[Chương 01] ...", pattern: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\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") const [epubCustomRegex, setEpubCustomRegex] = useState("") const epubInputRef = 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 [newGenreName, setNewGenreName] = 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 getSelectedChapterRegex = () => { if (epubRegexPreset === "custom") { return epubCustomRegex.trim() } return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern } 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 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 visibleNovelIds = useMemo(() => filteredNovels.map((novel) => novel.id), [filteredNovels]) const allVisibleSelected = visibleNovelIds.length > 0 && visibleNovelIds.every((id) => selectedNovelIds.includes(id)) 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 () => { if (!newGenreName.trim()) return setAddingGenre(true) try { const res = await fetch("/api/mod/the-loai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newGenreName, 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") setNewGenreName("") fetchGenres() setSelectedGenres(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)) } catch (error: any) { toast.error(error.message) } } 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) setTitle("") setOriginalTitle("") setAuthorName("") setOriginalAuthorName("") setDescription("") setCoverUrl("") setSeriesMode("none") setSelectedSeriesId("") setNewSeriesName("") setStatus("Đang ra") setSelectedGenres([]) 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([]) setEpubTitle("") setEpubAuthorName("") setEpubDescription("") setEpubSeriesMode("none") setEpubSeriesId("") setEpubSeriesName("") setEpubSplitMode("toc") setEpubRegexPreset("vi_chuong") setEpubCustomRegex("") if (epubInputRef.current) { epubInputRef.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 || "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 files = Array.from(e.target.files || []) if (files.length === 0) return if (files.some((file) => !file.name.endsWith('.epub'))) { toast.error("Vui lòng chọn file định dạng .epub") e.target.value = "" // Reset input return } if (files.length > 1) { setPendingEpubFiles(files) setOpenBulkEpubImport(true) e.target.value = "" return } const file = files[0] setPendingEpubFile(file) try { await requestEpubPreview(file, { splitMode: "toc", regexPreset: "vi_chuong", 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 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 { const res = await fetch("/api/mod/epub", { method: "POST", body: formData, }) if (!res.ok) { const data = await res.json() throw new Error(data.error || "Lỗi khi tải lên EPUB") } 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) let success = 0 let failed = 0 try { for (const file of pendingEpubFiles) { 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") const res = await fetch("/api/mod/epub", { method: "POST", body: formData, }) if (res.ok) success += 1 else failed += 1 } if (success > 0 && failed === 0) { toast.success(`Đã import ${success} file EPUB vào series thành công`) } else if (success > 0) { toast.warning(`Import thành công ${success} file, thất bại ${failed} file`) } 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("") 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.genreId)) } 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}

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)} />