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:
2026-03-06 17:30:56 +07:00
parent ce805adb08
commit 75ed8e233b
31 changed files with 1853 additions and 687 deletions
+47 -17
View File
@@ -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>
)
}