From 5f2c6d26f642e07e62a7c02fd060071f4d00dc3b Mon Sep 17 00:00:00 2001 From: virtus Date: Sun, 3 May 2026 20:57:50 +0700 Subject: [PATCH] feat: Refactor Import Client for EPUB management with enhanced UI and functionality - Implemented a multi-step wizard for importing EPUB files, including search, metadata review, chapter preview, and import progress tracking. - Added genre management features, allowing users to create, select, and delete genres during the import process. - Enhanced asset search functionality with improved API integration and user feedback. - Updated the UI components for better user experience and responsiveness. - Removed unused AI Tool links from the Novel Client and Mod Dashboard. - Cleaned up the HomePage API response handling to streamline data fetching for popular and latest novels. - Adjusted TypeScript definitions for route types in the Next.js environment. --- CROSS_REPO_ENDPOINT_MATRIX.md | 8 + FEATURES.md | 1 + FLOWS.md | 10 + .../{mod-import => import}/[...path]/route.ts | 10 + app/mod/collapsible-sidebar.tsx | 5 +- app/mod/import/import-client.tsx | 771 +++++++++++++----- app/mod/import/page.tsx | 15 +- app/mod/page.tsx | 17 - app/mod/truyen/novel-client.tsx | 9 +- app/page.tsx | 56 +- next-env.d.ts | 2 +- 11 files changed, 652 insertions(+), 252 deletions(-) rename app/api/{mod-import => import}/[...path]/route.ts (82%) diff --git a/CROSS_REPO_ENDPOINT_MATRIX.md b/CROSS_REPO_ENDPOINT_MATRIX.md index bd9eb2a..85c8925 100644 --- a/CROSS_REPO_ENDPOINT_MATRIX.md +++ b/CROSS_REPO_ENDPOINT_MATRIX.md @@ -26,9 +26,17 @@ Legend: | Comment | `GET/POST /api/truyen/{id}/comments` | Y | Y | Y | | | Rating | `POST /api/truyen/{id}/rate` | Y | Y | N | Mobile chua thay rating flow | | Search | `GET /api/truyen/suggest` | Y | Y | N | Mobile search suggest can bo sung | +| Import | `GET /api/import/assets/search` | Y | Y | N | Web MOD import wizard step 1 | +| Import | `GET /api/import/assets/{id}/preview-metadata` | Y | Y | N | Web MOD import wizard step 2 | +| Import | `POST /api/import/assets/{id}/ai-suggest` | Y | Y | N | AI metadata suggestion | +| Import | `POST /api/import/assets/{id}/review` | Y | Y | N | Save reviewed metadata | +| Import | `POST /api/import/assets/{id}/parse-preview` | Y | Y | N | TOC/regex-start preview | +| Import | `POST /api/import/assets/{id}/start-import` | Y | Y | N | Start import session | +| Import | `GET /api/import/sessions/{sessionId}` | Y | Y | N | Poll progress | ## Priority gaps de dong bo tiep 1. Mobile: `user/settings`, `recommendations`, `rate`, `suggest`. 2. Web/Mobile chapter-read strategy can unify (`chapters/{id}` vs `by-number`). 3. Chuan hoa error contract implementation theo `CONTRACT.md`. +4. Mobile import flow currently not planned. diff --git a/FEATURES.md b/FEATURES.md index 53de40a..d40fd54 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -28,6 +28,7 @@ Trang thai tinh nang cho web app `reader`. | Feature | Status | Notes | |---|---|---| | MOD dashboard/workflows | partial | Mot phan route mod da co, tiep tuc bo sung | +| EPUB import wizard (4 step) | done | `/mod/import` + `/api/import/*` review-first flow | ## Dependencies diff --git a/FLOWS.md b/FLOWS.md index 4cf7f06..9d8369f 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -38,3 +38,13 @@ Luon xem `reader-api` la canonical behavior. - Rating: `POST /api/truyen/{id}/rate`. - Recommendation: `/api/user/recommendations`. - Error rules: follow `CONTRACT.md`. + +## Flow 5: MOD EPUB Import Wizard + +- Route: `/mod/import` +- Steps: + 1. Search source asset by name (`GET /api/import/assets/search`) + 2. Review metadata + AI suggestion (`preview-metadata`, `ai-suggest`, `review`) + 3. Parse preview with TOC/regex-start (`POST /api/import/assets/{id}/parse-preview`) + 4. Start import and poll progress (`start-import`, `GET /api/import/sessions/{sessionId}`) +- Rule: reviewer confirms metadata before import starts. diff --git a/app/api/mod-import/[...path]/route.ts b/app/api/import/[...path]/route.ts similarity index 82% rename from app/api/mod-import/[...path]/route.ts rename to app/api/import/[...path]/route.ts index e78fbea..bc7ba0b 100644 --- a/app/api/mod-import/[...path]/route.ts +++ b/app/api/import/[...path]/route.ts @@ -45,6 +45,16 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ path: stri return proxyToReaderApi(req, path) } +export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path } = await ctx.params + return proxyToReaderApi(req, path) +} + +export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path } = await ctx.params + return proxyToReaderApi(req, path) +} + export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { const { path } = await ctx.params return proxyToReaderApi(req, path) diff --git a/app/mod/collapsible-sidebar.tsx b/app/mod/collapsible-sidebar.tsx index 2b25d3f..53be2f8 100644 --- a/app/mod/collapsible-sidebar.tsx +++ b/app/mod/collapsible-sidebar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { AlertTriangle, BookOpen, Home, Sparkles, Star, Tag, ChevronLeft, ChevronRight } from "lucide-react" +import { AlertTriangle, BookOpen, Home, Star, Tag, ChevronLeft, ChevronRight, UploadCloud } from "lucide-react" import { cn } from "@/lib/utils" export function CollapsibleSidebar() { @@ -32,9 +32,8 @@ export function CollapsibleSidebar() { { href: "/mod/truyen", label: "Quản lý truyện", icon: BookOpen }, { href: "/mod/thieu-thong-tin", label: "Truyện thiếu dữ liệu", icon: AlertTriangle }, { href: "/mod/the-loai", label: "Quản lý thể loại", icon: Tag }, + { href: "/mod/import", label: "Import EPUB", icon: UploadCloud }, { href: "/mod/de-cu", label: "Quản lý đề cử", icon: Star }, - { href: "/mod/import", label: "Import EPUB", icon: BookOpen }, - { href: "/mod/ai-tool", label: "AI Tool", icon: Sparkles }, ] return ( diff --git a/app/mod/import/import-client.tsx b/app/mod/import/import-client.tsx index 48c25a5..b7b3b52 100644 --- a/app/mod/import/import-client.tsx +++ b/app/mod/import/import-client.tsx @@ -1,218 +1,615 @@ "use client" -import { useEffect, useMemo, useState } from "react" +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 Asset = { id: string; path: string; status: string } -type Job = { id: string; sourceAssetId: string; path?: string; status: string; error?: string | null } +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 [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 [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 [selectedJobId, setSelectedJobId] = useState("") - const [mapNovelId, setMapNovelId] = useState("") - const [tab, setTab] = useState<"unconverted" | "converted">("unconverted") + 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 visibleAssets = useMemo( - () => tab === "unconverted" ? assets.filter((a) => a.status !== "completed") : assets, - [assets, tab], + 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], ) - 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) - } + 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 } - useEffect(() => { - refresh() - }, [debouncedQuery, offset, tab]) + const toggleGenre = (id: string) => { + setSelectedGenreIds((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id])) + } - 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]))) + 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 } - 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`, { + setAddingGenre(true) + try { + const res = await fetch("/api/mod/the-loai", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ status: "approved" }), + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name, description: "" }), }) - 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 + 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) } - 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 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 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 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([]) }) - 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
-
- - - -
-
+
+
+

Import EPUB Wizard

+

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

-
-

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" /> - -
+
+ {[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ả" /> +