"use client" import { useEffect, useMemo, useState } from "react" import { toast } from "sonner" type Asset = { id: string; path: string; status: string } type Job = { id: string; sourceAssetId: string; path?: string; status: string; error?: string | null } export function ImportClient() { const [assets, setAssets] = useState([]) const [jobs, setJobs] = useState([]) const [selectedAssetIds, setSelectedAssetIds] = useState([]) const [lastIndex, setLastIndex] = useState(null) const [assetQuery, setAssetQuery] = useState("") const [debouncedQuery, setDebouncedQuery] = useState("") const [loading, setLoading] = useState(false) const [offset, setOffset] = useState(0) const [limit] = useState(50) const [selectedJobId, setSelectedJobId] = useState("") const [mapNovelId, setMapNovelId] = useState("") const [tab, setTab] = useState<"unconverted" | "converted">("unconverted") const visibleAssets = useMemo( () => tab === "unconverted" ? assets.filter((a) => a.status !== "completed") : assets, [assets, tab], ) useEffect(() => { const t = setTimeout(() => setDebouncedQuery(assetQuery.trim()), 250) return () => clearTimeout(t) }, [assetQuery]) const refresh = async () => { setLoading(true) try { const aUrl = tab === "unconverted" ? `/api/mod-import/assets?unconvertedOnly=true&limit=${limit}&offset=${offset}${debouncedQuery ? `&q=${encodeURIComponent(debouncedQuery)}` : ""}` : `/api/mod-import/assets?status=completed&limit=${limit}&offset=${offset}${debouncedQuery ? `&q=${encodeURIComponent(debouncedQuery)}` : ""}` const [aRes, jRes] = await Promise.all([fetch(aUrl), fetch("/api/mod-import/review-required")]) const aData = await aRes.json().catch(() => []) const jData = await jRes.json().catch(() => []) if (!aRes.ok) throw new Error("Không tải được danh sách EPUB") if (!jRes.ok) throw new Error("Không tải được mismatch jobs") setAssets(Array.isArray(aData) ? aData : []) setJobs(Array.isArray(jData) ? jData : []) } catch (e: any) { toast.error(e?.message || "Lỗi tải dữ liệu") } finally { setLoading(false) } } useEffect(() => { refresh() }, [debouncedQuery, offset, tab]) const selectItem = (assetId: string, index: number, shiftKey: boolean) => { if (shiftKey && lastIndex !== null) { const start = Math.min(lastIndex, index) const end = Math.max(lastIndex, index) const range = visibleAssets.slice(start, end + 1).map((x) => x.id) setSelectedAssetIds((prev) => Array.from(new Set([...prev, ...range]))) return } setLastIndex(index) setSelectedAssetIds((prev) => (prev.includes(assetId) ? prev.filter((x) => x !== assetId) : [...prev, assetId])) } const convertSelected = async () => { if (selectedAssetIds.length === 0) return toast.error("Chưa chọn EPUB") let success = 0 for (const assetId of selectedAssetIds) { const approveRes = await fetch(`/api/mod-import/assets/${assetId}/approve`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ status: "approved" }), }) if (!approveRes.ok) continue const createRes = await fetch("/api/mod-import/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceAssetId: assetId }), }) const createData = await createRes.json().catch(() => ({})) if (!createRes.ok) continue const runRes = await fetch(`/api/mod-import/jobs/${createData.id}/run`, { method: "POST" }) if (runRes.ok) success += 1 } toast.success(`Đã convert ${success}/${selectedAssetIds.length} EPUB`) setSelectedAssetIds([]) refresh() } const autoReview = async () => { const res = await fetch("/api/mod-import/assets/auto-review?limit=5000", { method: "POST" }) const data = await res.json().catch(() => ({})) if (!res.ok) return toast.error((data as any)?.detail || "Auto-review thất bại") toast.success(`Đã phân loại ${data.processed}: approved ${data.approved}, review ${data.reviewRequired}`) refresh() } const mapMismatch = async () => { if (!selectedJobId || !mapNovelId) return toast.error("Chọn mismatch job và nhập novelId") const res = await fetch(`/api/mod-import/jobs/${selectedJobId}/apply-mapping`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ novelId: mapNovelId, overwrite: true }), }) const data = await res.json().catch(() => ({})) if (!res.ok) return toast.error((data as any)?.detail || "Map thất bại") toast.success(`Map xong: ${data.mapped || 0} chương, thiếu ${data.missing || 0}`) refresh() } const markConverted = async (assetId: string) => { if (!confirm("Đánh dấu EPUB này đã convert (ẩn khỏi danh sách chưa convert)?")) return const res = await fetch(`/api/mod-import/assets/${assetId}/mark-converted`, { method: "POST" }) const data = await res.json().catch(() => ({})) if (!res.ok) return toast.error((data as any)?.detail || "Mark converted thất bại") toast.success("Đã đánh dấu converted") setSelectedAssetIds((prev) => prev.filter((x) => x !== assetId)) refresh() } const unmarkConverted = async (assetId: string) => { if (!confirm("Bỏ trạng thái converted để convert lại?")) return const res = await fetch(`/api/mod-import/assets/${assetId}/unmark-converted`, { method: "POST" }) const data = await res.json().catch(() => ({})) if (!res.ok) return toast.error((data as any)?.detail || "Unmark thất bại") toast.success("Đã unmark converted") refresh() } const deleteMismatchJob = async (jobId: string) => { if (!confirm("Xoá mismatch job này và xoá luôn thư mục content trên NAS?")) return const res = await fetch(`/api/mod-import/jobs/${jobId}?removeContent=true`, { method: "DELETE" }) const data = await res.json().catch(() => ({})) if (!res.ok) return toast.error((data as any)?.detail || "Xoá job thất bại") toast.success(`Đã xoá job ${jobId}`) if (selectedJobId === jobId) setSelectedJobId("") refresh() } return (

EPUB nguồn (chưa convert)

Click để chọn, Shift + click để chọn một khoảng, sau đó bấm Convert.

setAssetQuery(e.target.value)} />
{visibleAssets.map((a, idx) => (
{tab === "unconverted" ? : null} {tab === "converted" ? : null}
))} {visibleAssets.length === 0 &&
Không có EPUB phù hợp
}
Đã chọn {selectedAssetIds.length} file

Mismatch / Review Jobs

Danh sách job bị mismatch (sum/path/mapping). Chọn job rồi nhập novelId để map lại, không convert lại EPUB.

{jobs.map((j) => (
))} {jobs.length === 0 &&
Không có mismatch job
}
setMapNovelId(e.target.value)} className="border rounded px-3 py-2 text-sm flex-1" placeholder="Novel ID cần map lại" />
) }