"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 [selectedJobId, setSelectedJobId] = useState("") const [mapNovelId, setMapNovelId] = useState("") const unconvertedAssets = useMemo(() => assets.filter((a) => a.status !== "completed"), [assets]) useEffect(() => { const t = setTimeout(() => setDebouncedQuery(assetQuery.trim()), 250) return () => clearTimeout(t) }, [assetQuery]) const refresh = async () => { setLoading(true) try { const aUrl = `/api/mod-import/assets?unconvertedOnly=true&limit=200${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]) 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 = unconvertedAssets.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 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 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)} />
{unconvertedAssets.map((a, idx) => ( ))} {unconvertedAssets.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" />
) }