Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-10 16:37:55 +07:00
parent 75ed8e233b
commit 8908395867
45 changed files with 2528 additions and 365 deletions
+527
View File
@@ -0,0 +1,527 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { Loader2, ArrowLeft, Save, SplitSquareHorizontal, Search, Trash2, X, Plus } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
// Helper to convert plain text with URLs to HTML with <a> tags
const renderWithLinks = (text: string) => {
const urlRegex = /(https?:\/\/[^\s]+)/g
return text.split('\n').map((paragraph, index) => {
if (!paragraph.trim()) return <br key={index} />
const parts = paragraph.split(urlRegex)
return (
<p key={index} className="mb-4 leading-relaxed">
{parts.map((part, i) => {
if (part.match(urlRegex)) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{part}</a>
}
return <span key={i}>{part}</span>
})}
</p>
)
})
}
export function EditorClient({ chapterId }: { chapterId: string }) {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [novel, setNovel] = useState<any>(null)
// Core states
const [number, setNumber] = useState("")
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [originalNovelId, setOriginalNovelId] = useState("")
// Tool states
const [openToolDialog, setOpenToolDialog] = useState(false)
const [toolAction, setToolAction] = useState<"replace" | "trash">("replace")
const [toolScope, setToolScope] = useState<"chapter" | "novel">("chapter")
const [toolFindText, setToolFindText] = useState("")
const [toolReplaceText, setToolReplaceText] = useState("")
const [toolTrashWords, setToolTrashWords] = useState("") // Just for the input box
const [novelTrashWords, setNovelTrashWords] = useState<string[]>([]) // Persisted DB array
const [toolMatchCase, setToolMatchCase] = useState(false)
const [toolExecuting, setToolExecuting] = useState(false)
const [toolPreviewing, setToolPreviewing] = useState(false)
const [toolPreviewResults, setToolPreviewResults] = useState<any[]>([])
// UI Layout states
const [splitView, setSplitView] = useState(true)
// Sync Scroll Refs
const textareaRef = useRef<HTMLTextAreaElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
const isScrolling = useRef<'textarea' | 'preview' | null>(null)
const scrollTimeout = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const fetchChapter = async () => {
try {
const res = await fetch(`/api/mod/chuong/${chapterId}`)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
console.error("Fetch chapter error:", res.status, errorData)
throw new Error(errorData.error || `Không thể tải chương (${res.status})`)
}
const data = await res.json()
setNumber(data.number.toString())
setTitle(data.title)
setContent(data.content)
setOriginalNovelId(data.novelId)
// Fetch novel details to show breadcrumbs
const novelRes = await fetch(`/api/truyen?slug=${data.novelId}`)
if (novelRes.ok) {
const novelData = await novelRes.json()
setNovel(novelData)
}
} catch (error: any) {
console.error("Failed to load:", error)
toast.error(error.message || "Lỗi khi tải dữ liệu chương")
} finally {
setLoading(false)
}
}
fetchChapter()
}, [chapterId])
useEffect(() => {
if (!originalNovelId) return
const fetchTrashWords = async () => {
try {
const res = await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`)
if (res.ok) {
const data = await res.json()
setNovelTrashWords(data.trashWords || [])
}
} catch (e) {
console.error("Fetch trash words error:", e)
}
}
fetchTrashWords()
}, [originalNovelId])
const handleTextareaScroll = () => {
if (!textareaRef.current || !previewRef.current) return
if (isScrolling.current === 'preview') return
isScrolling.current = 'textarea'
const { scrollTop, scrollHeight, clientHeight } = textareaRef.current
const percentage = scrollTop / (scrollHeight - clientHeight) || 0
const maxPreviewScroll = previewRef.current.scrollHeight - previewRef.current.clientHeight
previewRef.current.scrollTop = percentage * maxPreviewScroll
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
scrollTimeout.current = setTimeout(() => { isScrolling.current = null }, 50)
}
const handlePreviewScroll = () => {
if (!textareaRef.current || !previewRef.current) return
if (isScrolling.current === 'textarea') return
isScrolling.current = 'preview'
const { scrollTop, scrollHeight, clientHeight } = previewRef.current
const percentage = scrollTop / (scrollHeight - clientHeight) || 0
const maxTextareaScroll = textareaRef.current.scrollHeight - textareaRef.current.clientHeight
textareaRef.current.scrollTop = percentage * maxTextareaScroll
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
scrollTimeout.current = setTimeout(() => { isScrolling.current = null }, 50)
}
const handleAddTrashWord = async () => {
if (!toolTrashWords.trim()) return
const newWords = [...novelTrashWords, toolTrashWords]
setNovelTrashWords(newWords)
setToolTrashWords("")
try {
await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trashWords: newWords })
})
} catch (e) {
console.error("Save trash words error:", e)
}
}
const handleRemoveTrashWord = async (index: number) => {
const newWords = novelTrashWords.filter((_, i) => i !== index)
setNovelTrashWords(newWords)
try {
await fetch(`/api/mod/truyen/${originalNovelId}/trash-words`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trashWords: newWords })
})
} catch (e) {
console.error("Save trash words error:", e)
}
}
const handleSave = async () => {
if (!title || !content || !number) {
toast.error("Vui lòng điền đủ thông tin")
return
}
setSaving(true)
try {
const res = await fetch("/api/mod/chuong", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: chapterId,
novelId: originalNovelId,
number: parseInt(number),
title,
content
})
})
if (!res.ok) throw new Error("Cập nhật thất bại")
toast.success("Đã lưu chương thành công!")
router.push(`/mod/chuong?novelId=${originalNovelId}`)
} catch (error: any) {
toast.error(error.message)
} finally {
setSaving(false)
}
}
const handleToolExecute = async (isPreview: boolean = false) => {
if (toolAction === "replace" && !toolFindText) {
toast.error("Vui lòng nhập từ khóa cần tìm")
return
}
if (toolAction === "trash" && novelTrashWords.length === 0) {
toast.error("Danh sách từ rác trống. Vui lòng thêm từ rác trước.")
return
}
if (toolScope === "chapter") {
if (isPreview) {
toast.info("Xem trước chỉ áp dụng cho Toàn Truyện. Xin hãy áp dụng ngay cho chương này.")
return
}
let newContent = content
let count = 0
const flags = toolMatchCase ? 'g' : 'gi'
if (toolAction === "replace") {
const safeFindText = toolFindText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(safeFindText, flags)
const matches = newContent.match(regex)
if (matches) count = matches.length
newContent = newContent.replace(regex, toolReplaceText)
toast.success(`Đã thay thế ${count} lần nhóm từ "${toolFindText}" thành "${toolReplaceText}"`)
} else {
novelTrashWords.forEach(word => {
const safeWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(safeWord, flags)
const matches = newContent.match(regex)
if (matches) count += matches.length
newContent = newContent.replace(regex, '')
})
toast.success(`Đã lọc bỏ ${count} từ rác`)
}
setContent(newContent)
setOpenToolDialog(false)
return
}
// Global replace (Entire novel scope)
if (isPreview) setToolPreviewing(true)
else setToolExecuting(true)
try {
const res = await fetch("/api/mod/chuong/global-replace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
novelId: originalNovelId,
action: toolAction,
findText: toolFindText,
replaceText: toolReplaceText,
trashWords: novelTrashWords,
matchCase: toolMatchCase,
preview: isPreview
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Thao tác thất bại")
if (isPreview) {
setToolPreviewResults(data.previews || [])
if (data.previews?.length === 0) {
toast.info("Không tìm thấy kết quả nào trùng khớp trên toàn truyện")
}
} else {
toast.success(`Đã chạy công cụ thành công trên ${data.updatedChapters} chương!`)
toast.info("Trang đang tự tải lại để cập nhật nội dung mới...", { duration: 2000 })
setTimeout(() => window.location.reload(), 2000)
setOpenToolDialog(false)
setToolPreviewResults([])
setToolFindText("")
setToolReplaceText("")
setToolTrashWords("")
}
} catch (error: any) {
toast.error(error.message)
} finally {
if (isPreview) setToolPreviewing(false)
else setToolExecuting(false)
}
}
if (loading) {
return <div className="flex justify-center items-center h-[50vh]"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
}
return (
<div className="space-y-4 max-w-7xl mx-auto flex flex-col h-[calc(100vh-6rem)]">
{/* Header & Breadcrumb */}
<div className="flex items-center justify-between bg-card p-4 rounded-xl border shadow-sm shrink-0">
<div className="flex items-center gap-4">
<Link href={`/mod/chuong?novelId=${originalNovelId}`}>
<Button variant="ghost" size="icon"><ArrowLeft className="w-5 h-5" /></Button>
</Link>
<div>
<h1 className="text-xl font-bold">Chỉnh sửa Chương {number}</h1>
<p className="text-sm text-muted-foreground">{novel?.title || `Novel ID: ${originalNovelId}`}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={() => setSplitView(!splitView)} className="hidden md:flex gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
{splitView ? "Tắt Xem Trước" : "Bật Xem Trước"}
</Button>
<Button onClick={handleSave} disabled={saving} className="gap-2">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Lưu Thay Đi
</Button>
</div>
</div>
{/* Quick Tools Bar */}
<div className="bg-card p-3 rounded-xl border shadow-sm flex flex-wrap items-center gap-4 shrink-0">
<Button variant="secondary" onClick={() => { setToolAction("replace"); setOpenToolDialog(true) }} className="gap-2">
<Search className="w-4 h-4 text-muted-foreground" /> Tìm & Thay Thế
</Button>
<Button variant="outline" onClick={() => { setToolAction("trash"); setOpenToolDialog(true) }} className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200">
<Trash2 className="w-4 h-4" /> Dọn Dẹp Từ Rác
</Button>
<Dialog open={openToolDialog} onOpenChange={(open) => {
setOpenToolDialog(open)
if (!open) setToolPreviewResults([])
}}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{toolAction === "replace" ? "Bộ công cụ: Tìm Kiếm & Thay Thế" : "Bộ công cụ: Dọn Dẹp Từ Rác"}
</DialogTitle>
<DialogDescription>
{toolAction === "replace"
? "Thay thế cụm từ trên chương này hoặc trên toàn bộ truyện."
: "Xóa bỏ các cụm từ rác, watermark trên chương này hoặc trên toàn bộ truyện."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 custom-scrollbar pr-2">
{toolPreviewResults.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Bản xem trước ({toolPreviewResults.length} dụ)</h3>
<Button variant="ghost" size="sm" onClick={() => setToolPreviewResults([])}>
<ArrowLeft className="w-4 h-4 mr-2" /> Quay lại tuỳ chỉnh
</Button>
</div>
<div className="space-y-3">
{toolPreviewResults.map((res: any) => (
<div key={res.chapterId} className="p-3 border rounded-lg bg-card text-left">
<div className="text-sm font-medium mb-1">Chương {res.number}: {res.title}</div>
<div className="text-sm text-muted-foreground bg-muted p-2 rounded italic font-serif">
{res.snippet}
</div>
</div>
))}
</div>
</div>
) : (
<div className="space-y-6">
{/* Scope Selector */}
<div className="p-3 border rounded-lg bg-muted/30">
<div className="text-sm font-medium mb-2">Phạm vi áp dụng thao tác:</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="toolScope" checked={toolScope === "chapter"} onChange={() => setToolScope("chapter")} />
<span>Chỉ Chương Này</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="toolScope" checked={toolScope === "novel"} onChange={() => setToolScope("novel")} />
<span className="text-primary font-medium">Toàn Bộ Truyện</span>
</label>
</div>
</div>
{/* Action Content */}
{toolAction === "replace" ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tìm cụm từ:</label>
<Input
placeholder="Ví dụ: truyenchu.vn"
value={toolFindText}
onChange={(e) => setToolFindText(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Thay thế bằng (Bỏ trống đ xóa hoàn toàn):</label>
<Input
placeholder="..."
value={toolReplaceText}
onChange={(e) => setToolReplaceText(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-2 w-max">
<input type="checkbox" className="w-4 h-4 rounded" checked={toolMatchCase} onChange={(e) => setToolMatchCase(e.target.checked)} />
<span className="text-sm">Phân biệt chữ Hoa / chữ thường</span>
</label>
</div>
) : (
<div className="space-y-4">
{novelTrashWords.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">Danh sách từ rác hiện tại:</label>
<div className="flex flex-col gap-2 max-h-[40vh] overflow-y-auto pr-2 custom-scrollbar">
{novelTrashWords.map((word, idx) => (
<div key={idx} className="flex items-start gap-2 p-3 relative bg-red-50 dark:bg-red-950/20 text-red-900 border border-red-200 dark:border-red-900/50 rounded-lg group">
<pre className="text-sm flex-1 whitespace-pre-wrap font-sans">{word}</pre>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500 hover:text-red-700 hover:bg-red-100 shrink-0" onClick={() => handleRemoveTrashWord(idx)}>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
)}
<div className="space-y-2 mt-4 pt-4 border-t">
<label className="text-sm font-medium">Thêm từ rác (Hỗ trợ nhiều dòng với Enter):</label>
<Textarea
placeholder="Ví dụ:\n\n.\n\n.\n\n."
value={toolTrashWords}
onChange={(e) => setToolTrashWords(e.target.value)}
className="resize-none h-24"
/>
<Button type="button" variant="secondary" onClick={handleAddTrashWord} disabled={!toolTrashWords.trim()} className="w-full">
<Plus className="w-4 h-4 mr-2" /> Thêm vào danh sách CSDL
</Button>
</div>
<p className="text-sm text-muted-foreground mt-2">
Các cụm từ rác sẽ đưc lưu lại cho toàn bộ truyện. Chế đ lọc rác tự đng tìm kiếm không phân biệt Hoa/thường.
</p>
</div>
)}
</div>
)}
</div>
<DialogFooter className="mt-auto pt-4 border-t">
<Button variant="outline" onClick={() => {
setOpenToolDialog(false)
setToolPreviewResults([])
}}>Đóng</Button>
{toolPreviewResults.length === 0 ? (
<>
<Button variant="secondary" onClick={() => handleToolExecute(true)} disabled={toolPreviewing || toolScope === 'chapter' || (toolAction === 'replace' ? !toolFindText : novelTrashWords.length === 0)}>
{toolPreviewing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Xem Trước
</Button>
<Button variant="destructive" onClick={() => handleToolExecute(false)} disabled={toolExecuting || (toolAction === 'trash' && novelTrashWords.length === 0)}>
{toolExecuting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Áp Dụng Thực Thay
</Button>
</>
) : (
<Button variant="destructive" onClick={() => handleToolExecute(false)} disabled={toolExecuting || (toolAction === 'trash' && novelTrashWords.length === 0)}>
{toolExecuting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Đã Chắc Chắn, Bắt Đu Thay Thế!
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Editor Workspace */}
<div className="flex flex-col flex-1 pb-4 min-h-0">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 shrink-0">
<div className="space-y-1">
<label className="text-xs font-semibold uppercase text-muted-foreground">Chương số</label>
<Input type="number" value={number} onChange={(e) => setNumber(e.target.value)} className="font-mono" />
</div>
<div className="space-y-1 md:col-span-3">
<label className="text-xs font-semibold uppercase text-muted-foreground">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
</div>
<div className={`flex-1 flex gap-4 min-h-0 ${splitView ? 'flex-row' : 'flex-col'}`}>
{/* Left: Raw Textarea */}
<div className={`flex flex-col flex-1 h-full min-h-0 border rounded-xl overflow-hidden bg-background shadow-inner`}>
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold uppercase text-muted-foreground shrink-0">
Nội Dung Nguồn
</div>
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onScroll={handleTextareaScroll}
className="flex-1 w-full p-4 resize-none border-0 focus-visible:ring-0 rounded-none h-full custom-scrollbar text-base"
placeholder="Nội dung chương..."
/>
</div>
{/* Right: Preview (Only shown if splitView is true and on tablet/desktop) */}
<div className={`flex flex-col flex-1 h-full min-h-0 border rounded-xl overflow-hidden bg-background shadow-inner ${splitView ? 'hidden md:flex' : 'hidden'}`}>
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold uppercase text-muted-foreground shrink-0 flex justify-between">
<span>Bản Hiển Thị</span>
<span className="text-primary normal-case">Link đưc nhận diện tự đng</span>
</div>
<div
ref={previewRef}
onScroll={handlePreviewScroll}
className="flex-1 overflow-y-auto p-6 bg-card custom-scrollbar"
>
<div className="prose prose-sm md:prose-base dark:prose-invert max-w-none font-serif">
{content ? renderWithLinks(content) : <p className="text-muted-foreground italic">Nội dung trống...</p>}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { EditorClient } from "./editor-client"
export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
const resolvedParams = await params
return <EditorClient chapterId={resolvedParams.id} />
}
+93 -100
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useState, useEffect, Suspense, useRef } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -14,9 +14,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2 } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
// @ts-ignore
import * as mammoth from "mammoth"
interface Chapter {
_id: string
@@ -70,11 +73,18 @@ function ChapterManager() {
const [openDelete, setOpenDelete] = useState(false)
const [deletingChapterId, setDeletingChapterId] = useState<string | null>(null)
// Form states
const [number, setNumber] = useState("")
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
// Multi-upload states
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadingMulti, setUploadingMulti] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [totalUpload, setTotalUpload] = useState(0)
const fetchChapters = async (pageToFetch = 1) => {
if (!novelId) return
setLoading(true)
@@ -136,6 +146,65 @@ function ChapterManager() {
}
}
const handleMultiFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0 || !novelId) return
setUploadingMulti(true)
setTotalUpload(files.length)
setUploadProgress(0)
// Sort files by name to ensure order (e.g. Chapter 1, Chapter 2)
files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
try {
let currentNumber = parseInt(number) || 1
for (let i = 0; i < files.length; i++) {
const file = files[i]
let content = ""
if (file.name.endsWith(".txt")) {
content = await file.text()
} else if (file.name.endsWith(".docx")) {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.extractRawText({ arrayBuffer })
content = result.value
} else {
continue
}
if (!content.trim()) continue // Bỏ qua file rỗng
let fileTitle = file.name.replace(/\.[^/.]+$/, "")
// Loại bỏ "Chương X: " khỏi file title nếu cần thiết
let cleanedTitle = fileTitle.replace(/^(Chương|Ch\.)\s*\d+\s*[:-]?\s*/i, "")
if (!cleanedTitle) cleanedTitle = fileTitle
const res = await fetch("/api/mod/chuong", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId, number: currentNumber, title: cleanedTitle, content }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || `Lỗi khi lưu file ${file.name}`)
}
currentNumber++
setUploadProgress(i + 1)
}
toast.success(`Đã tải lên thành công ${files.length} chương!`)
fetchChapters()
} catch (error: any) {
toast.error(error.message)
} finally {
setUploadingMulti(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handlePreviewOptimize = () => {
let newChapters = [...chapters]
@@ -189,58 +258,10 @@ function ChapterManager() {
}
}
const handleOpenEdit = async (chapter: Chapter) => {
setEditingChapterId(chapter._id)
setNumber(chapter.number.toString())
setTitle(chapter.title)
setContent("") // Khởi tạo rỗng trong lúc chờ fetch nội dung
setOpenEdit(true)
setLoadingEditData(true)
try {
// Lấy chi tiết chương từ list db để có nội dung qua API GET /api/mod/chuong/[id] vừa tạo
const res = await fetch(`/api/mod/chuong/${chapter._id}`)
if (res.ok) {
const data = await res.json()
setContent(data.content)
} else {
toast.error("Không tải được nội dung chương")
}
} catch {
toast.error("Không tải được nội dung chương")
} finally {
setLoadingEditData(false)
}
}
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!number || !title || !content || !novelId || !editingChapterId) {
toast.error("Vui lòng điền đầy đủ")
return
}
setSubmitting(true)
try {
const res = await fetch("/api/mod/chuong", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: editingChapterId, novelId, number: parseInt(number), title, content }),
})
const resData = await res.json()
if (!res.ok) throw new Error(resData.error || "Cập nhật thất bại")
toast.success("Đã cập nhật chương thành công!")
setOpenEdit(false)
fetchChapters()
} catch (error: any) {
toast.error(error.message)
} finally {
setSubmitting(false)
}
}
// handleOpenEdit has been removed because edit is now via dedicated page
// handleDelete remains the same
const handleDelete = async () => {
if (!deletingChapterId || !novelId) return
setSubmitting(true)
@@ -285,6 +306,7 @@ function ChapterManager() {
</h1>
<div className="flex gap-3">
<Button variant="secondary" className="gap-2" onClick={() => {
setOpenOptimize(true)
setPreviewMode(false)
@@ -292,6 +314,19 @@ function ChapterManager() {
<Wand2 className="h-4 w-4" /> Tối ưu hóa
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleMultiFileUpload}
multiple
accept=".txt,.docx"
className="hidden"
/>
<Button variant="secondary" className="gap-2" onClick={() => fileInputRef.current?.click()} disabled={uploadingMulti}>
{uploadingMulti ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{uploadingMulti ? `Đang tải lên ${uploadProgress}/${totalUpload}...` : "Tải lên hàng loạt"}
</Button>
<Dialog open={openAdd} onOpenChange={setOpenAdd}>
<DialogTrigger asChild>
<Button className="gap-2">
@@ -336,51 +371,6 @@ function ChapterManager() {
</DialogContent>
</Dialog>
<Dialog open={openEdit} onOpenChange={setOpenEdit}>
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Chỉnh Sửa Chương</DialogTitle>
<DialogDescription>
Thay đi nội dung hoặc thông tin chương truyện.
</DialogDescription>
</DialogHeader>
{loadingEditData ? (
<div className="flex-1 flex justify-center items-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<form onSubmit={handleEditSubmit} className="flex flex-col gap-4 pt-4 flex-1 h-full overflow-hidden">
<div className="grid grid-cols-4 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>
<div className="space-y-2 col-span-3">
<label className="text-sm font-medium">Tên chương</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
</div>
<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</label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="flex-1 w-full p-4 resize-none min-h-[300px]"
required
/>
</div>
<DialogFooter className="mt-auto pt-4">
<Button type="button" variant="outline" onClick={() => setOpenEdit(false)}>Hủy</Button>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Lưu thay đi
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
@@ -471,6 +461,7 @@ function ChapterManager() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
@@ -498,9 +489,11 @@ function ChapterManager() {
<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">
<Button size="sm" variant="outline" className="h-8 px-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50" onClick={() => handleOpenEdit(ch)}>
<Edit className="w-4 h-4 mr-1" /> Sửa
</Button>
<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)
+234 -58
View File
@@ -13,7 +13,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2 } from "lucide-react"
import { BookOpen, Loader2, Plus, Upload, Edit, Trash2, LayoutGrid, List, Image as ImageIcon } from "lucide-react"
import { toast } from "sonner"
import Link from "next/link"
@@ -24,6 +24,7 @@ interface Novel {
authorName: string
status: string
totalChapters: number
coverUrl?: string
}
interface Genre {
@@ -40,9 +41,16 @@ export function NovelClient() {
// Form states
const [title, setTitle] = useState("")
const [originalTitle, setOriginalTitle] = useState("")
const [authorName, setAuthorName] = useState("")
const [originalAuthorName, setOriginalAuthorName] = useState("")
const [description, setDescription] = useState("")
const [coverUrl, setCoverUrl] = useState("")
const [status, setStatus] = useState("Đang ra")
// View state
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [uploadingCover, setUploadingCover] = useState(false)
// Edit states
const [openEdit, setOpenEdit] = useState(false)
@@ -150,14 +158,17 @@ export function NovelClient() {
const res = await fetch("/api/mod/truyen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, authorName, description, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
body: JSON.stringify({ title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds: selectedGenres }), // Can add status here later if API accepts it on create
})
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("")
setStatus("Đang ra")
setSelectedGenres([])
fetchNovels()
@@ -203,12 +214,47 @@ export function NovelClient() {
}
}
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error("Vui lòng chọn file hình ảnh")
e.target.value = ""
return
}
setUploadingCover(true)
const formData = new FormData()
formData.append("file", file)
try {
const res = await fetch("/api/mod/upload-cover", {
method: "POST",
body: formData,
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Lỗi khi tải lên ảnh bìa")
setCoverUrl(data.url)
toast.success("Tải ảnh bìa thành công!")
} catch (err: any) {
toast.error(err.message || "Có lỗi xảy ra khi xử lý ảnh bìa")
} finally {
setUploadingCover(false)
e.target.value = ""
}
}
const handleOpenEdit = async (novel: Novel) => {
setEditingNovel(novel)
setTitle(novel.title)
setAuthorName(novel.authorName)
setStatus(novel.status)
setDescription("")
setOriginalTitle("")
setOriginalAuthorName("")
setCoverUrl(novel.coverUrl || "")
setOpenEdit(true)
setLoadingEditData(true)
@@ -217,6 +263,8 @@ export function NovelClient() {
if (res.ok) {
const data = await res.json()
setDescription(data.description || "")
setOriginalTitle(data.originalTitle || "")
setOriginalAuthorName(data.originalAuthorName || "")
if (data.genres && Array.isArray(data.genres)) {
setSelectedGenres(data.genres.map((g: any) => g.genreId))
} else {
@@ -247,8 +295,11 @@ export function NovelClient() {
body: JSON.stringify({
id: editingNovel.id,
title,
originalTitle,
authorName,
originalAuthorName,
description,
coverUrl,
genreIds: selectedGenres,
status: status
}),
@@ -298,6 +349,27 @@ export function NovelClient() {
</h1>
<div className="flex gap-3">
<div className="flex bg-muted rounded-md p-1">
<Button
variant="ghost"
size="sm"
className={`h-8 px-2 ${viewMode === 'list' ? 'bg-background shadow-sm' : ''}`}
onClick={() => setViewMode('list')}
title="Dạng danh sách"
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 px-2 ${viewMode === 'grid' ? 'bg-background shadow-sm' : ''}`}
onClick={() => setViewMode('grid')}
title="Dạng lưới"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
<input
type="file"
id="epub-upload"
@@ -319,7 +391,7 @@ export function NovelClient() {
<Dialog open={openAdd} onOpenChange={(val) => {
setOpenAdd(val);
if (val) {
setTitle(""); setAuthorName(""); setDescription(""); setSelectedGenres([]); setNewGenreName("");
setTitle(""); setOriginalTitle(""); setAuthorName(""); setOriginalAuthorName(""); setDescription(""); setCoverUrl(""); setSelectedGenres([]); setNewGenreName("");
}
}}>
<DialogTrigger asChild>
@@ -340,9 +412,32 @@ export function NovelClient() {
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ví dụ: Phàm Nhân Tu Tiên" autoFocus />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tác giả gốc</label>
<label className="text-sm font-medium">Tên gốc (Tùy chọn)</label>
<Input value={originalTitle} onChange={(e) => setOriginalTitle(e.target.value)} placeholder="Ví dụ: 凡人修仙传" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tác giả</label>
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Ví dụ: Vong Ngữ" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tác giả gốc (Tùy chọn)</label>
<Input value={originalAuthorName} onChange={(e) => setOriginalAuthorName(e.target.value)} placeholder="Ví dụ: 忘语" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">nh bìa (Tùy chọn)</label>
<div className="flex gap-2">
<Input value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="URL ảnh..." className="flex-1" />
<input type="file" id="cover-upload-add" className="hidden" accept="image/*" onChange={handleCoverUpload} />
<Button type="button" variant="secondary" onClick={() => document.getElementById('cover-upload-add')?.click()} disabled={uploadingCover}>
{uploadingCover ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
</Button>
</div>
{coverUrl && (
<div className="mt-2 w-24 h-32 rounded border overflow-hidden">
<img src={coverUrl} alt="Preview" className="w-full h-full object-cover" />
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Thêm thể loại</label>
<div className="flex gap-2">
@@ -408,9 +503,32 @@ export function NovelClient() {
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tác giả gốc</label>
<label className="text-sm font-medium">Tên gốc (Tùy chọn)</label>
<Input value={originalTitle} onChange={(e) => setOriginalTitle(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tác giả</label>
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tác giả gốc (Tùy chọn)</label>
<Input value={originalAuthorName} onChange={(e) => setOriginalAuthorName(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">nh bìa (Tùy chọn)</label>
<div className="flex gap-2">
<Input value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="URL ảnh..." className="flex-1" />
<input type="file" id="cover-upload-edit" className="hidden" accept="image/*" onChange={handleCoverUpload} />
<Button type="button" variant="secondary" onClick={() => document.getElementById('cover-upload-edit')?.click()} disabled={uploadingCover}>
{uploadingCover ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
</Button>
</div>
{coverUrl && (
<div className="mt-2 w-24 h-32 rounded border overflow-hidden">
<img src={coverUrl} alt="Preview" className="w-full h-full object-cover" />
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Cập nhật thể loại</label>
<div className="flex gap-2">
@@ -498,61 +616,119 @@ export function NovelClient() {
</div>
<div className="rounded-xl border bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr>
<th scope="col" className="px-5 py-4 font-semibold">Tên truyện</th>
<th scope="col" className="px-5 py-4 font-semibold">Tác giả</th>
<th scope="col" className="px-5 py-4 font-semibold">Số chương</th>
<th scope="col" className="px-5 py-4 font-semibold">Trạng thái</th>
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
) : novels.length === 0 ? (
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</td></tr>
) : (
novels.map((novel) => (
<tr key={novel.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">{novel.title}</td>
<td className="px-5 py-4 text-muted-foreground">{novel.authorName}</td>
<td className="px-5 py-4">
<span className="inline-flex items-center justify-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
{novel.totalChapters}
</span>
</td>
<td className="px-5 py-4">
<span className="bg-emerald-100 text-emerald-800 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-emerald-900/40 dark:text-emerald-300">
{novel.status}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link href={`/mod/chuong?novelId=${novel.id}`}>
<Button size="sm" variant="outline" className="h-8">
Cập nhật chương
{viewMode === 'list' ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted/50 text-muted-foreground border-b border-border">
<tr>
<th scope="col" className="px-5 py-4 font-semibold">Tên truyện</th>
<th scope="col" className="px-5 py-4 font-semibold">Tác giả</th>
<th scope="col" className="px-5 py-4 font-semibold">Số chương</th>
<th scope="col" className="px-5 py-4 font-semibold">Trạng thái</th>
<th scope="col" className="px-5 py-4 text-right font-semibold">Thao tác</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground"><Loader2 className="h-6 w-6 animate-spin mx-auto" /></td></tr>
) : novels.length === 0 ? (
<tr><td colSpan={5} className="p-8 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</td></tr>
) : (
novels.map((novel) => (
<tr key={novel.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 flex items-center gap-3">
{novel.coverUrl ? (
<img src={novel.coverUrl} alt={novel.title} className="w-8 h-10 object-cover rounded shadow-sm hidden sm:block" />
) : (
<div className="w-8 h-10 bg-muted rounded shadow-sm hidden sm:flex items-center justify-center text-muted-foreground"><BookOpen className="w-4 h-4" /></div>
)}
{novel.title}
</td>
<td className="px-5 py-4 text-muted-foreground">{novel.authorName}</td>
<td className="px-5 py-4">
<span className="inline-flex items-center justify-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
{novel.totalChapters}
</span>
</td>
<td className="px-5 py-4">
<span className="bg-emerald-100 text-emerald-800 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-emerald-900/40 dark:text-emerald-300">
{novel.status}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link href={`/mod/chuong?novelId=${novel.id}`}>
<Button size="sm" variant="outline" className="h-8">
Cập nhật chương
</Button>
</Link>
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button size="icon" variant="outline" className="h-8 w-8 text-blue-600 border-blue-200 hover:bg-blue-50" onClick={() => handleOpenEdit(novel)}>
<Edit className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50" onClick={() => {
setDeletingNovelId(novel.id)
setOpenDelete(true)
}}>
<Trash2 className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="h-8 w-8 text-red-600 border-red-200 hover:bg-red-50" onClick={() => {
setDeletingNovelId(novel.id)
setOpenDelete(true)
}}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : (
<div className="p-4 sm:p-6 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
{loading ? (
<div className="col-span-full py-12 flex justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : novels.length === 0 ? (
<div className="col-span-full py-12 text-center text-muted-foreground">Bạn chưa đăng truyện nào. Hãy thêm truyện mới!</div>
) : (
novels.map((novel) => (
<div key={novel.id} className="group relative flex flex-col rounded-xl overflow-hidden border shadow-sm transition-all hover:-translate-y-1 hover:shadow-md bg-card">
<div className="aspect-[2/3] w-full bg-muted relative border-b">
{novel.coverUrl ? (
<img src={novel.coverUrl} alt={novel.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground gap-2">
<BookOpen className="w-8 h-8 opacity-20" />
<span className="text-xs opacity-50 font-medium">No Cover</span>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<div className="absolute top-2 right-2">
<span className="bg-emerald-100 text-emerald-800 text-[10px] font-bold px-1.5 py-0.5 rounded shadow-sm dark:bg-emerald-900 dark:text-emerald-300">
{novel.totalChapters} Chương
</span>
</div>
</div>
<div className="p-3 flex flex-col flex-1">
<h3 className="font-semibold text-sm line-clamp-2 leading-tight mb-1" title={novel.title}>{novel.title}</h3>
<p className="text-xs text-muted-foreground mb-3">{novel.authorName}</p>
<div className="mt-auto grid grid-cols-2 gap-1.5">
<Button size="sm" variant="outline" className="w-full h-7 text-xs px-0" onClick={() => handleOpenEdit(novel)}>
<Edit className="h-3 w-3 mr-1" /> Sửa
</Button>
<Button size="sm" variant="outline" className="w-full h-7 text-xs px-0 text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {
setDeletingNovelId(novel.id)
setOpenDelete(true)
}}>
<Trash2 className="h-3 w-3 mr-1" /> Xóa
</Button>
<Link href={`/mod/chuong?novelId=${novel.id}`} className="col-span-2">
<Button size="sm" className="w-full h-7 text-xs">
<List className="h-3 w-3 mr-1" /> DS Chương
</Button>
</Link>
</div>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
)