"use client" import { useEffect, useMemo, useState } from "react" import { Check, Loader2, Sparkles, Trash2, UploadCloud, X, Search } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Progress } from "@/components/ui/progress" import { toast } from "sonner" type AssetItem = { id: string path?: string title?: string | null author?: string | null status: string updatedAt: string } type ParsePreviewSample = { bucket: string number: number title: string chars: number preview: string } type Genre = { id: string name: string } export function ImportClient() { const [step, setStep] = useState(1) const [uploadingEpub, setUploadingEpub] = useState(false) const [asset, setAsset] = useState(null) const [epubFile, setEpubFile] = useState(null) const [coverDetected, setCoverDetected] = useState(false) const [coverPreviewUrl, setCoverPreviewUrl] = useState("") const [uploadingCover, setUploadingCover] = useState(false) const [title, setTitle] = useState("") const [author, setAuthor] = useState("") const [shortDescription, setShortDescription] = useState("") const [genres, setGenres] = useState([]) const [selectedGenreIds, setSelectedGenreIds] = useState([]) const [genreQuery, setGenreQuery] = useState("") const [addingGenre, setAddingGenre] = useState(false) const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc") const [chapterStartPattern, setChapterStartPattern] = useState("^\\s*(?:[#>*\\-\\[]\\s*)*(?:ch(?:u\\.?|ương|uong)?|chapter|hồi|hoi|quyển|quyen|phần|phan|tập|tap)\\s*\\d+(?:[\\.:\\-\\)]\\s*|\\s+).+$") const [replaceExisting, setReplaceExisting] = useState(false) const [previewLoading, setPreviewLoading] = useState(false) const [previewItems, setPreviewItems] = useState([]) const [chapterCount, setChapterCount] = useState(0) const [parseError, setParseError] = useState("") const [aiLoading, setAiLoading] = useState(false) const [importing, setImporting] = useState(false) const [result, setResult] = useState | null>(null) const normalizedGenreQuery = genreQuery.trim().toLowerCase() const exactMatchedGenre = useMemo( () => genres.find((genre) => genre.name.trim().toLowerCase() === normalizedGenreQuery) || null, [genres, normalizedGenreQuery], ) const matchedGenres = useMemo(() => { if (!normalizedGenreQuery) return [] return genres.filter((genre) => genre.name.toLowerCase().includes(normalizedGenreQuery)).slice(0, 20) }, [genres, normalizedGenreQuery]) const selectedGenreItems = useMemo( () => genres.filter((genre) => selectedGenreIds.includes(genre.id)), [genres, selectedGenreIds], ) const fetchGenres = async (): Promise => { const res = await fetch("/api/mod/the-loai", { credentials: "include" }) const data = await res.json() if (!res.ok) throw new Error(data?.error || data?.detail || "Không lấy được thể loại") const next = Array.isArray(data) ? (data as Genre[]) : [] setGenres(next) return next } const toggleGenre = (id: string) => { setSelectedGenreIds((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id])) } const handleAddGenre = async () => { const name = genreQuery.trim() if (!name) return const existed = genres.find((genre) => genre.name.trim().toLowerCase() === name.toLowerCase()) if (existed) { setSelectedGenreIds((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" }, credentials: "include", body: JSON.stringify({ name, description: "" }), }) const data = await res.json() if (!res.ok) throw new Error(data?.error || data?.detail || "Không tạo được thể loại") await fetchGenres() if (data?.id) { setSelectedGenreIds((prev) => (prev.includes(data.id) ? prev : [...prev, data.id])) } setGenreQuery("") } catch (error) { toast.error(error instanceof Error ? error.message : "Không tạo được thể loại") } 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", credentials: "include" }) const data = await res.json().catch(() => ({})) if (!res.ok) throw new Error(data?.error || data?.detail || "Không xóa được thể loại") await fetchGenres() setSelectedGenreIds((prev) => prev.filter((v) => v !== id)) if (genreQuery.trim().toLowerCase() === name.trim().toLowerCase()) { setGenreQuery("") } } catch (error) { toast.error(error instanceof Error ? error.message : "Không xóa được thể loại") } } const ensureGenreIdsByNames = async (names: string[]): Promise => { const uniqueNames = [...new Set(names.map((n) => n.trim()).filter(Boolean))].slice(0, 6) if (uniqueNames.length === 0) return [] let genreList = await fetchGenres() const ids: string[] = [] for (const name of uniqueNames) { const existing = genreList.find((g) => g.name.trim().toLowerCase() === name.toLowerCase()) if (existing) { ids.push(existing.id) continue } const res = await fetch("/api/mod/the-loai", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name, description: "" }), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.error || data?.detail || `Không thể tạo thể loại: ${name}`) } if (data?.id) { ids.push(data.id) } genreList = await fetchGenres() } return [...new Set(ids)].slice(0, 6) } const onUploadEpub = async (file: File) => { setUploadingEpub(true) try { const form = new FormData() form.append("file", file) const res = await fetch("/api/import/uploads/preview", { method: "POST", credentials: "include", body: form }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Không tải được EPUB") setEpubFile(file) const item: AssetItem = { id: `preview-${Date.now()}`, title: data.suggested?.title || file.name, author: data.suggested?.author || "Unknown", status: "discovered", updatedAt: new Date().toISOString(), } await onSelectAsset(item, data) } catch (error) { toast.error(error instanceof Error ? error.message : "Không tải được EPUB") } finally { setUploadingEpub(false) } } const onSelectAsset = async (item: AssetItem, previewData?: any) => { setAsset(item) setStep(2) setCoverPreviewUrl("") try { const data = previewData || {} const suggested = data?.suggested || {} setCoverDetected(Boolean(data?.coverDetected)) setCoverPreviewUrl(typeof data?.coverPreviewDataUrl === "string" ? data.coverPreviewDataUrl : "") setTitle(suggested.title || item.title || "") setAuthor(suggested.author || item.author || "Unknown") setShortDescription(suggested.shortDescription || "") const genreList = await fetchGenres() const suggestedGenres: string[] = suggested.genres || [] if (suggestedGenres.length > 0) { const lowered = suggestedGenres.map((v) => v.trim().toLowerCase()) const ids = genreList.filter((g) => lowered.includes(g.name.trim().toLowerCase())).map((g) => g.id) setSelectedGenreIds(ids.slice(0, 6)) } } catch (error) { toast.error(error instanceof Error ? error.message : "Không lấy được metadata") } } const onUploadReplacementCover = async (file: File) => { if (!asset) return setUploadingCover(true) try { setCoverDetected(true) setCoverPreviewUrl(URL.createObjectURL(file)) toast.success("Da upload cover thay the") } catch (error) { toast.error(error instanceof Error ? error.message : "Upload cover that bai") } finally { setUploadingCover(false) } } const onAiSuggest = async () => { if (!asset || !epubFile) return setAiLoading(true) try { const form = new FormData() form.append("file", epubFile) form.append("preview", "true") form.append("splitMode", splitMode) if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern) const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "AI suggest failed") const suggestedGenres: string[] = (data?.novel?.detectedGenres || []).slice(0, 6) setGenreQuery("") if (suggestedGenres.length > 0) { const ensuredIds = await ensureGenreIdsByNames(suggestedGenres) setSelectedGenreIds(ensuredIds) } if (!shortDescription) setShortDescription(data?.novel?.description || "") toast.success("Đã áp dụng gợi ý AI") } catch (error) { toast.error(error instanceof Error ? error.message : "AI suggest lỗi") } finally { setAiLoading(false) } } const onSaveReview = async () => { if (!asset) return try { toast.success("Đã lưu review") setPreviewItems([]) setChapterCount(0) setStep(3) } catch (error) { toast.error(error instanceof Error ? error.message : "Lưu review thất bại") } } const onParsePreview = async () => { if (!asset || !epubFile) return setPreviewLoading(true) setPreviewItems([]) setChapterCount(0) setParseError("") try { const form = new FormData() form.append("file", epubFile) form.append("preview", "true") form.append("splitMode", splitMode) if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern) const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Parse preview thất bại") const chapters = Array.isArray(data?.chaptersPreview) ? data.chaptersPreview : [] setPreviewItems(chapters.map((c: any) => ({ bucket: "preview", number: c.number || 0, title: c.title || "", chars: (c.excerpt || "").length, preview: c.excerpt || "" }))) setChapterCount(Number(data?.novel?.totalChapters || chapters.length || 0)) if ((Number(data?.novel?.totalChapters || chapters.length || 0)) <= 0) { setParseError("Không tách được chương từ EPUB này với cấu hình hiện tại. Thử đổi TOC/Regex rồi parse lại.") } toast.success("Đã tạo preview chương") } catch (error) { setParseError(error instanceof Error ? error.message : "Preview thất bại") toast.error(error instanceof Error ? error.message : "Preview thất bại") } finally { setPreviewLoading(false) } } const onStartImport = async () => { if (!asset || !epubFile) return setImporting(true) try { const form = new FormData() form.append("file", epubFile) form.append("preview", "false") form.append("splitMode", splitMode) if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern) form.append("title", title) form.append("authorName", author) form.append("description", shortDescription) form.append("replaceExisting", String(replaceExisting)) const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Start import thất bại") setResult(data || null) setStep(4) setImporting(false) toast.success("Import hoàn tất") } catch (error) { setImporting(false) toast.error(error instanceof Error ? error.message : "Không bắt đầu import được") } } useEffect(() => { setPreviewItems([]) setChapterCount(0) setParseError("") }, [splitMode, chapterStartPattern]) useEffect(() => { if (step === 3 && asset) { void onParsePreview() } }, [step, asset?.id]) useEffect(() => { fetchGenres().catch(() => { setGenres([]) }) }, []) return (

Import EPUB Wizard

4 bước: Upload -> Metadata -> Chapter Preview -> Import

{[1, 2, 3, 4].map((n) => (
= n ? "border-primary bg-primary/5" : "border-border"}`}> Bước {n}
))}
{step === 1 && (

Chọn file EPUB từ máy của bạn để bắt đầu.

{ const file = e.target.files?.[0] if (file) { void onUploadEpub(file) } e.currentTarget.value = "" }} /> {uploadingEpub &&

Đang tải và phân tích EPUB...

}
)} {step === 2 && asset && (

Review metadata

{coverDetected ? "Da nhan dien duoc cover trong EPUB. He thong se upload len R2 khi bat dau import." : "Chua nhan dien duoc cover trong EPUB (neu co)."}

{coverPreviewUrl ? ( Cover preview { setCoverPreviewUrl("") setCoverDetected(false) toast.error("Khong tai duoc cover preview tu server") }} /> ) : (
Khong co preview
)}
{ const file = e.target.files?.[0] if (file) { void onUploadReplacementCover(file) } }} disabled={uploadingCover} />

Upload anh thay the de uu tien dung cover nay khi import.

setTitle(e.target.value)} placeholder="Tiêu đề" /> setAuthor(e.target.value)} placeholder="Tác giả" />