refactor: Streamline EPUB handling with new split modes and improved error management
Build and Push Reader Image / docker (push) Successful in 1m32s

- Removed legacy AI Tool references and unnecessary fields from the README and various components.
- Introduced new EPUB split modes (toc, regex, tag) to enhance flexibility in chapter extraction.
- Updated import and chapter management components to utilize the new EPUB split functionality.
- Improved error handling in the login API route for better user feedback.
- Cleaned up unused files and optimized the overall code structure for maintainability.
This commit is contained in:
2026-05-19 00:15:19 +07:00
parent 3036854cf2
commit 878018ca11
12 changed files with 370 additions and 1327 deletions
-5
View File
@@ -44,11 +44,6 @@ READER_API_ORIGIN="http://localhost:8000"
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
# AI Tool cho MOD (LLM + web search)
OPENAI_API_KEY="your_openai_api_key"
# Tùy chọn, mặc định: gpt-4o-mini-search-preview
OPENAI_WEB_MODEL="gpt-4o-mini-search-preview"
# Cloudflare R2 (lưu ảnh bìa)
R2_ACCOUNT_ID="your_cloudflare_account_id"
R2_ACCESS_KEY_ID="your_r2_access_key_id"
+26 -2
View File
@@ -36,8 +36,19 @@ export async function POST(req: NextRequest) {
})
if (!upstream.ok) {
const message = await upstream.text()
return NextResponse.json({ detail: message || "Authentication failed" }, { status: upstream.status })
const raw = await upstream.text()
let detail = "Authentication failed"
try {
const parsed = JSON.parse(raw) as { detail?: unknown }
if (typeof parsed.detail === "string") {
detail = parsed.detail
} else if (raw.trim()) {
detail = raw.trim()
}
} catch {
if (raw.trim()) detail = raw.trim()
}
return NextResponse.json({ detail }, { status: upstream.status })
}
const data = (await upstream.json()) as MobileLoginResponse
@@ -66,6 +77,19 @@ export async function POST(req: NextRequest) {
return response
} catch (error) {
console.error("/api/auth/login failed", error)
const cause = error instanceof Error ? error.cause : null
const code =
cause && typeof cause === "object" && "code" in cause
? String((cause as { code?: unknown }).code || "")
: ""
if (code === "ECONNREFUSED" || (error instanceof Error && error.message.includes("fetch failed"))) {
return NextResponse.json(
{
detail: `Không kết nối được reader-api tại ${readerApiOrigin}. Kiểm tra READER_API_ORIGIN và đảm bảo API đang chạy (ví dụ cổng 18080).`,
},
{ status: 503 },
)
}
return NextResponse.json({ detail: "Internal Server Error" }, { status: 500 })
}
}
File diff suppressed because it is too large Load Diff
+128 -12
View File
@@ -17,6 +17,12 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react"
import { toast } from "sonner"
import {
appendEpubSplitFormFields,
DEFAULT_EPUB_CHAPTER_TAG,
EPUB_HTML_TAG_PRESETS,
type EpubSplitMode,
} from "@/lib/epub-split"
import Link from "next/link"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
@@ -58,11 +64,12 @@ interface Chapter {
interface EpubPreviewData {
preview: true
fileName: string
splitMode: "toc" | "regex"
splitMode: EpubSplitMode
detectedStructureType: "light_novel" | "standard"
parserInfo?: {
splitMode: string
chapterRegexUsed?: string
chapterTagUsed?: string
regexPreset?: string
sourceSections: number
chaptersDetected: number
@@ -116,6 +123,8 @@ function ChapterManager() {
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
const [optRenumber, setOptRenumber] = useState(true)
const [optNormalizeTitles, setOptNormalizeTitles] = useState(true)
const [optNormalizeGenericOnly, setOptNormalizeGenericOnly] = useState(true)
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
@@ -147,9 +156,11 @@ function ChapterManager() {
const [openEpubAppend, setOpenEpubAppend] = useState(false)
const [epubFile, setEpubFile] = useState<File | null>(null)
const epubInputRef = useRef<HTMLInputElement>(null)
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("regex")
const [epubSplitMode, setEpubSplitMode] = useState<EpubSplitMode>("regex")
const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi")
const [epubCustomRegex, setEpubCustomRegex] = useState("")
const [epubTagPreset, setEpubTagPreset] = useState("a")
const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
const [appendingEpub, setAppendingEpub] = useState(false)
const [epubPreviewData, setEpubPreviewData] = useState<EpubPreviewData | null>(null)
@@ -160,6 +171,13 @@ function ChapterManager() {
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
}
const getEpubSourceTag = () => {
if (epubTagPreset === "custom") {
return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
}
return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
}
const handleEpubAppendPreview = async () => {
if (!epubFile || !novelId) return
@@ -170,8 +188,10 @@ function ChapterManager() {
try {
const formData = new FormData()
formData.append("file", epubFile)
formData.append("splitMode", epubSplitMode)
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
appendEpubSplitFormFields(formData, epubSplitMode, {
chapterRegex: getEpubSourceRegex(),
chapterTag: getEpubSourceTag(),
})
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("appendTargetNovelId", novelId)
formData.append("preview", "true")
@@ -209,8 +229,10 @@ function ChapterManager() {
try {
const formData = new FormData()
formData.append("file", epubFile)
formData.append("splitMode", epubSplitMode)
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
appendEpubSplitFormFields(formData, epubSplitMode, {
chapterRegex: getEpubSourceRegex(),
chapterTag: getEpubSourceTag(),
})
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("appendTargetNovelId", novelId)
@@ -256,7 +278,7 @@ function ChapterManager() {
})
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Xóa thất bại")
if (!res.ok) throw new Error(data.error || data.detail || "Xóa thất bại")
toast.success(`Đã xóa thành công ${data.deletedCount} chương!`, { id: toastId })
setOpenBulkDelete(false)
@@ -429,7 +451,7 @@ function ChapterManager() {
const handlePreviewOptimize = async () => {
if (!novelId) return
if (!optRemovePrefix && !optRenumber) {
if (!optRemovePrefix && !optRenumber && !optNormalizeTitles) {
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
return
}
@@ -446,6 +468,36 @@ function ChapterManager() {
setOptimizeSourceChapters(allChapters)
let newChapters = [...allChapters]
if (optNormalizeTitles) {
const previewRes = await fetch("/api/mod/chuong/normalize-titles/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
novelId,
overwriteGenericOnly: optNormalizeGenericOnly,
}),
})
const previewData = await previewRes.json().catch(() => ({}))
if (!previewRes.ok) {
throw new Error(previewData.detail || previewData.error || "Không thể tạo gợi ý tiêu đề chương")
}
const titleById = new Map<string, string>(
(previewData.items || []).map((item: { id: string; suggestedTitle: string }) => [
item.id,
item.suggestedTitle,
]),
)
newChapters = newChapters.map((ch) => {
const key = ch._id || ch.id
const suggested = key ? titleById.get(key) : undefined
return suggested ? { ...ch, title: suggested } : ch
})
const changeCount = Number(previewData.changeCount || 0)
if (changeCount === 0) {
toast.info("Không tìm thấy tiêu đề chương nào cần chuẩn hóa theo bộ lọc hiện tại")
}
}
if (optRenumber) {
newChapters.sort((a, b) => a.number - b.number)
newChapters = newChapters.map((ch, idx) => ({
@@ -636,10 +688,10 @@ function ChapterManager() {
<h3 className="font-semibold text-sm border-b pb-2">Giải Thuật Tách Chương</h3>
<div className="flex items-center gap-4">
<Label className="w-1/4">Phiên bản Text</Label>
<RadioGroup value={epubSplitMode} onValueChange={(v: "toc" | "regex") => {
<RadioGroup value={epubSplitMode} onValueChange={(v: EpubSplitMode) => {
setEpubSplitMode(v)
setEpubPreviewData(null)
}} className="flex items-center gap-4">
}} className="flex flex-wrap items-center gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="toc" id="toc_mode_a" />
<Label htmlFor="toc_mode_a" className="cursor-pointer font-normal">Mục lục (TOC)</Label>
@@ -648,8 +700,49 @@ function ChapterManager() {
<RadioGroupItem value="regex" id="regex_mode_a" />
<Label htmlFor="regex_mode_a" className="cursor-pointer font-normal">Quy tắc (Regex)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="tag" id="tag_mode_a" />
<Label htmlFor="tag_mode_a" className="cursor-pointer font-normal">Thẻ HTML</Label>
</div>
</RadioGroup>
</div>
{epubSplitMode === "tag" && (
<div className="space-y-3 pt-2">
<div className="flex flex-col gap-2">
<Label>Thẻ HTML tách chương</Label>
<Select value={epubTagPreset} onValueChange={(v) => {
setEpubTagPreset(v)
setEpubPreviewData(null)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EPUB_HTML_TAG_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
<SelectItem value="custom">Tùy chỉnh tên thẻ...</SelectItem>
</SelectContent>
</Select>
</div>
{epubTagPreset === "custom" && (
<div className="flex flex-col gap-2">
<Label>Tên thẻ ( dụ: a, h2)</Label>
<Input
value={epubCustomTag}
onChange={(e) => {
setEpubCustomTag(e.target.value)
setEpubPreviewData(null)
}}
placeholder="a"
/>
</div>
)}
</div>
)}
{epubSplitMode === "regex" && (
<div className="space-y-3 pt-2">
@@ -696,12 +789,15 @@ function ChapterManager() {
<div className="text-xs space-y-1 text-right">
<p><span className="text-muted-foreground">Flow file HTML:</span> {epubPreviewData.parserInfo.sourceSections}</p>
<p><span className="text-muted-foreground">Phát hiện:</span> {epubPreviewData.parserInfo.chaptersDetected} chương</p>
{epubPreviewData.parserInfo.chapterTagUsed && (
<p><span className="text-muted-foreground">Thẻ HTML:</span> &lt;{epubPreviewData.parserInfo.chapterTagUsed}&gt;</p>
)}
</div>
</div>
{epubPreviewData.chaptersPreview.length === 0 ? (
<div className="p-4 text-center border border-dashed rounded bg-muted/40 text-muted-foreground text-sm">
Không tách đưc chương nào. Xem lại Tùy chọn Tách / Mẫu Regex.
Không tách đưc chương nào. Xem lại chế đ tách (TOC / Regex / Thẻ HTML).
</div>
) : (
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-2 bg-card">
@@ -730,7 +826,7 @@ function ChapterManager() {
<DialogFooter className="mt-auto pt-4 border-t">
<Button variant="outline" onClick={() => setOpenEpubAppend(false)} disabled={appendingEpub}>Huỷ</Button>
{!epubPreviewData ? (
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub}>
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub || (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim()) || (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim())}>
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xem trước dữ liệu
</Button>
@@ -887,6 +983,26 @@ function ChapterManager() {
<p className="text-sm text-muted-foreground mt-1">Sắp xếp gán lại số chương liên tục từ 1 đến N đ sửa lỗi nhảy cóc</p>
</div>
</label>
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
<input type="checkbox" className="w-5 h-5 rounded" checked={optNormalizeTitles} onChange={(e) => setOptNormalizeTitles(e.target.checked)} />
<div>
<p className="font-medium text-base">Chuẩn hóa tiêu đ từ nội dung đu chương</p>
<p className="text-sm text-muted-foreground mt-1">
dụ: dòng <span className="line-through">Chương 8</span> + dòng <strong>Tất Cả Đu o Giác</strong> tiêu đ <strong>Tất Cả Đu o Giác</strong>
</p>
{optNormalizeTitles && (
<label className="mt-3 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 rounded"
checked={optNormalizeGenericOnly}
onChange={(e) => setOptNormalizeGenericOnly(e.target.checked)}
/>
Chỉ sửa tiêu đ dạng &quot;Chương N&quot; (giữ tiêu đ đã đt tay)
</label>
)}
</div>
</label>
</div>
) : (
<div className="flex-1 overflow-auto border rounded-lg my-4 custom-scrollbar">
+53 -9
View File
@@ -6,6 +6,12 @@ import { Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import { toast } from "sonner"
import {
appendEpubSplitFormFields,
DEFAULT_EPUB_CHAPTER_TAG,
EPUB_HTML_TAG_PRESETS,
type EpubSplitMode,
} from "@/lib/epub-split"
/** Đồng bộ với `MOD_EPUB_MAX_CHAPTERS` trên reader-api. */
const BATCH_IMPORT_MAX_CHAPTERS = 4000
@@ -46,10 +52,17 @@ function normalizeNovelTitle(raw: string): string {
}
export function ImportBatchClient() {
const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc")
const [splitMode, setSplitMode] = useState<EpubSplitMode>("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 [tagPreset, setTagPreset] = useState("a")
const [customTag, setCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
const getChapterTag = () => {
if (tagPreset === "custom") return customTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
return EPUB_HTML_TAG_PRESETS.find((p) => p.id === tagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
}
const [replaceExisting, setReplaceExisting] = useState(false)
const [running, setRunning] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
@@ -142,8 +155,10 @@ export function ImportBatchClient() {
const formAi = new FormData()
formAi.append("file", file)
formAi.append("splitMode", splitMode)
if (splitMode === "regex") formAi.append("chapterRegex", chapterStartPattern)
appendEpubSplitFormFields(formAi, splitMode, {
chapterRegex: chapterStartPattern,
chapterTag: getChapterTag(),
})
formAi.append("title", title)
formAi.append("authorName", author)
@@ -184,8 +199,10 @@ export function ImportBatchClient() {
const formParse = new FormData()
formParse.append("file", file)
formParse.append("preview", "true")
formParse.append("splitMode", splitMode)
if (splitMode === "regex") formParse.append("chapterRegex", chapterStartPattern)
appendEpubSplitFormFields(formParse, splitMode, {
chapterRegex: chapterStartPattern,
chapterTag: getChapterTag(),
})
formParse.append("enforceMaxChapters", "true")
const r3 = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: formParse, signal })
@@ -220,15 +237,17 @@ export function ImportBatchClient() {
fileName: displayPath,
ok: false,
resolvedTitle: title,
error: "Bước 3: không tách được chương với cấu hình TOC/Regex hiện tại",
error: "Bước 3: không tách được chương với cấu hình TOC/Regex/Thẻ HTML hiện tại",
}
}
const formImport = new FormData()
formImport.append("file", file)
formImport.append("preview", "false")
formImport.append("splitMode", splitMode)
if (splitMode === "regex") formImport.append("chapterRegex", chapterStartPattern)
appendEpubSplitFormFields(formImport, splitMode, {
chapterRegex: chapterStartPattern,
chapterTag: getChapterTag(),
})
formImport.append("title", title)
formImport.append("authorName", author)
formImport.append("status", status)
@@ -348,11 +367,12 @@ export function ImportBatchClient() {
<select
className="rounded border px-2 py-1 text-sm"
value={splitMode}
onChange={(e) => setSplitMode(e.target.value as "toc" | "regex")}
onChange={(e) => setSplitMode(e.target.value as EpubSplitMode)}
disabled={running}
>
<option value="toc">TOC</option>
<option value="regex">Regex tiếng Việt</option>
<option value="tag">Thẻ HTML</option>
</select>
{splitMode === "regex" && (
<Input
@@ -363,6 +383,30 @@ export function ImportBatchClient() {
placeholder="Regex bắt đầu chương"
/>
)}
{splitMode === "tag" && (
<>
<select
className="rounded border px-2 py-1 text-sm"
value={tagPreset}
onChange={(e) => setTagPreset(e.target.value)}
disabled={running}
>
{EPUB_HTML_TAG_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
<option value="custom">Tùy chỉnh tên thẻ...</option>
</select>
{tagPreset === "custom" && (
<Input
className="max-w-[120px] font-mono text-xs"
value={customTag}
onChange={(e) => setCustomTag(e.target.value)}
disabled={running}
placeholder="a, h2"
/>
)}
</>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={replaceExisting} onChange={(e) => setReplaceExisting(e.target.checked)} disabled={running} />
+43 -10
View File
@@ -7,6 +7,12 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Progress } from "@/components/ui/progress"
import { toast } from "sonner"
import {
appendEpubSplitFormFields,
DEFAULT_EPUB_CHAPTER_TAG,
EPUB_HTML_TAG_PRESETS,
type EpubSplitMode,
} from "@/lib/epub-split"
type AssetItem = {
id: string
@@ -49,8 +55,15 @@ export function ImportClient() {
const [selectedGenreIds, setSelectedGenreIds] = useState<string[]>([])
const [genreQuery, setGenreQuery] = useState("")
const [addingGenre, setAddingGenre] = useState(false)
const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc")
const [splitMode, setSplitMode] = useState<EpubSplitMode>("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 [tagPreset, setTagPreset] = useState("a")
const [customTag, setCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
const getChapterTag = () => {
if (tagPreset === "custom") return customTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
return EPUB_HTML_TAG_PRESETS.find((p) => p.id === tagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
}
const [replaceExisting, setReplaceExisting] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
@@ -242,8 +255,10 @@ export function ImportClient() {
const form = new FormData()
form.append("file", epubFile)
form.append("preview", "true")
form.append("splitMode", splitMode)
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
appendEpubSplitFormFields(form, splitMode, {
chapterRegex: chapterStartPattern,
chapterTag: getChapterTag(),
})
form.append("title", title)
form.append("authorName", author)
const res = await fetch("/api/mod/epub/ai-suggest", { method: "POST", credentials: "include", body: form })
@@ -291,8 +306,10 @@ export function ImportClient() {
const form = new FormData()
form.append("file", epubFile)
form.append("preview", "true")
form.append("splitMode", splitMode)
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
appendEpubSplitFormFields(form, splitMode, {
chapterRegex: chapterStartPattern,
chapterTag: getChapterTag(),
})
const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form })
const data = await res.json()
if (!res.ok) throw new Error(data?.detail || "Parse preview thất bại")
@@ -306,7 +323,7 @@ export function ImportClient() {
const total = Number(data?.novel?.totalChapters || data?.chapterCount || 0)
setChapterCount(total)
if (total <= 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.")
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 / Thẻ HTML rồi parse lại.")
}
toast.success("Đã tạo preview chương")
} catch (error) {
@@ -324,8 +341,10 @@ export function ImportClient() {
const form = new FormData()
form.append("file", epubFile)
form.append("preview", "false")
form.append("splitMode", splitMode)
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
appendEpubSplitFormFields(form, splitMode, {
chapterRegex: chapterStartPattern,
chapterTag: getChapterTag(),
})
form.append("title", title)
form.append("originalTitle", originalTitle)
form.append("authorName", author)
@@ -351,7 +370,7 @@ export function ImportClient() {
setPreviewItems([])
setChapterCount(0)
setParseError("")
}, [splitMode, chapterStartPattern])
}, [splitMode, chapterStartPattern, tagPreset, customTag])
useEffect(() => {
if (step === 3 && asset) {
@@ -523,13 +542,27 @@ export function ImportClient() {
<section className="space-y-3 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
<label className="text-sm font-medium">Tách chương:</label>
<select className="rounded border px-2 py-1 text-sm" value={splitMode} onChange={(e) => setSplitMode(e.target.value as "toc" | "regex")}>
<select className="rounded border px-2 py-1 text-sm" value={splitMode} onChange={(e) => setSplitMode(e.target.value as EpubSplitMode)}>
<option value="toc">TOC (lọc intro/mục lục)</option>
<option value="regex">Regex tiếng Việt</option>
<option value="tag">Thẻ HTML</option>
</select>
{splitMode === "regex" && (
<Input value={chapterStartPattern} onChange={(e) => setChapterStartPattern(e.target.value)} placeholder="Regex bắt đầu chương" />
)}
{splitMode === "tag" && (
<>
<select className="rounded border px-2 py-1 text-sm" value={tagPreset} onChange={(e) => setTagPreset(e.target.value)}>
{EPUB_HTML_TAG_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
<option value="custom">Tùy chỉnh tên thẻ...</option>
</select>
{tagPreset === "custom" && (
<Input value={customTag} onChange={(e) => setCustomTag(e.target.value)} placeholder="Ví dụ: a, h2" className="max-w-[120px]" />
)}
</>
)}
</div>
<div className="flex items-center justify-between">
<h2 className="font-semibold">Preview chapters</h2>
+83 -88
View File
@@ -18,7 +18,13 @@ import { toast } from "sonner"
import Link from "next/link"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
import { Progress } from "@/components/ui/progress"
import { MOD_AI_PREFILL_STORAGE_KEY, type AINovelPrefillPayload } from "@/lib/mod-ai-tools"
import {
appendEpubSplitFormFields,
DEFAULT_EPUB_CHAPTER_TAG,
EPUB_HTML_TAG_PRESETS,
type EpubSplitMode,
splitModeLabel,
} from "@/lib/epub-split"
interface Novel {
id: string
@@ -37,13 +43,14 @@ interface Genre {
interface EpubPreviewData {
fileName: string
splitMode: "toc" | "regex"
splitMode: EpubSplitMode
detectedStructureType: "standard" | "light_novel"
hasCoverFromEpub?: boolean
coverPreviewDataUrl?: string | null
parserInfo?: {
splitMode: "toc" | "regex"
splitMode: EpubSplitMode
chapterRegexUsed?: string | null
chapterTagUsed?: string | null
regexPreset?: string | null
sourceSections: number
chaptersDetected: number
@@ -125,9 +132,11 @@ export function NovelClient() {
const [epubTitle, setEpubTitle] = useState("")
const [epubAuthorName, setEpubAuthorName] = useState("")
const [epubDescription, setEpubDescription] = useState("")
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("toc")
const [epubSplitMode, setEpubSplitMode] = useState<EpubSplitMode>("toc")
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong_hoi")
const [epubCustomRegex, setEpubCustomRegex] = useState("")
const [epubTagPreset, setEpubTagPreset] = useState<string>("a")
const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
const epubInputRef = useRef<HTMLInputElement>(null)
const epubFolderInputRef = useRef<HTMLInputElement>(null)
@@ -167,7 +176,6 @@ export function NovelClient() {
const [pageSize, setPageSize] = useState(20)
const [bulkProgress, setBulkProgress] = useState<Record<string, BulkUploadProgressItem>>({})
const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState<BulkDuplicateHandling>("ask")
const [pendingAIPrefill, setPendingAIPrefill] = useState<AINovelPrefillPayload | null>(null)
const getSelectedChapterRegex = () => {
if (epubRegexPreset === "custom") {
@@ -177,6 +185,13 @@ export function NovelClient() {
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
}
const getSelectedChapterTag = () => {
if (epubTagPreset === "custom") {
return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
}
return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
}
const normalizeEpubFiles = (files: File[]) => {
return files.filter((file) => file.name.toLowerCase().endsWith(".epub"))
}
@@ -338,79 +353,6 @@ export function NovelClient() {
setGenreQuery("")
}
const applyAIPrefillToAddForm = (prefill: AINovelPrefillPayload) => {
const nextTitle = prefill.title?.trim() || ""
const nextAuthor = prefill.authorName?.trim() || ""
setTitle(nextTitle)
setOriginalTitle((prefill.originalTitle || nextTitle).trim())
setAuthorName(nextAuthor)
setOriginalAuthorName((prefill.originalAuthorName || nextAuthor).trim())
setDescription((prefill.description || "").trim())
setCoverUrl((prefill.coverUrl || "").trim())
setGenreQuery("")
const validStatus = ["Đang ra", "Hoàn thành", "Tạm ngưng"].includes(prefill.status || "")
? (prefill.status as "Đang ra" | "Hoàn thành" | "Tạm ngưng")
: "Đang ra"
setStatus(validStatus)
const suggestedGenres = Array.isArray(prefill.genresSuggested) ? prefill.genresSuggested : []
if (suggestedGenres.length === 0 || genres.length === 0) {
setSelectedGenres([])
return
}
const byName = new Map(genres.map((genre) => [normalizeGenreName(genre.name), genre.id]))
const pickedIds: string[] = []
const missing: string[] = []
for (const name of suggestedGenres) {
const id = byName.get(normalizeGenreName(name))
if (!id) {
missing.push(name)
continue
}
if (!pickedIds.includes(id)) pickedIds.push(id)
}
setSelectedGenres(pickedIds)
if (missing.length > 0) {
toast.info(`The loai goi y chua co san: ${missing.slice(0, 4).join(", ")}`)
}
}
useEffect(() => {
if (typeof window === "undefined") return
const raw = window.localStorage.getItem(MOD_AI_PREFILL_STORAGE_KEY)
if (!raw) return
window.localStorage.removeItem(MOD_AI_PREFILL_STORAGE_KEY)
try {
const parsed = JSON.parse(raw) as AINovelPrefillPayload
setPendingAIPrefill(parsed)
setOpenAdd(true)
} catch {
toast.error("Du lieu AI Tool khong hop le")
}
}, [])
useEffect(() => {
if (!pendingAIPrefill) return
const needsGenres = Array.isArray(pendingAIPrefill.genresSuggested)
&& pendingAIPrefill.genresSuggested.length > 0
&& genres.length === 0
if (needsGenres) return
applyAIPrefillToAddForm(pendingAIPrefill)
setPendingAIPrefill(null)
toast.success("Da nap du lieu de xuat tu AI Tool")
}, [pendingAIPrefill, genres])
const filteredNovels = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
if (!keyword) return novels
@@ -739,7 +681,7 @@ export function NovelClient() {
const requestEpubPreview = async (
file: File,
options?: {
splitMode?: "toc" | "regex"
splitMode?: EpubSplitMode
regexPreset?: string
regexInput?: string
preserveEditedMetadata?: boolean
@@ -758,11 +700,12 @@ export function NovelClient() {
const formData = new FormData()
formData.append("file", file)
formData.append("preview", "true")
formData.append("splitMode", splitMode)
appendEpubSplitFormFields(formData, splitMode, {
chapterRegex: regexInput,
chapterTag: getSelectedChapterTag(),
})
if (splitMode === "regex") {
formData.append("chapterRegexPreset", regexPreset)
formData.append("chapterRegex", regexInput)
}
const res = await fetch("/api/mod/epub", {
@@ -873,6 +816,10 @@ export function NovelClient() {
toast.error("Vui lòng nhập regex tùy chỉnh trước khi tải lên")
return
}
if (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim()) {
toast.error("Vui lòng nhập tên thẻ HTML trước khi tải lên")
return
}
setUploadingEpub(true)
const formData = new FormData()
@@ -880,12 +827,13 @@ export function NovelClient() {
formData.append("title", epubTitle)
formData.append("authorName", epubAuthorName)
formData.append("description", epubDescription)
formData.append("splitMode", epubSplitMode)
appendEpubSplitFormFields(formData, epubSplitMode, {
chapterRegex: epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex(),
chapterTag: getSelectedChapterTag(),
})
if (epubSplitMode === "regex") {
const selectedRegex = epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex()
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("chapterRegex", selectedRegex)
}
try {
@@ -1286,7 +1234,8 @@ export function NovelClient() {
<>
<p>
<span className="font-semibold">Parser:</span>{" "}
{epubPreviewData.parserInfo.splitMode === "regex" ? "Regex" : "TOC"}
{splitModeLabel(epubPreviewData.parserInfo.splitMode)}
{epubPreviewData.parserInfo.chapterTagUsed ? ` (${epubPreviewData.parserInfo.chapterTagUsed})` : ""}
</p>
<p>
<span className="font-semibold">Nguồn phân tích:</span>{" "}
@@ -1310,6 +1259,12 @@ export function NovelClient() {
{epubPreviewData.parserInfo.chapterRegexUsed}
</p>
)}
{epubPreviewData.parserInfo.chapterTagUsed && (
<p>
<span className="font-semibold">Thẻ HTML dùng:</span>{" "}
&lt;{epubPreviewData.parserInfo.chapterTagUsed}&gt;
</p>
)}
</>
)}
</div>
@@ -1321,14 +1276,31 @@ export function NovelClient() {
<label className="text-xs font-medium text-muted-foreground">Chế đ tách chương</label>
<select
value={epubSplitMode}
onChange={(e) => setEpubSplitMode(e.target.value as "toc" | "regex")}
onChange={(e) => setEpubSplitMode(e.target.value as EpubSplitMode)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="toc">Theo TOC trong EPUB</option>
<option value="regex">Theo Regex</option>
<option value="tag">Theo thẻ HTML</option>
</select>
</div>
{epubSplitMode === "tag" && (
<div className="grid gap-2">
<label className="text-xs font-medium text-muted-foreground">Thẻ tách chương</label>
<select
value={epubTagPreset}
onChange={(e) => setEpubTagPreset(e.target.value)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{EPUB_HTML_TAG_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
<option value="custom">Tự nhập tên thẻ</option>
</select>
</div>
)}
{epubSplitMode === "regex" && (
<div className="grid gap-2">
<label className="text-xs font-medium text-muted-foreground">Preset Regex</label>
@@ -1365,12 +1337,35 @@ export function NovelClient() {
</>
)}
{epubSplitMode === "tag" && (
<>
{epubTagPreset !== "custom" ? (
<div className="rounded-md bg-muted/40 p-2 text-xs">
Mỗi thẻ &lt;{getSelectedChapterTag()}&gt; mở đu một chương. Tiêu đ lấy từ nội dung trong thẻ (nếu ).
</div>
) : (
<div className="grid gap-2">
<label className="text-xs font-medium text-muted-foreground">Tên thẻ HTML</label>
<Input
value={epubCustomTag}
onChange={(e) => setEpubCustomTag(e.target.value)}
placeholder="Ví dụ: a, h2, h1"
/>
</div>
)}
</>
)}
<div className="flex justify-end">
<Button
type="button"
variant="secondary"
onClick={handleReparseEpub}
disabled={previewingEpub || (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim())}
disabled={
previewingEpub
|| (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim())
|| (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim())
}
>
{previewingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Phân tích lại theo cấu hình
+5 -1
View File
@@ -68,6 +68,9 @@ async function initializeGoogleIdentity(clientId: string): Promise<void> {
if (googleInitializedClientId === clientId) return
const hostname = typeof window !== "undefined" ? window.location.hostname : ""
const isLocalHost = hostname === "localhost" || hostname === "127.0.0.1"
// Re-initialize with new clientId (or first time)
googleApi.initialize({
client_id: clientId,
@@ -86,7 +89,8 @@ async function initializeGoogleIdentity(clientId: string): Promise<void> {
},
auto_select: false,
cancel_on_tap_outside: true,
use_fedcm_for_prompt: true,
// FedCM on localhost often yields tokens backend cannot verify; production keeps FedCM.
use_fedcm_for_prompt: !isLocalHost,
})
googleInitializedClientId = clientId
+31
View File
@@ -0,0 +1,31 @@
export type EpubSplitMode = "toc" | "regex" | "tag"
export const EPUB_HTML_TAG_PRESETS = [
{ id: "a", name: "Thẻ <a> (anchor / mục lục liên kết)", tag: "a" },
{ id: "h2", name: "Thẻ <h2>", tag: "h2" },
{ id: "h1", name: "Thẻ <h1>", tag: "h1" },
{ id: "h3", name: "Thẻ <h3>", tag: "h3" },
{ id: "p", name: "Thẻ <p>", tag: "p" },
] as const
export const DEFAULT_EPUB_CHAPTER_TAG = "a"
export function splitModeLabel(mode: string | undefined): string {
if (mode === "regex") return "Regex"
if (mode === "tag") return "Thẻ HTML"
return "TOC"
}
export function appendEpubSplitFormFields(
form: FormData,
splitMode: EpubSplitMode,
options?: { chapterRegex?: string; chapterTag?: string },
) {
form.append("splitMode", splitMode)
if (splitMode === "regex" && options?.chapterRegex) {
form.append("chapterRegex", options.chapterRegex)
}
if (splitMode === "tag") {
form.append("chapterTag", (options?.chapterTag || DEFAULT_EPUB_CHAPTER_TAG).trim() || DEFAULT_EPUB_CHAPTER_TAG)
}
}
-25
View File
@@ -1,25 +0,0 @@
export const MOD_AI_PREFILL_STORAGE_KEY = "mod:ai-tool:novel-prefill"
export const MOD_AI_MODEL_STORAGE_KEY = "mod:ai-tool:model"
export const MOD_AI_WEB_DEFAULT_MODEL = "gpt-4o-mini-search-preview"
export const MOD_AI_WEB_MODEL_OPTIONS = [
{
value: "gpt-4o-mini-search-preview",
label: "gpt-4o-mini-search-preview (nhanh)",
},
{
value: "gpt-4o-search-preview",
label: "gpt-4o-search-preview (chat luong cao)",
},
] as const
export type AINovelPrefillPayload = {
title?: string
originalTitle?: string
authorName?: string
originalAuthorName?: string
description?: string
coverUrl?: string
status?: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested?: string[]
}
-68
View File
@@ -1,68 +0,0 @@
require('dotenv').config({ path: '.env' });
async function fetchJsonWithTimeout(url, init, timeoutMs = 25000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${text.slice(0, 500)}`);
}
return await res.json();
} finally {
clearTimeout(timeout);
}
}
const prompt = "Please reply with { \"ready\": true } in JSON";
async function tryGoogle() {
const apiKey = process.env.GOOGLE_AI_KEY;
if (!apiKey) return console.log("Google: No key");
try {
const data = await fetchJsonWithTimeout(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
tools: [{ googleSearch: {} }],
generationConfig: { temperature: 0.2, maxOutputTokens: 1400 },
}),
}
);
console.log("Google Success:", JSON.stringify(data).slice(0, 100));
} catch(e) { console.log("Google Error:", e.stack || e.message); }
}
async function tryDeepSeek() {
const apiKey = process.env.DEEKSEEK_KEY || process.env.DEEPSEEK_KEY;
if (!apiKey) return console.log("DeepSeek: No key");
try {
const data = await fetchJsonWithTimeout(
"https://api.deepseek.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
temperature: 0.2,
max_tokens: 500,
messages: [{ role: "user", content: prompt }],
}),
}
);
console.log("DeepSeek Success:", JSON.stringify(data).slice(0,100));
} catch(e) { console.log("DeepSeek Error:", e.stack || e.message); }
}
async function run() {
await tryGoogle();
await tryDeepSeek();
}
run();
+1 -1
View File
File diff suppressed because one or more lines are too long