Add moderation APIs and admin UI
Add moderator/admin backend APIs and client features for managing novels and chapters. New endpoints include mod chapter routes (paginated list, single GET, PUT, DELETE, and bulk optimize), mod novel routes (create, GET by id, update, delete), genre CRUD, user bookmarks, novel comments, and rating endpoints. Update EPUB import to use a shared slugify util. Enhance moderator UI: chapter manager gains pagination, bulk optimization preview/apply, edit/delete dialogs; novel client adds genre management and edit/delete flows. Also update Prisma schema, add a DB wipe script, remove unused lib/data.ts, and adjust related types/utils and bookmark context.
This commit is contained in:
@@ -1,77 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { use, useState, useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { getGenreBySlug, getNovelsByGenre } from "@/lib/data"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { NovelCard } from "@/components/novel-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export default function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = use(params)
|
||||
const genre = getGenreBySlug(slug)
|
||||
|
||||
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { slug }
|
||||
})
|
||||
|
||||
if (!genre) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <GenreContent genreName={genre.name} genreSlug={genre.slug} genreDescription={genre.description} />
|
||||
}
|
||||
|
||||
function GenreContent({ genreName, genreSlug, genreDescription }: { genreName: string; genreSlug: string; genreDescription: string }) {
|
||||
const [sortBy, setSortBy] = useState("latest")
|
||||
const allNovels = getNovelsByGenre(genreSlug)
|
||||
|
||||
const sortedNovels = useMemo(() => {
|
||||
const sorted = [...allNovels]
|
||||
switch (sortBy) {
|
||||
case "popular":
|
||||
sorted.sort((a, b) => b.views - a.views)
|
||||
break
|
||||
case "rating":
|
||||
sorted.sort((a, b) => b.rating - a.rating)
|
||||
break
|
||||
case "latest":
|
||||
default:
|
||||
sorted.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime())
|
||||
const allNovels = await prisma.novel.findMany({
|
||||
where: {
|
||||
genres: {
|
||||
some: {
|
||||
genreId: genre.id
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc"
|
||||
}
|
||||
return sorted
|
||||
}, [allNovels, sortBy])
|
||||
})
|
||||
|
||||
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<div className="mb-6">
|
||||
<Link href="/the-loai" className="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-4 w-4" /> Thể Loại
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground">{genreName}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{genreDescription}</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">{genre.name}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{genre.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{sortedNovels.length} truyện</p>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">Mới nhất</SelectItem>
|
||||
<SelectItem value="popular">Xem nhiều</SelectItem>
|
||||
<SelectItem value="rating">Đánh giá cao</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">{allNovels.length} truyện</p>
|
||||
<div className="w-40" /> {/* Spacer for symmetry if we add sort later */}
|
||||
</div>
|
||||
|
||||
{sortedNovels.length === 0 ? (
|
||||
{allNovels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<p className="text-lg font-medium">Chưa có truyện nào</p>
|
||||
<p className="text-sm">Thể loại này chưa có truyện, hãy quay lại sau.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sortedNovels.map((novel) => (
|
||||
{allNovels.map((novel) => (
|
||||
<NovelCard key={novel.id} novel={novel} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
+12
-4
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
||||
import { genres, getNovelsByGenre } from "@/lib/data"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
Sparkles: <Sparkles className="h-6 w-6" />,
|
||||
@@ -15,13 +15,21 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Shield: <Shield className="h-6 w-6" />,
|
||||
}
|
||||
|
||||
export default function GenresPage() {
|
||||
export default async function GenresPage() {
|
||||
const genres = await prisma.genre.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { novels: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<h1 className="mb-6 text-2xl font-bold text-foreground">Thể Loại Truyện</h1>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{genres.map((genre) => {
|
||||
const novelCount = getNovelsByGenre(genre.slug).length
|
||||
const novelCount = genre._count.novels
|
||||
return (
|
||||
<Link
|
||||
key={genre.id}
|
||||
@@ -29,7 +37,7 @@ export default function GenresPage() {
|
||||
className="group flex items-start gap-4 rounded-xl border border-border bg-card p-5 transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
{iconMap[genre.icon] || <BookOpen className="h-6 w-6" />}
|
||||
{genre.icon && iconMap[genre.icon] ? iconMap[genre.icon] : <BookOpen className="h-6 w-6" />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">{genre.name}</h2>
|
||||
|
||||
Reference in New Issue
Block a user