From 3e8496cfeb97f5c087028938d249889582363b6c Mon Sep 17 00:00:00 2001 From: virtus Date: Thu, 30 Apr 2026 01:54:08 +0700 Subject: [PATCH] Add import functionality for EPUB files: create ImportClient component and ModImportPage --- app/api/mod-import/[...path]/route.ts | 51 ++++++++ app/mod/collapsible-sidebar.tsx | 1 + app/mod/import/import-client.tsx | 167 ++++++++++++++++++++++++++ app/mod/import/page.tsx | 16 +++ 4 files changed, 235 insertions(+) create mode 100644 app/api/mod-import/[...path]/route.ts create mode 100644 app/mod/import/import-client.tsx create mode 100644 app/mod/import/page.tsx diff --git a/app/api/mod-import/[...path]/route.ts b/app/api/mod-import/[...path]/route.ts new file mode 100644 index 0000000..e78fbea --- /dev/null +++ b/app/api/mod-import/[...path]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") + +async function proxyToReaderApi(req: NextRequest, path: string[]) { + const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null + + const url = new URL(req.url) + const query = url.search || "" + const targetUrl = `${readerApiOrigin}/api/import/${path.join("/")}${query}` + + const headers = new Headers(req.headers) + headers.delete("host") + headers.delete("cookie") + if (accessToken) { + headers.set("authorization", `Bearer ${accessToken}`) + } + + const isBodyMethod = req.method !== "GET" && req.method !== "HEAD" + const upstream = await fetch(targetUrl, { + method: req.method, + headers, + body: isBodyMethod ? req.body : undefined, + cache: "no-store", + duplex: "half", + } as any) + + return new NextResponse(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }) +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path } = await ctx.params + return proxyToReaderApi(req, path) +} + +export async function POST(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 03e0ee8..2b25d3f 100644 --- a/app/mod/collapsible-sidebar.tsx +++ b/app/mod/collapsible-sidebar.tsx @@ -33,6 +33,7 @@ export function CollapsibleSidebar() { { 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/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 }, ] diff --git a/app/mod/import/import-client.tsx b/app/mod/import/import-client.tsx new file mode 100644 index 0000000..bb066ed --- /dev/null +++ b/app/mod/import/import-client.tsx @@ -0,0 +1,167 @@ +"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" /> + +
+
+
+ ) +} diff --git a/app/mod/import/page.tsx b/app/mod/import/page.tsx new file mode 100644 index 0000000..7d54414 --- /dev/null +++ b/app/mod/import/page.tsx @@ -0,0 +1,16 @@ +import { requireModSessionUser } from "@/lib/server-auth" +import { ImportClient } from "./import-client" + +export default async function ModImportPage() { + await requireModSessionUser() + + return ( +
+

Import EPUB

+

+ Quản lý nguồn EPUB trên NAS, chạy convert, map chapter và hoàn tất import. +

+ +
+ ) +}