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,14 +1,37 @@
|
||||
import Link from "next/link"
|
||||
import { Eye } from "lucide-react"
|
||||
import type { Chapter } from "@/lib/types"
|
||||
import { formatViews } from "@/lib/data"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
|
||||
interface ChapterListProps {
|
||||
chapters: Chapter[]
|
||||
chapters: {
|
||||
id: string
|
||||
novelId: string
|
||||
number: number
|
||||
title: string
|
||||
createdAt: string
|
||||
views: number
|
||||
}[]
|
||||
novelSlug: string
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalChapters: number
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters, novelSlug }: ChapterListProps) {
|
||||
const generatePagination = (currentPage: number, totalPages: number) => {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, '...', totalPages]
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
|
||||
}
|
||||
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages]
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters, novelSlug, currentPage, totalPages, totalChapters }: ChapterListProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{chapters.map((chapter) => (
|
||||
@@ -30,6 +53,46 @@ export function ChapterList({ chapters, novelSlug }: ChapterListProps) {
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border p-4 bg-muted/10">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Trang <span className="font-medium text-foreground">{currentPage}</span> / {totalPages} (Tổng {totalChapters} chương)
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={currentPage > 1 ? `/truyen/${novelSlug}?page=${currentPage - 1}` : '#'}
|
||||
className={`inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-input px-3 text-sm font-medium shadow-sm transition-colors ${currentPage <= 1 ? 'pointer-events-none opacity-50 bg-muted/50 text-muted-foreground' : 'bg-background hover:bg-accent hover:text-accent-foreground'}`}
|
||||
aria-disabled={currentPage <= 1}
|
||||
>
|
||||
Trước
|
||||
</Link>
|
||||
|
||||
{generatePagination(currentPage, totalPages).map((p, i) => (
|
||||
<div key={i} className="hidden sm:block">
|
||||
{p === '...' ? (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/truyen/${novelSlug}?page=${p}`}
|
||||
className={`inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-md border text-sm font-medium shadow-sm transition-colors ${currentPage === p ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : 'bg-background border-input hover:bg-accent hover:text-accent-foreground'}`}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href={currentPage < totalPages ? `/truyen/${novelSlug}?page=${currentPage + 1}` : '#'}
|
||||
className={`inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-input px-3 text-sm font-medium shadow-sm transition-colors ${currentPage >= totalPages ? 'pointer-events-none opacity-50 bg-muted/50 text-muted-foreground' : 'bg-background hover:bg-accent hover:text-accent-foreground'}`}
|
||||
aria-disabled={currentPage >= totalPages}
|
||||
>
|
||||
Sau
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,22 +19,30 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!content.trim() || !user) return
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const newComment: Comment = {
|
||||
id: `c-${Date.now()}`,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
avatarColor: user.avatarColor,
|
||||
novelId,
|
||||
chapterId,
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!content.trim() || !user || isSubmitting) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/truyen/${novelId}/comments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: content.trim(), chapterId })
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const newComment = await res.json()
|
||||
setComments((prev) => [newComment, ...prev])
|
||||
setContent("")
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
setComments((prev) => [newComment, ...prev])
|
||||
setContent("")
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -62,7 +70,7 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="submit" size="sm" disabled={!content.trim()}>
|
||||
<Button type="submit" size="sm" disabled={!content.trim() || isSubmitting}>
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
Gửi
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Eye, Star } from "lucide-react"
|
||||
import { formatViews } from "@/lib/data"
|
||||
import { formatViews } from "@/lib/utils"
|
||||
|
||||
export interface CardNovel {
|
||||
id: string
|
||||
|
||||
+47
-17
@@ -7,14 +7,50 @@ interface StarRatingProps {
|
||||
rating: number
|
||||
ratingCount: number
|
||||
interactive?: boolean
|
||||
novelId?: string
|
||||
onRate?: (value: number) => void
|
||||
}
|
||||
|
||||
export function StarRating({ rating, ratingCount, interactive = false, onRate }: StarRatingProps) {
|
||||
export function StarRating({ rating: initialRating, ratingCount: initialCount, interactive = false, novelId, onRate }: StarRatingProps) {
|
||||
const [hover, setHover] = useState(0)
|
||||
const [selected, setSelected] = useState(0)
|
||||
const [currentRating, setCurrentRating] = useState(initialRating)
|
||||
const [currentCount, setCurrentCount] = useState(initialCount)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const displayRating = hover || selected || rating
|
||||
const displayRating = hover || selected || currentRating
|
||||
|
||||
const handleRate = async (star: number) => {
|
||||
if (!interactive || isSubmitting) return
|
||||
|
||||
setSelected(star)
|
||||
|
||||
if (onRate) {
|
||||
onRate(star)
|
||||
return
|
||||
}
|
||||
|
||||
if (novelId) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/truyen/${novelId}/rate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ score: star })
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCurrentRating(data.rating)
|
||||
setCurrentCount(data.ratingCount)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rate", error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -23,30 +59,24 @@ export function StarRating({ rating, ratingCount, interactive = false, onRate }:
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={!interactive}
|
||||
className={`${interactive ? "cursor-pointer" : "cursor-default"}`}
|
||||
onMouseEnter={() => interactive && setHover(star)}
|
||||
onMouseLeave={() => interactive && setHover(0)}
|
||||
onClick={() => {
|
||||
if (interactive && onRate) {
|
||||
setSelected(star)
|
||||
onRate(star)
|
||||
}
|
||||
}}
|
||||
disabled={!interactive || isSubmitting}
|
||||
className={`${interactive && !isSubmitting ? "cursor-pointer" : "cursor-default opacity-80"}`}
|
||||
onMouseEnter={() => interactive && !isSubmitting && setHover(star)}
|
||||
onMouseLeave={() => interactive && !isSubmitting && setHover(0)}
|
||||
onClick={() => handleRate(star)}
|
||||
aria-label={`${star} sao`}
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 transition-colors ${
|
||||
star <= displayRating
|
||||
className={`h-4 w-4 transition-colors ${star <= displayRating
|
||||
? "fill-primary text-primary"
|
||||
: "text-muted-foreground/30"
|
||||
}`}
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">{rating.toFixed(1)}</span>
|
||||
<span className="text-xs text-muted-foreground">({ratingCount} đánh giá)</span>
|
||||
<span className="text-sm font-semibold text-foreground">{currentRating.toFixed(1)}</span>
|
||||
<span className="text-xs text-muted-foreground">({currentCount} đánh giá)</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,12 +54,22 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
|
||||
const loadVoices = () => {
|
||||
const available = speechSynthesis.getVoices()
|
||||
// First try to find any Vietnamese voice ('vi-VN', 'Google Tiếng Việt', etc.)
|
||||
const viVoices = available.filter(
|
||||
(v) => v.lang.startsWith("vi") || v.name.toLowerCase().includes("vietnam")
|
||||
(v) => v.lang.startsWith("vi") || v.name.toLowerCase().includes("vietnam") || v.name.toLowerCase().includes("tiếng việt")
|
||||
)
|
||||
const allUsable = viVoices.length > 0 ? viVoices : available.slice(0, 10)
|
||||
|
||||
// Filter out overly robotic generic fallbacks if we have good ones
|
||||
const goodViVoices = viVoices.filter(v => v.name.includes("Google") || v.name.includes("Microsoft") || v.name.includes("Natural"))
|
||||
|
||||
const preferredViVoices = goodViVoices.length > 0 ? goodViVoices : viVoices
|
||||
|
||||
// If we still have NO vi voices, fallback to ALL voices so the user isn't stuck with an empty list
|
||||
const allUsable = preferredViVoices.length > 0 ? preferredViVoices : available
|
||||
|
||||
setVoices(allUsable)
|
||||
if (allUsable.length > 0 && !selectedVoiceURI) {
|
||||
// Automatically default to the first Vietnamese voice if available, otherwise just the first system voice
|
||||
setSelectedVoiceURI(allUsable[0].voiceURI)
|
||||
}
|
||||
}
|
||||
@@ -170,6 +180,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
|
||||
if (e.error !== "canceled" && e.error !== "interrupted") {
|
||||
setIsPlaying(false)
|
||||
releaseWakeLock()
|
||||
|
||||
if (e.error === "synthesis-failed" || e.error === "network") {
|
||||
// Toast notification is tricky here without importing sonner, let's just log and stop cleanly without crashing
|
||||
console.warn("Trình duyệt không hỗ trợ đọc giọng nói này hoặc bị lỗi kết nối.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user