"use client" import { useEffect, useMemo, useRef, useState } from "react" import { Check, Loader2, Search, Sparkles, Trash2, UploadCloud, X } 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 SearchResponse = { items: AssetItem[] pagination: { page: number; limit: number; total: number; totalPages: number } } 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 [query, setQuery] = useState("") const [searching, setSearching] = useState(false) const [assets, setAssets] = useState([]) const [asset, setAsset] = 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 [sessionId, setSessionId] = useState("") const [phase, setPhase] = useState("prepare") const [progress, setProgress] = useState(0) const [result, setResult] = useState | null>(null) const pollRef = useRef | 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 onSearch = async () => { if (query.trim().length < 2) { toast.warning("Nhập ít nhất 2 ký tự để tìm") return } setSearching(true) try { const res = await fetch(`/api/import/assets/search?q=${encodeURIComponent(query.trim())}&page=1&limit=20`, { credentials: "include" }) const data = (await res.json()) as SearchResponse if (!res.ok) throw new Error((data as unknown as { detail?: string }).detail || "Search failed") setAssets(data.items || []) } catch (error) { toast.error(error instanceof Error ? error.message : "Không tìm được asset") } finally { setSearching(false) } } const onSelectAsset = async (item: AssetItem) => { setAsset(item) setStep(2) setCoverPreviewUrl("") try { const res = await fetch(`/api/import/assets/${item.id}/preview-metadata`, { credentials: "include" }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Không lấy được metadata") const suggested = data?.suggested || {} setCoverDetected(Boolean(data?.asset?.coverDetected)) if (data?.asset?.coverDetected) { const ts = Date.now() setCoverPreviewUrl(`/api/import/assets/${item.id}/preview-cover?t=${ts}`) } setTitle(suggested.title || item.title || item.path.split("/").pop()?.replace(/\.epub$/i, "") || "") 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 { const form = new FormData() form.append("file", file) const res = await fetch(`/api/import/assets/${asset.id}/upload-cover`, { method: "POST", credentials: "include", body: form, }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Upload cover that bai") setCoverDetected(true) if (data?.coverUrl) { setCoverPreviewUrl(data.coverUrl) } 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) return setAiLoading(true) try { const res = await fetch(`/api/import/assets/${asset.id}/ai-suggest`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ splitMode, chapterStartPattern: splitMode === "regex" ? chapterStartPattern : null }), }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "AI suggest failed") const suggestedGenres: string[] = (data?.suggestedGenres || []).slice(0, 6) setGenreQuery("") if (suggestedGenres.length > 0) { const ensuredIds = await ensureGenreIdsByNames(suggestedGenres) setSelectedGenreIds(ensuredIds) } setShortDescription(data?.shortDescription || "") 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 { const res = await fetch(`/api/import/assets/${asset.id}/review`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ title, author, shortDescription, genres: selectedGenreItems.map((g) => g.name), targetMode: "new", replaceExisting, }), }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Lưu review thất bại") 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) return setPreviewLoading(true) setPreviewItems([]) setChapterCount(0) setParseError("") try { const res = await fetch(`/api/import/assets/${asset.id}/parse-preview`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ splitMode, chapterStartPattern: splitMode === "regex" ? chapterStartPattern : null }), }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Parse preview thất bại") setPreviewItems(data?.sample || []) setChapterCount(data?.chapterCount || 0) if ((data?.chapterCount || 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 pollSession = async (id: string) => { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } pollRef.current = setInterval(async () => { try { const res = await fetch(`/api/import/sessions/${id}`, { credentials: "include" }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Không lấy được tiến trình") setPhase(data.phase || "prepare") setProgress(Number(data.progressPct || 0)) if (data.status === "completed" || data.status === "failed") { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } setImporting(false) setResult(data.resultJson || null) if (data.status === "completed") { toast.success("Import hoàn tất") } else { toast.error(data.log || "Import thất bại") } } } catch { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } setImporting(false) } }, 1500) } const onStartImport = async () => { if (!asset) return setImporting(true) try { const res = await fetch(`/api/import/assets/${asset.id}/start-import`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ replaceExisting, splitMode, chapterStartPattern: splitMode === "regex" ? chapterStartPattern : null, }), }) const data = await res.json() if (!res.ok) throw new Error(data?.detail || "Start import thất bại") if (!data?.sessionId) throw new Error("Missing sessionId from start-import") setSessionId(data.sessionId) setStep(4) pollSession(data.sessionId) } catch (error) { setImporting(false) toast.error(error instanceof Error ? error.message : "Không bắt đầu import được") } } useEffect(() => { return () => { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } } }, []) 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: Search -> Metadata -> Chapter Preview -> Import

{[1, 2, 3, 4].map((n) => (
= n ? "border-primary bg-primary/5" : "border-border"}`}> Bước {n}
))}
{step === 1 && (
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() if (!searching) { onSearch() } } }} placeholder="Tìm theo tên EPUB..." />
{assets.map((item) => ( ))}
)} {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 ) : (
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ả" />