"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 tags const renderWithLinks = (text: string) => { const urlRegex = /(https?:\/\/[^\s]+)/g return text.split('\n').map((paragraph, index) => { if (!paragraph.trim()) return
const parts = paragraph.split(urlRegex) return (

{parts.map((part, i) => { if (part.match(urlRegex)) { return {part} } return {part} })}

) }) } export function EditorClient({ chapterId }: { chapterId: string }) { const router = useRouter() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [novel, setNovel] = useState(null) // Core states const [number, setNumber] = useState("") const [volumeNumber, setVolumeNumber] = useState("") const [volumeTitle, setVolumeTitle] = useState("") const [volumeChapterNumber, setVolumeChapterNumber] = 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([]) // Persisted DB array const [toolMatchCase, setToolMatchCase] = useState(false) const [toolExecuting, setToolExecuting] = useState(false) const [toolPreviewing, setToolPreviewing] = useState(false) const [toolPreviewResults, setToolPreviewResults] = useState([]) // UI Layout states const [splitView, setSplitView] = useState(true) // Sync Scroll Refs const textareaRef = useRef(null) const previewRef = useRef(null) const isScrolling = useRef<'textarea' | 'preview' | null>(null) const scrollTimeout = useRef(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()) setVolumeNumber(data.volumeNumber ? String(data.volumeNumber) : "") setVolumeTitle(data.volumeTitle || "") setVolumeChapterNumber(data.volumeChapterNumber ? String(data.volumeChapterNumber) : "") 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), volumeNumber: volumeNumber ? parseInt(volumeNumber) : null, volumeTitle: volumeTitle.trim() || null, volumeChapterNumber: volumeChapterNumber ? parseInt(volumeChapterNumber) : null, 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
} return (
{/* Header & Breadcrumb */}

Chỉnh sửa Chương {number}

{novel?.title || `Novel ID: ${originalNovelId}`}

{/* Quick Tools Bar */}
{ setOpenToolDialog(open) if (!open) setToolPreviewResults([]) }}> {toolAction === "replace" ? "Bộ công cụ: Tìm Kiếm & Thay Thế" : "Bộ công cụ: Dọn Dẹp Từ Rác"} {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."}
{toolPreviewResults.length > 0 ? (

Bản xem trước ({toolPreviewResults.length} ví dụ)

{toolPreviewResults.map((res: any) => (
Chương {res.number}: {res.title}
{res.snippet}
))}
) : (
{/* Scope Selector */}
Phạm vi áp dụng thao tác:
{/* Action Content */} {toolAction === "replace" ? (
setToolFindText(e.target.value)} />
setToolReplaceText(e.target.value)} />
) : (
{novelTrashWords.length > 0 && (
{novelTrashWords.map((word, idx) => (
{word}
))}
)}