From c811135b926d0b9d68e05200d05f925d47aa92e9 Mon Sep 17 00:00:00 2001 From: virtus Date: Mon, 6 Apr 2026 18:24:05 +0000 Subject: [PATCH] Add genre management functionality and update sidebar navigation --- .gitignore | 4 +- app/mod/collapsible-sidebar.tsx | 3 +- app/mod/the-loai/genre-client.tsx | 348 ++++++++++++++++++++++++++++++ app/mod/the-loai/page.tsx | 13 ++ app/the-loai/[slug]/page.tsx | 113 +++++++--- app/the-loai/page.tsx | 2 +- app/tim-kiem/page.tsx | 12 +- 7 files changed, 456 insertions(+), 39 deletions(-) create mode 100644 app/mod/the-loai/genre-client.tsx create mode 100644 app/mod/the-loai/page.tsx diff --git a/.gitignore b/.gitignore index 6168c54..834aecd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ node_modules/ .env*.local .DS_Store .env -test-ebook/ \ No newline at end of file +test-ebook/ +reader.code-workspace +reader-api.code-workspace \ No newline at end of file diff --git a/app/mod/collapsible-sidebar.tsx b/app/mod/collapsible-sidebar.tsx index 4a3d5d5..03e0ee8 100644 --- a/app/mod/collapsible-sidebar.tsx +++ b/app/mod/collapsible-sidebar.tsx @@ -3,7 +3,7 @@ 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 { AlertTriangle, BookOpen, Home, Sparkles, Star, Tag, ChevronLeft, ChevronRight } from "lucide-react" import { cn } from "@/lib/utils" export function CollapsibleSidebar() { @@ -31,6 +31,7 @@ export function CollapsibleSidebar() { { 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/the-loai", label: "Quản lý thể loại", icon: Tag }, { href: "/mod/de-cu", label: "Quản lý đề cử", icon: Star }, { href: "/mod/ai-tool", label: "AI Tool", icon: Sparkles }, ] diff --git a/app/mod/the-loai/genre-client.tsx b/app/mod/the-loai/genre-client.tsx new file mode 100644 index 0000000..5953e3e --- /dev/null +++ b/app/mod/the-loai/genre-client.tsx @@ -0,0 +1,348 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { GitMerge, Loader2, Pencil, Plus, Save, Tag, Trash2, X } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface Genre { + id: string + name: string + slug: string + description?: string | null + icon?: string | null + novelCount?: number +} + +export function GenreClient() { + const [genres, setGenres] = useState([]) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [keyword, setKeyword] = useState("") + + // Form state + const [editingId, setEditingId] = useState(null) + const [formName, setFormName] = useState("") + const [formDesc, setFormDesc] = useState("") + const [formIcon, setFormIcon] = useState("") + + // Merge dialog + const [mergeOpen, setMergeOpen] = useState(false) + const [mergeSource, setMergeSource] = useState(null) + const [mergeTargetId, setMergeTargetId] = useState("") + const [mergeKeyword, setMergeKeyword] = useState("") + + // Delete confirm + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleteOpen, setDeleteOpen] = useState(false) + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase() + if (!q) return genres + return genres.filter( + (g) => + g.name.toLowerCase().includes(q) || + g.slug.toLowerCase().includes(q) + ) + }, [keyword, genres]) + + const mergeTargetOptions = useMemo(() => { + const q = mergeKeyword.trim().toLowerCase() + return genres.filter( + (g) => + g.id !== mergeSource?.id && + (!q || g.name.toLowerCase().includes(q) || g.slug.toLowerCase().includes(q)) + ) + }, [genres, mergeSource, mergeKeyword]) + + const fetchGenres = async () => { + try { + const res = await fetch("/api/mod/the-loai") + if (!res.ok) throw new Error("Không thể tải danh sách thể loại") + const data: Genre[] = await res.json() + // Enrich with novelCount from public endpoint + const countRes = await fetch("/api/genres") + if (countRes.ok) { + const countData: Genre[] = await countRes.json() + const countMap = Object.fromEntries(countData.map((g) => [g.id, g.novelCount ?? 0])) + data.forEach((g) => { g.novelCount = countMap[g.id] ?? 0 }) + } + setGenres(data) + } catch (err: any) { + toast.error(err.message || "Lỗi tải thể loại") + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchGenres() }, []) + + const resetForm = () => { + setEditingId(null) + setFormName("") + setFormDesc("") + setFormIcon("") + } + + const handleEdit = (g: Genre) => { + setEditingId(g.id) + setFormName(g.name) + setFormDesc(g.description || "") + setFormIcon(g.icon || "") + window.scrollTo({ top: 0, behavior: "smooth" }) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!formName.trim()) { toast.error("Vui lòng nhập tên thể loại"); return } + + setSubmitting(true) + try { + const payload: Record = { + name: formName.trim(), + description: formDesc.trim() || null, + icon: formIcon.trim() || null, + } + if (editingId) payload.id = editingId + + const res = await fetch("/api/mod/the-loai", { + method: editingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || "Không thể lưu") + toast.success(editingId ? "Đã cập nhật thể loại" : "Đã tạo thể loại") + resetForm() + fetchGenres() + } catch (err: any) { + toast.error(err.message) + } finally { + setSubmitting(false) + } + } + + const confirmDelete = (g: Genre) => { + setDeleteTarget(g) + setDeleteOpen(true) + } + + const handleDelete = async () => { + if (!deleteTarget) return + setSubmitting(true) + try { + const res = await fetch(`/api/mod/the-loai?id=${deleteTarget.id}`, { method: "DELETE" }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || "Không thể xóa") + toast.success(`Đã xóa thể loại "${deleteTarget.name}"`) + if (editingId === deleteTarget.id) resetForm() + setDeleteOpen(false) + fetchGenres() + } catch (err: any) { + toast.error(err.message) + } finally { + setSubmitting(false) + } + } + + const openMerge = (g: Genre) => { + setMergeSource(g) + setMergeTargetId("") + setMergeKeyword("") + setMergeOpen(true) + } + + const handleMerge = async () => { + if (!mergeSource || !mergeTargetId) { toast.error("Vui lòng chọn thể loại đích"); return } + setSubmitting(true) + try { + const res = await fetch("/api/mod/the-loai/merge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId: mergeSource.id, targetId: mergeTargetId }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || "Không thể gộp") + const target = genres.find((g) => g.id === mergeTargetId) + toast.success(`Đã gộp "${mergeSource.name}" vào "${target?.name}"`) + setMergeOpen(false) + if (editingId === mergeSource.id) resetForm() + fetchGenres() + } catch (err: any) { + toast.error(err.message) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ +

Quản lý Thể Loại

+
+ + {/* Form tạo / chỉnh sửa */} +
+

{editingId ? "Chỉnh sửa thể loại" : "Tạo thể loại mới"}

+
+
+ + setFormName(e.target.value)} placeholder="Ví dụ: Kiếm Hiệp" /> +
+
+ + setFormIcon(e.target.value)} placeholder="Ví dụ: Sword, Flame, Heart..." /> +
+
+
+ +