Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-23 11:12:56 +07:00
parent e345d9ccce
commit ffd177718f
39 changed files with 5258 additions and 520 deletions
File diff suppressed because it is too large Load Diff
+450 -48
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, Suspense, useRef } from "react"
import { useState, useEffect, Suspense, useRef, Fragment } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -15,12 +15,34 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search } from "lucide-react"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
// @ts-ignore
import * as mammoth from "mammoth"
const CHAPTER_REGEX_PRESETS = [
{
id: "vi_chuong_hoi",
name: "VN - Chương/Hồi/Tiết/Phần 1: ...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "mix_chapter",
name: "Mixed - Chương/Hồi/Chapter...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "numeric_only",
name: "Chỉ có số (1. ...)",
pattern: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$",
},
]
interface Chapter {
_id: string
number: number
@@ -32,6 +54,33 @@ interface Chapter {
createdAt: string
}
interface EpubPreviewData {
preview: true
fileName: string
splitMode: "toc" | "regex"
detectedStructureType: "light_novel" | "standard"
parserInfo?: {
splitMode: string
chapterRegexUsed?: string
regexPreset?: string
sourceSections: number
chaptersDetected: number
chaptersFinal: number
insertedMissingChapters: number
detectedMaxChapterNumber: number
detectedNumberAssignments: number
}
chaptersPreview: Array<{
number: number
title: string
isPlaceholder: boolean
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
excerpt: string
}>
}
const generatePagination = (currentPage: number, totalPages: number) => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
@@ -77,7 +126,10 @@ function ChapterManager() {
// Delete states
const [openDelete, setOpenDelete] = useState(false)
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
const [openBulkDelete, setOpenBulkDelete] = useState(false)
const [bulkDeleteFrom, setBulkDeleteFrom] = useState("")
const [bulkDeleteTo, setBulkDeleteTo] = useState("")
const [bulkDeleting, setBulkDeleting] = useState(false)
// Form states
const [number, setNumber] = useState("")
@@ -93,6 +145,135 @@ function ChapterManager() {
const [uploadProgress, setUploadProgress] = useState(0)
const [totalUpload, setTotalUpload] = useState(0)
// EPUB append states
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 [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi")
const [epubCustomRegex, setEpubCustomRegex] = useState("")
const [appendingEpub, setAppendingEpub] = useState(false)
const [epubPreviewData, setEpubPreviewData] = useState<EpubPreviewData | null>(null)
const getEpubSourceRegex = () => {
if (epubRegexPreset === "custom") {
return epubCustomRegex.trim()
}
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
}
const handleEpubAppendPreview = async () => {
if (!epubFile || !novelId) return
setAppendingEpub(true)
setEpubPreviewData(null)
const toastId = toast.loading("Đang đọc và phân tích EPUB...")
try {
const formData = new FormData()
formData.append("file", epubFile)
formData.append("title", "Append Ebook")
formData.append("splitMode", epubSplitMode)
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("appendTargetNovelId", novelId)
formData.append("preview", "true")
const xhr = new XMLHttpRequest()
const result = await new Promise<{ status: number; ok: boolean; data: any }>((resolve, reject) => {
xhr.open("POST", "/api/mod/epub")
xhr.timeout = 250000
xhr.onload = () => resolve({ status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText || "{}") })
xhr.onerror = () => reject(new Error("Lỗi mạng khi phân tích EPUB"))
xhr.ontimeout = () => reject(new Error("Quá thời gian chờ khi xử lý EPUB"))
xhr.send(formData)
})
if (!result.ok) {
throw new Error(result.data.error || "Phân tích EPUB thất bại")
}
setEpubPreviewData(result.data)
toast.success("Phân tích thành công. Vui lòng kiểm tra lại trước khi chèn.", { id: toastId })
} catch (error: any) {
console.error(error)
toast.error(error.message || "Lỗi khi phân tích file EPUB", { id: toastId })
} finally {
setAppendingEpub(false)
}
}
const handleEpubAppendSubmit = async () => {
if (!epubFile || !novelId) return
setAppendingEpub(true)
const toastId = toast.loading("Đang đọc và tách chương từ EPUB...")
try {
const formData = new FormData()
formData.append("file", epubFile)
formData.append("title", "Append Ebook")
formData.append("splitMode", epubSplitMode)
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
formData.append("chapterRegexPreset", epubRegexPreset)
formData.append("appendTargetNovelId", novelId)
const xhr = new XMLHttpRequest()
const result = await new Promise<{ status: number; ok: boolean; data: any }>((resolve, reject) => {
xhr.open("POST", "/api/mod/epub")
xhr.timeout = 250000
xhr.onload = () => resolve({ status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText || "{}") })
xhr.onerror = () => reject(new Error("Lỗi mạng khi upload EPUB"))
xhr.ontimeout = () => reject(new Error("Quá thời gian chờ khi xử lý EPUB"))
xhr.send(formData)
})
if (!result.ok) {
throw new Error(result.data.error || "Nhập EPUB thất bại")
}
toast.success(`Nhập EPUB thành công! Đã chêm thêm ${result.data.parserInfo?.chaptersFinal || result.data.totalChapters} chương.`, { id: toastId })
setEpubPreviewData(null)
setOpenEpubAppend(false)
setEpubFile(null)
fetchChapters()
} catch (error: any) {
toast.error(error.message, { id: toastId })
} finally {
setAppendingEpub(false)
if (epubInputRef.current) epubInputRef.current.value = ""
}
}
const handleBulkDeleteSubmit = async () => {
if (!bulkDeleteFrom || !bulkDeleteTo || !novelId) return
setBulkDeleting(true)
const toastId = toast.loading("Đang xóa hàng loạt chương...")
try {
const res = await fetch("/api/mod/chuong/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
novelId,
fromNumber: Number(bulkDeleteFrom),
toNumber: Number(bulkDeleteTo)
})
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Xóa thất bại")
toast.success(`Đã xóa thành công ${data.deletedCount} chương!`, { id: toastId })
setOpenBulkDelete(false)
setBulkDeleteFrom("")
setBulkDeleteTo("")
fetchChapters()
} catch (error: any) {
toast.error(error.message, { id: toastId })
} finally {
setBulkDeleting(false)
}
}
const fetchChapters = async (pageToFetch = 1) => {
if (!novelId) return
setLoading(true)
@@ -397,6 +578,164 @@ function ChapterManager() {
<Wand2 className="h-4 w-4" /> Tối ưu hóa
</Button>
<Button variant="destructive" className="gap-2" onClick={() => setOpenBulkDelete(true)} disabled={chapters.length === 0}>
<Trash2 className="h-4 w-4" /> Xóa theo khoảng
</Button>
<input
type="file"
ref={epubInputRef}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
setEpubPreviewData(null)
setEpubSplitMode("regex")
setEpubFile(file)
setOpenEpubAppend(true)
}
if (e.target) e.target.value = ""
}}
accept=".epub"
className="hidden"
/>
<Button variant="secondary" className="gap-2" onClick={() => epubInputRef.current?.click()}>
<BookOpen className="h-4 w-4" /> Nhập từ EPUB
</Button>
<Dialog open={openEpubAppend} onOpenChange={(val) => {
setOpenEpubAppend(val)
if (!val) {
setEpubPreviewData(null)
if (epubInputRef.current) epubInputRef.current.value = ""
}
}}>
<DialogContent className="sm:max-w-[750px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bổ sung chương từ file EPUB</DialogTitle>
<DialogDescription>
Đc trích xuất hàng loạt chương từ file EPUB vào truyện này.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-4 flex-1 overflow-y-auto pr-2 pb-4">
<div className="space-y-2 rounded-md border p-4 bg-muted/50">
<h3 className="font-semibold text-sm">Chế đ ghi đè thông minh (Smart Merge Overwrite)</h3>
<p className="text-sm text-muted-foreground">
Hệ thống sẽ tự đng ghép nối các dải chương. Những chương thiếu sẽ đưc điền khuyết (Insert), những chương mới sẽ đưc chêm vào. Các chương cùng số thứ tự sẽ bị <strong>Ghi đè</strong> bằng nội dung lấy từ EPUB đ đm bảo đ chính xác.
</p>
</div>
<div className="space-y-4">
<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") => {
setEpubSplitMode(v)
setEpubPreviewData(null)
}} className="flex 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>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="regex" id="regex_mode_a" />
<Label htmlFor="regex_mode_a" className="cursor-pointer font-normal">Quy tắc (Regex)</Label>
</div>
</RadioGroup>
</div>
{epubSplitMode === "regex" && (
<div className="space-y-3 pt-2">
<div className="flex flex-col gap-2">
<Label>Mẫu Regex sẵn</Label>
<Select value={epubRegexPreset} onValueChange={(v) => {
setEpubRegexPreset(v)
setEpubPreviewData(null)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHAPTER_REGEX_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
<SelectItem value="custom">Nâng cao (Tùy chỉnh Regex)...</SelectItem>
</SelectContent>
</Select>
</div>
{epubRegexPreset === "custom" && (
<div className="flex flex-col gap-2">
<Label>Regex Tùy Chỉnh</Label>
<Input
value={epubCustomRegex}
onChange={(e) => {
setEpubCustomRegex(e.target.value)
setEpubPreviewData(null)
}}
placeholder="Vd: /^(Chương|Hồi) \d+/gm"
/>
</div>
)}
</div>
)}
</div>
{epubPreviewData && epubPreviewData.parserInfo && (
<div className="space-y-4 border-t pt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="font-semibold text-sm">Kết quả trích xuất dự kiến (30 mục đu)</h3>
<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>
</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.
</div>
) : (
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-2 bg-card">
{epubPreviewData.chaptersPreview.map((chapter) => (
<div key={`preview-${chapter.number}`} className="flex border rounded overflow-hidden shadow-sm text-sm">
<div className="w-16 bg-muted border-r flex flex-col justify-center items-center shrink-0">
<span className="text-xs text-muted-foreground">Ch.</span>
<span className="font-semibold">{chapter.number}</span>
</div>
<div className="p-3 flex-1 min-w-0">
<h4 className="font-semibold mb-1 truncate text-foreground flex items-center gap-2">
{chapter.title}
{chapter.isPlaceholder && (
<span className="bg-destructive/10 text-destructive text-[10px] px-1.5 py-0.5 rounded uppercase font-bold">Lỗi tách chờ XL</span>
)}
</h4>
<p className="text-xs text-muted-foreground line-clamp-2">{chapter.excerpt}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter className="mt-auto pt-4 border-t">
<Button variant="outline" onClick={() => setOpenEpubAppend(false)} disabled={appendingEpub}>Huỷ</Button>
{!epubPreviewData ? (
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub}>
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xem trước dữ liệu
</Button>
) : (
<Button onClick={handleEpubAppendSubmit} disabled={appendingEpub || epubPreviewData.chaptersPreview.length === 0}>
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xác nhận ghi đè
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<input
type="file"
ref={fileInputRef}
@@ -424,30 +763,34 @@ function ChapterManager() {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
<div className="grid grid-cols-6 gap-4">
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Chương số</label>
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} required />
<div className="grid grid-cols-6 gap-4 border-b pb-4">
<div className="space-y-2 col-span-2">
<label className="text-sm font-medium text-primary">Chương số</label>
<Input type="number" step="any" value={number} onChange={(e) => setNumber(e.target.value)} required />
</div>
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Quyển số</label>
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
</div>
<div className="space-y-2 col-span-1">
<label className="text-sm font-medium">Chương trong quyển</label>
<Input type="number" value={volumeChapterNumber} onChange={(e) => setVolumeChapterNumber(e.target.value)} placeholder="VD: 5" />
</div>
<div className="space-y-2 col-span-3">
<label className="text-sm font-medium">Tên quyển (Tuỳ chọn)</label>
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Quyển 1 - Khởi đầu" />
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2 col-span-4">
<label className="text-sm font-medium">Tên chương</label>
<label className="text-sm font-medium text-primary">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Thiếu niên kỳ bạt" required autoFocus />
</div>
</div>
<details className="group border rounded-lg [&_summary::-webkit-details-marker]:hidden">
<summary className="flex cursor-pointer items-center justify-between px-4 py-2 bg-muted/30 font-medium">
<span className="text-sm">Tùy chọn nâng cao (Quyển / Tập)</span>
<span className="transition duration-300 group-open:-rotate-180">
<svg fill="none" height="18" shape-rendering="geometricPrecision" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" viewBox="0 0 24 24" width="18"><path d="M6 9l6 6 6-6"></path></svg>
</span>
</summary>
<div className="grid grid-cols-6 gap-4 p-4 text-muted-foreground bg-card">
<div className="space-y-2 col-span-2">
<label className="text-xs font-medium">Quyển số</label>
<Input type="number" value={volumeNumber} onChange={(e) => setVolumeNumber(e.target.value)} placeholder="VD: 1" />
</div>
<div className="space-y-2 col-span-4">
<label className="text-xs font-medium">Tên quyển</label>
<Input value={volumeTitle} onChange={(e) => setVolumeTitle(e.target.value)} placeholder="VD: Khởi đầu mới" />
</div>
</div>
</details>
<div className="space-y-2 flex-1 flex flex-col h-full">
<label className="text-sm font-medium">Nội dung văn bản (Hỗ trợ xuống dòng)</label>
<Textarea
@@ -468,6 +811,38 @@ function ChapterManager() {
</DialogContent>
</Dialog>
<Dialog open={openBulkDelete} onOpenChange={setOpenBulkDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2">
<Trash2 className="h-5 w-5" /> Xóa hàng loạt chương
</DialogTitle>
<DialogDescription>
Cẩn thận! Thao tác này sẽ xóa vĩnh viễn các chương nằm trong khoảng bạn chọn. Không thể khôi phục!
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Từ chương số</Label>
<Input type="number" value={bulkDeleteFrom} onChange={(e) => setBulkDeleteFrom(e.target.value)} placeholder="Ví dụ: 10" />
</div>
<div className="space-y-2">
<Label>Đến chương số</Label>
<Input type="number" value={bulkDeleteTo} onChange={(e) => setBulkDeleteTo(e.target.value)} placeholder="Ví dụ: 20" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenBulkDelete(false)} disabled={bulkDeleting}>Huỷ</Button>
<Button variant="destructive" onClick={handleBulkDeleteSubmit} disabled={bulkDeleting || !bulkDeleteFrom || !bulkDeleteTo}>
{bulkDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xác nhận Xóa
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
@@ -577,7 +952,7 @@ function ChapterManager() {
</div>
<div className="rounded-xl border bg-card shadow-sm">
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-hidden">
<table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr>
@@ -594,32 +969,59 @@ function ChapterManager() {
) : chapters.length === 0 ? (
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Chưa chương nào đưc đăng.</td></tr>
) : (
chapters.map((ch) => (
<tr key={ch._id} className="border-b border-border hover:bg-muted/30 transition-colors last:border-0">
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
<td className="px-5 py-4 text-muted-foreground">
{ch.volumeNumber || ch.volumeTitle
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
: "-"}
</td>
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link href={`/mod/chuong/${ch._id}`}>
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50">
<Edit className="w-4 h-4 mr-1" /> Sửa
chapters.map((ch, index) => (
<Fragment key={ch._id}>
<tr className="border-b border-border hover:bg-muted/30 transition-colors last:border-0 relative group">
<td className="px-5 py-4 font-medium text-foreground">Chương {ch.number}</td>
<td className="px-5 py-4 text-muted-foreground">
{ch.volumeNumber || ch.volumeTitle
? `${ch.volumeTitle || `Quyển ${ch.volumeNumber}`}${ch.volumeChapterNumber ? ` · Ch.${ch.volumeChapterNumber}` : ""}`
: "-"}
</td>
<td className="px-5 py-4 text-muted-foreground">{ch.title}</td>
<td className="px-5 py-4 text-right">{ch.views}</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link href={`/mod/chuong/${ch._id}`}>
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50">
<Edit className="w-4 h-4 mr-1" /> Sửa
</Button>
</Link>
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
setDeletingChapterId(ch._id)
setOpenDelete(true)
}}>
<Trash2 className="w-4 h-4 mr-1" /> Xóa
</Button>
</Link>
<Button size="sm" variant="outline" className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
setDeletingChapterId(ch._id)
setOpenDelete(true)
}}>
<Trash2 className="w-4 h-4 mr-1" /> Xóa
</Button>
</div>
</td>
</tr>
</div>
</td>
</tr>
<tr className="group/insert">
<td colSpan={5} className="p-0 border-none relative">
<div className="h-2 flex items-center justify-center opacity-0 group-hover/insert:opacity-100 transition-opacity -my-1 z-10">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-dashed border-primary/50"></div></div>
<Button
size="sm"
variant="outline"
className="h-6 rounded-full px-2 text-primary relative z-20 bg-background hover:bg-primary hover:text-primary-foreground text-xs"
onClick={() => {
const nextCh = chapters[index + 1]
let suggestedNum = ch.number + 1
if (nextCh && nextCh.number <= ch.number + 1) {
suggestedNum = Number((ch.number + 0.1).toFixed(2))
}
setNumber(suggestedNum.toString())
setTitle("")
setContent("")
setOpenAdd(true)
}}
>
<Plus className="w-3 h-3 mr-1" /> Chèn chương đây
</Button>
</div>
</td>
</tr>
</Fragment>
))
)}
</tbody>
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { AlertTriangle, BookOpen, Home, Sparkles, Star, ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
export function CollapsibleSidebar() {
const pathname = usePathname()
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
const saved = localStorage.getItem("mod-sidebar-collapsed")
if (saved === "true") setCollapsed(true)
}, [])
const toggle = () => {
const next = !collapsed
setCollapsed(next)
localStorage.setItem("mod-sidebar-collapsed", String(next))
}
if (!mounted) {
return <aside className="w-64 border-r bg-background p-4 hidden md:block transition-all duration-300"></aside>
}
const navItems = [
{ href: "/mod", label: "Tổng quan", icon: Home },
{ 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/de-cu", label: "Quản lý đề cử", icon: Star },
{ href: "/mod/ai-tool", label: "AI Tool", icon: Sparkles },
]
return (
<aside className={cn(
"border-r bg-background hidden md:flex flex-col relative transition-all duration-300",
collapsed ? "w-16 items-center py-4" : "w-64 p-4"
)}>
<button
onClick={toggle}
className="absolute -right-3 top-6 bg-background border rounded-full p-1 hover:bg-muted text-muted-foreground hover:text-foreground z-10 transition-transform shadow-sm"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
{!collapsed && <h2 className="mb-6 px-2 text-lg font-bold whitespace-nowrap overflow-hidden">Mod Dashboard</h2>}
{collapsed && <div className="mb-6 h-7" />}
<nav className="flex flex-col gap-2 w-full">
{navItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center rounded-md font-medium transition-colors hover:bg-muted group relative",
collapsed ? "justify-center p-2 mx-auto w-10 h-10" : "px-3 py-2 gap-3 text-sm",
isActive ? "bg-primary/10 text-primary hover:bg-primary/20" : "text-muted-foreground"
)}
title={collapsed ? item.label : undefined}
>
<Icon className={cn("shrink-0", collapsed ? "h-5 w-5" : "h-4 w-4")} />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{item.label}</span>}
{collapsed && (
<div className="absolute left-full ml-3 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible whitespace-nowrap z-50 shadow-md border">
{item.label}
</div>
)}
</Link>
)
})}
</nav>
</aside>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { RecommendationClient } from "./recommendation-client"
export default async function ModRecommendationPage() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <RecommendationClient />
}
+365
View File
@@ -0,0 +1,365 @@
"use client"
import Link from "next/link"
import { FormEvent, useEffect, useMemo, useState } from "react"
import { Loader2, Search, Star, Trash2, UserRoundCheck } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type NovelLite = {
id: string
title: string
slug: string
authorName: string
coverUrl: string | null
status: string
totalChapters: number
}
type RecommendationItem = {
id: string
createdAt: string | null
recommendCount: number
novel: NovelLite
editor: {
id: string
name: string
}
}
type SummaryItem = {
novel: NovelLite
recommendCount: number
}
type CandidateNovel = NovelLite & {
alreadyRecommended: boolean
recommendCount: number
}
type RecommendationResponse = {
items: RecommendationItem[]
summary: SummaryItem[]
candidates: CandidateNovel[]
myNovelIds: string[]
currentUser: {
id: string
role: string
recommendationCount: number
maxRecommendationCount: number
}
}
function formatRelativeTime(value: string | null): string {
if (!value) return "Vừa đề cử"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "Vừa đề cử"
const diff = Date.now() - date.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return "Vừa xong"
if (diff < hour) return `${Math.floor(diff / minute)} phút trước`
if (diff < day) return `${Math.floor(diff / hour)} giờ trước`
if (diff < day * 30) return `${Math.floor(diff / day)} ngày trước`
return date.toLocaleDateString("vi-VN")
}
export function RecommendationClient() {
const [loading, setLoading] = useState(true)
const [searching, setSearching] = useState(false)
const [submittingNovelId, setSubmittingNovelId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [keyword, setKeyword] = useState("")
const [activeQuery, setActiveQuery] = useState("")
const [items, setItems] = useState<RecommendationItem[]>([])
const [summary, setSummary] = useState<SummaryItem[]>([])
const [candidates, setCandidates] = useState<CandidateNovel[]>([])
const [currentUser, setCurrentUser] = useState<{
id: string
role: string
recommendationCount: number
maxRecommendationCount: number
} | null>(null)
const fetchData = async (query: string, initial = false) => {
if (initial) {
setLoading(true)
} else {
setSearching(true)
}
try {
const url = query.trim() ? `/api/mod/de-cu?q=${encodeURIComponent(query.trim())}` : "/api/mod/de-cu"
const res = await fetch(url)
const data = (await res.json()) as RecommendationResponse & { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể tải dữ liệu đề cử")
}
setItems(data.items || [])
setSummary(data.summary || [])
setCandidates(data.candidates || [])
setCurrentUser(data.currentUser || null)
setActiveQuery(query.trim())
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể tải dữ liệu đề cử"
toast.error(message)
} finally {
setLoading(false)
setSearching(false)
}
}
useEffect(() => {
fetchData("", true)
}, [])
const handleSearch = async (e: FormEvent) => {
e.preventDefault()
await fetchData(keyword)
}
const handleRecommend = async (novelId: string) => {
setSubmittingNovelId(novelId)
try {
const res = await fetch("/api/mod/de-cu", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể thêm đề cử")
}
toast.success("Đã thêm đề cử")
await fetchData(activeQuery)
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể thêm đề cử"
toast.error(message)
} finally {
setSubmittingNovelId(null)
}
}
const handleDelete = async (id: string) => {
if (!confirm("Bạn có chắc muốn xóa đề cử này?")) return
setDeletingId(id)
try {
const res = await fetch(`/api/mod/de-cu?id=${encodeURIComponent(id)}`, { method: "DELETE" })
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể xóa đề cử")
}
toast.success("Đã xóa đề cử")
await fetchData(activeQuery)
} catch (error) {
const message = error instanceof Error ? error.message : "Không thể xóa đề cử"
toast.error(message)
} finally {
setDeletingId(null)
}
}
const canDelete = (item: RecommendationItem) => {
if (!currentUser) return false
return currentUser.role === "ADMIN" || currentUser.id === item.editor.id
}
const rankedSummary = useMemo(() => summary.slice(0, 12), [summary])
const hasReachedLimit =
Boolean(currentUser) &&
(currentUser?.recommendationCount || 0) >= (currentUser?.maxRecommendationCount || 0)
return (
<div className="space-y-6">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<UserRoundCheck className="h-6 w-6 text-primary" /> Quản đ cử biên tập
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Mỗi biên tập viên thể đ cử truyện thủ công. Trang chủ sẽ lấy dữ liệu từ danh sách này.
</p>
{currentUser && (
<p className="mt-2 text-xs text-primary">
Bạn đang dùng {currentUser.recommendationCount}/{currentUser.maxRecommendationCount} đ cử.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="space-y-4 xl:col-span-1">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h2 className="mb-3 text-base font-semibold">Tìm truyện đ đ cử</h2>
<form onSubmit={handleSearch} className="flex gap-2">
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Nhập tên truyện, tác giả hoặc slug"
/>
<Button type="submit" variant="outline" className="gap-2" disabled={searching}>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Tìm
</Button>
</form>
<div className="mt-3 space-y-2">
{searching ? (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Đang tìm...
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">Nhập từ khóa rồi bấm tìm đ hiện danh sách truyện.</p>
) : (
candidates.map((novel) => (
<div key={novel.id} className="rounded-lg border border-border bg-background/60 p-2.5">
<div className="flex items-start gap-2">
<img
src={novel.coverUrl || "/default-cover.svg"}
alt={novel.title}
className="h-14 w-10 rounded border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<Link href={`/truyen/${novel.slug}`} className="line-clamp-1 text-sm font-semibold hover:text-primary">
{novel.title}
</Link>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
<p className="text-xs text-muted-foreground">{novel.recommendCount} đ cử</p>
</div>
</div>
<Button
type="button"
size="sm"
className="mt-2 w-full gap-2"
disabled={
novel.alreadyRecommended ||
submittingNovelId === novel.id ||
(hasReachedLimit && !novel.alreadyRecommended)
}
onClick={() => handleRecommend(novel.id)}
>
{submittingNovelId === novel.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Star className="h-4 w-4" />}
{novel.alreadyRecommended
? "Bạn đã đề cử"
: hasReachedLimit
? "Đã đạt giới hạn 5 truyện"
: "Đề cử truyện này"}
</Button>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<h2 className="mb-3 text-base font-semibold">Top theo số lượng đ cử</h2>
<div className="space-y-2">
{rankedSummary.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa dữ liệu đ cử.</p>
) : (
rankedSummary.map((item, index) => (
<Link
key={item.novel.id}
href={`/truyen/${item.novel.slug}`}
className="flex items-center gap-2 rounded-lg border border-border bg-background/60 p-2.5 hover:border-primary/40"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{index + 1}
</span>
<img
src={item.novel.coverUrl || "/default-cover.svg"}
alt={item.novel.title}
className="h-12 w-9 rounded border border-border/70 object-cover"
/>
<div className="min-w-0 flex-1">
<p className="line-clamp-1 text-sm font-medium">{item.novel.title}</p>
<p className="text-xs text-muted-foreground">{item.recommendCount} đ cử</p>
</div>
</Link>
))
)}
</div>
</div>
</div>
<div className="rounded-xl border bg-card shadow-sm xl:col-span-2">
<div className="border-b border-border p-4">
<h2 className="text-base font-semibold">Danh sách đ cử hiện tại</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-4 py-3 font-semibold">Truyện</th>
<th className="px-4 py-3 font-semibold">Biên tập viên</th>
<th className="px-4 py-3 font-semibold">Tổng đ cử</th>
<th className="px-4 py-3 font-semibold">Thời gian</th>
<th className="px-4 py-3 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Chưa đ cử nào
</td>
</tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link href={`/truyen/${item.novel.slug}`} className="font-medium hover:text-primary">
{item.novel.title}
</Link>
<p className="text-xs text-muted-foreground">{item.novel.authorName}</p>
</td>
<td className="px-4 py-3">{item.editor.name}</td>
<td className="px-4 py-3">{item.recommendCount}</td>
<td className="px-4 py-3 text-muted-foreground">{formatRelativeTime(item.createdAt)}</td>
<td className="px-4 py-3 text-right">
{canDelete(item) ? (
<Button
variant="outline"
size="sm"
className="gap-1.5 text-red-600 border-red-200 hover:bg-red-50"
onClick={() => handleDelete(item.id)}
disabled={deletingId === item.id}
>
{deletingId === item.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
Xóa
</Button>
) : (
<span className="text-xs text-muted-foreground">Không quyền xóa</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
+2 -17
View File
@@ -1,8 +1,7 @@
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import Link from "next/link"
import { AlertTriangle, BookOpen, Home } from "lucide-react"
import { CollapsibleSidebar } from "./collapsible-sidebar"
export default async function ModLayout({
children,
@@ -18,21 +17,7 @@ export default async function ModLayout({
return (
<div className="flex min-h-[calc(100vh-3.5rem)] bg-muted/20">
{/* Sidebar */}
<aside className="w-64 border-r bg-background p-4 hidden md:block">
<h2 className="mb-6 px-2 text-lg font-bold">Mod Dashboard</h2>
<nav className="flex flex-col gap-2">
<Link href="/mod" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<Home className="h-4 w-4" /> Tổng quan
</Link>
<Link href="/mod/truyen" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<BookOpen className="h-4 w-4" /> Quản truyện
</Link>
<Link href="/mod/thieu-thong-tin" className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary">
<AlertTriangle className="h-4 w-4" /> Truyện thiếu dữ liệu
</Link>
</nav>
</aside>
<CollapsibleSidebar />
{/* Main Content */}
<main className="flex-1 p-6">
+18
View File
@@ -1,6 +1,8 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import Link from "next/link"
import { Sparkles } from "lucide-react"
export default async function ModDashboardPage() {
const session = await getServerSession(authOptions)
@@ -65,6 +67,22 @@ export default async function ModDashboardPage() {
<p className="text-3xl font-bold mt-2">{seriesCount}</p>
</div>
</div>
<div className="mt-6 rounded-xl border bg-card text-card-foreground shadow p-6">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Tool
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Công cụ AI hỗ trợ tìm kiếm tự bổ sung thông tin truyện vào form quản .
</p>
<Link
href="/mod/ai-tool"
className="mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Mở AI Tool
</Link>
</div>
</div>
)
}
+118 -33
View File
@@ -13,11 +13,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon, Search, FileText, X, Check, FolderOpen, ChevronLeft, ChevronRight } from "lucide-react"
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon, Search, FileText, X, Check, FolderOpen, ChevronLeft, ChevronRight, WandSparkles } from "lucide-react"
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"
interface Novel {
id: string
@@ -106,24 +107,19 @@ interface EpubUploadResponseData {
const CHAPTER_REGEX_PRESETS = [
{
id: "vi_chuong",
name: "VN - Chương 1: ...",
pattern: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "en_chapter",
name: "EN - Chapter 1: ...",
pattern: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
id: "vi_chuong_hoi",
name: "VN - Chương/Hồi/Tiết/Phần 1: ...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "mix_chapter",
name: "Mixed - Chương/Chapter",
pattern: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
name: "Mixed - Chương/Hồi/Chapter...",
pattern: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
},
{
id: "bracket_chapter",
name: "[Chương 01] ...",
pattern: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$",
id: "numeric_only",
name: "Chỉ có số (1. ...)",
pattern: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$",
},
]
@@ -146,7 +142,7 @@ export function NovelClient() {
const [epubSeriesId, setEpubSeriesId] = useState("")
const [epubSeriesName, setEpubSeriesName] = useState("")
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("toc")
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong")
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong_hoi")
const [epubCustomRegex, setEpubCustomRegex] = useState("")
const epubInputRef = useRef<HTMLInputElement>(null)
const epubFolderInputRef = useRef<HTMLInputElement>(null)
@@ -191,6 +187,7 @@ 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") {
@@ -360,6 +357,99 @@ export function NovelClient() {
fetchSeries()
}, [])
const normalizeGenreName = (value: string) => value.trim().toLowerCase()
const resetAddForm = () => {
setTitle("")
setOriginalTitle("")
setAuthorName("")
setOriginalAuthorName("")
setDescription("")
setCoverUrl("")
setSeriesMode("none")
setSelectedSeriesId("")
setNewSeriesName("")
setStatus("Đang ra")
setSelectedGenres([])
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())
setSeriesMode("none")
setSelectedSeriesId("")
setNewSeriesName("")
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
@@ -671,18 +761,7 @@ export function NovelClient() {
if (!res.ok) throw new Error("Thêm mới thất bại")
toast.success("Đã thêm truyện thành công!")
setOpenAdd(false)
setTitle("")
setOriginalTitle("")
setAuthorName("")
setOriginalAuthorName("")
setDescription("")
setCoverUrl("")
setSeriesMode("none")
setSelectedSeriesId("")
setNewSeriesName("")
setStatus("Đang ra")
setSelectedGenres([])
setGenreQuery("")
resetAddForm()
fetchNovels()
fetchSeries()
} catch {
@@ -707,7 +786,7 @@ export function NovelClient() {
setEpubSeriesId("")
setEpubSeriesName("")
setEpubSplitMode("toc")
setEpubRegexPreset("vi_chuong")
setEpubRegexPreset("vi_chuong_hoi")
setEpubCustomRegex("")
if (epubInputRef.current) {
epubInputRef.current.value = ""
@@ -799,7 +878,7 @@ export function NovelClient() {
try {
await requestEpubPreview(file, {
splitMode: "toc",
regexPreset: "vi_chuong",
regexPreset: "vi_chuong_hoi",
regexInput: CHAPTER_REGEX_PRESETS[0].pattern,
preserveEditedMetadata: false,
})
@@ -1273,6 +1352,12 @@ export function NovelClient() {
<FolderOpen className="h-4 w-4" />
Chọn thư mục EPUB
</Button>
<Button variant="outline" className="gap-2" asChild>
<Link href="/mod/ai-tool">
<WandSparkles className="h-4 w-4" />
AI Tool
</Link>
</Button>
<Dialog
open={openEpubPreview}
@@ -1706,13 +1791,13 @@ export function NovelClient() {
</Dialog>
<Dialog open={openAdd} onOpenChange={(val) => {
setOpenAdd(val);
if (val) {
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSeriesMode("none"); setSelectedSeriesId(""); setNewSeriesName(""); setSelectedGenres([]); setGenreQuery("");
setOpenAdd(val)
if (!val) {
resetAddForm()
}
}}>
<DialogTrigger asChild>
<Button className="gap-2">
<Button className="gap-2" onClick={resetAddForm}>
<Plus className="h-4 w-4" /> Thêm truyện
</Button>
</DialogTrigger>