Refactor code structure for improved readability and maintainability
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 và 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ó 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 có 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 có 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 lý đề cử biên tập
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mỗi biên tập viên có 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 có 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ó đề 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 có quyền xóa</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-17
@@ -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 lý 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">
|
||||
|
||||
@@ -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 và tự bổ sung thông tin truyện vào form quản lý.
|
||||
</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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user