mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 17:47:47 +00:00
feat: implement pagination for comments with load more functionality
This commit is contained in:
@@ -23,6 +23,10 @@ const replyContent = ref<Record<number, string>>({})
|
|||||||
const isReplyingTo = ref<number | null>(null)
|
const isReplyingTo = ref<number | null>(null)
|
||||||
const isReplyAnonymous = ref<Record<number, boolean>>({})
|
const isReplyAnonymous = ref<Record<number, boolean>>({})
|
||||||
const expandedComments = ref<Set<number>>(new Set())
|
const expandedComments = ref<Set<number>>(new Set())
|
||||||
|
const commentOffset = ref(0)
|
||||||
|
const commentTotal = ref(0)
|
||||||
|
const commentLimit = 5
|
||||||
|
const isCommentsLoadingMore = ref(false)
|
||||||
|
|
||||||
function addTimeAgoToReplies(replies: any[]): any[] {
|
function addTimeAgoToReplies(replies: any[]): any[] {
|
||||||
return replies?.map(r => ({
|
return replies?.map(r => ({
|
||||||
@@ -32,21 +36,42 @@ function addTimeAgoToReplies(replies: any[]): any[] {
|
|||||||
})) || []
|
})) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadComments() {
|
async function loadComments(reset = true) {
|
||||||
if (!props.slug) return
|
if (!props.slug) return
|
||||||
isCommentsLoading.value = true
|
if (reset) {
|
||||||
|
isCommentsLoading.value = true
|
||||||
|
commentOffset.value = 0
|
||||||
|
} else {
|
||||||
|
isCommentsLoadingMore.value = true
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await fetchComments(props.source, props.slug, user.value?.id)
|
const data = await fetchComments(props.source, props.slug, user.value?.id, commentLimit, commentOffset.value)
|
||||||
comments.value = data.map(c => ({
|
const newComments = data.items.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
timeAgo: timeAgo(c.createdAt),
|
timeAgo: timeAgo(c.createdAt),
|
||||||
replies: addTimeAgoToReplies(c.replies || [])
|
replies: addTimeAgoToReplies(c.replies || [])
|
||||||
}))
|
}))
|
||||||
|
if (reset) {
|
||||||
|
comments.value = newComments
|
||||||
|
} else {
|
||||||
|
comments.value = [...comments.value, ...newComments]
|
||||||
|
}
|
||||||
|
commentTotal.value = data.total
|
||||||
|
commentOffset.value += data.items.length
|
||||||
} finally {
|
} finally {
|
||||||
isCommentsLoading.value = false
|
isCommentsLoading.value = false
|
||||||
|
isCommentsLoadingMore.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMoreComments() {
|
||||||
|
await loadComments(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMoreComments() {
|
||||||
|
return comments.value.length < commentTotal.value
|
||||||
|
}
|
||||||
|
|
||||||
function loadCommentDraft() {
|
function loadCommentDraft() {
|
||||||
const draft = getDraft(props.slug, props.source)
|
const draft = getDraft(props.slug, props.source)
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -418,8 +443,7 @@ watch(() => props.slug, () => {
|
|||||||
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
|
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
|
||||||
<CornerDownLeft class="size-3.5" /><span>Trả lời</span>
|
<CornerDownLeft class="size-3.5" /><span>Trả lời</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="user && user.role === 'admin'" type="button"
|
<button v-if="user && user.role === 'admin'" type="button" @click="handleTogglePin(comment.id)"
|
||||||
@click="handleTogglePin(comment.id)"
|
|
||||||
class="flex items-center gap-1.5 text-xs"
|
class="flex items-center gap-1.5 text-xs"
|
||||||
:class="comment.pinned ? 'text-cinek-400 hover:text-cinek-300' : 'text-slate-600 hover:text-slate-400'">
|
:class="comment.pinned ? 'text-cinek-400 hover:text-cinek-300' : 'text-slate-600 hover:text-slate-400'">
|
||||||
<Pin class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span>
|
<Pin class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span>
|
||||||
@@ -464,16 +488,17 @@ watch(() => props.slug, () => {
|
|||||||
class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
|
class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
|
||||||
<ChevronUp v-if="expandedComments.has(comment.id)" class="size-3" />
|
<ChevronUp v-if="expandedComments.has(comment.id)" class="size-3" />
|
||||||
<ChevronDown v-else class="size-3" />
|
<ChevronDown v-else class="size-3" />
|
||||||
<span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị ${countAllReplies(comment.replies)} phản hồi` }}</span>
|
<span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị
|
||||||
|
${countAllReplies(comment.replies)} phản hồi` }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="replies-container pl-3 border-l-2 border-white/10"
|
<div class="replies-container pl-3 border-l-2 border-white/10"
|
||||||
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
|
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
||||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
:is-replying-to="isReplyingTo" :reply-content="replyContent"
|
||||||
:is-submitting-reply="isSubmittingReply"
|
:is-reply-anonymous="isReplyAnonymous" :is-submitting-reply="isSubmittingReply"
|
||||||
@start-reply="startReply" @cancel-reply="cancelReply" @submit-reply="submitReply"
|
@start-reply="startReply" @cancel-reply="cancelReply" @submit-reply="submitReply"
|
||||||
@vote="handleVote" @delete="handleDeleteComment" />
|
@vote="handleVote" @delete="handleDeleteComment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,7 +508,23 @@ watch(() => props.slug, () => {
|
|||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
<!-- Load More Button -->
|
||||||
|
<div v-if="hasMoreComments()" class="flex justify-center mt-6">
|
||||||
|
<button type="button" @click="loadMoreComments"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-5 py-2.5 text-sm font-medium text-slate-300 hover:bg-white/10 hover:text-white transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isCommentsLoadingMore">
|
||||||
|
<Loader2 v-if="isCommentsLoadingMore" class="size-4 animate-spin" />
|
||||||
|
<span>{{ isCommentsLoadingMore ? 'Đang tải...' : `Xem thêm bình luận (${comments.length}/${commentTotal})`
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hasMoreComments() && comments.length && commentTotal > commentLimit" class="text-center mt-4">
|
||||||
|
<span class="text-xs text-slate-600">Đã hiển thị tất cả {{ commentTotal }} bình luận</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!comments.length && !isCommentsLoading"
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<MessageSquare class="size-10 text-white/10 mb-3" />
|
<MessageSquare class="size-10 text-white/10 mb-3" />
|
||||||
<p class="text-slate-400 text-sm">Chưa có bình luận nào.</p>
|
<p class="text-slate-400 text-sm">Chưa có bình luận nào.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,10 +536,12 @@ watch(() => props.slug, () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.35s ease, opacity 0.3s ease;
|
transition: max-height 0.35s ease, opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replies-container.collapsed {
|
.replies-container.collapsed {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replies-container.expanded {
|
.replies-container.expanded {
|
||||||
max-height: 5000px;
|
max-height: 5000px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -509,10 +552,12 @@ watch(() => props.slug, () => {
|
|||||||
.comment-list-leave-active {
|
.comment-list-leave-active {
|
||||||
transition: all 0.4s ease;
|
transition: all 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-list-enter-from {
|
.comment-list-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-20px);
|
transform: translateY(-20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-list-leave-to {
|
.comment-list-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50px);
|
transform: translateX(-50px);
|
||||||
@@ -520,6 +565,7 @@ watch(() => props.slug, () => {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-list-leave-active {
|
.comment-list-leave-active {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ export type Comment = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useComments() {
|
export function useComments() {
|
||||||
async function fetchComments(source: string, slug: string, userId?: number) {
|
async function fetchComments(source: string, slug: string, userId?: number, limit?: number, offset?: number) {
|
||||||
try {
|
try {
|
||||||
const data = await $fetch('/api/comments', {
|
const data = await $fetch('/api/comments', {
|
||||||
query: { source, slug, userId },
|
query: { source, slug, userId, limit, offset },
|
||||||
})
|
})
|
||||||
return data.items as Comment[]
|
return data as { items: Comment[], total: number }
|
||||||
} catch {
|
} catch {
|
||||||
return [] as Comment[]
|
return { items: [] as Comment[], total: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ useHead(() => ({
|
|||||||
|
|
||||||
<!-- Content overlaps hero -->
|
<!-- Content overlaps hero -->
|
||||||
<div class="max-w-350 mx-auto px-4 -mt-24 md:-mt-48 lg:-mt-64 relative z-10 mb-20">
|
<div class="max-w-350 mx-auto px-4 -mt-24 md:-mt-48 lg:-mt-64 relative z-10 mb-20">
|
||||||
<div class="flex flex-col md:flex-row gap-6 md:gap-10 items-start">
|
<div class="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||||
<!-- Poster Column -->
|
<!-- Poster Column -->
|
||||||
<div class="shrink-0 w-40 md:w-56 lg:w-64 max-w-70 mx-auto md:mx-0 flex flex-col gap-8">
|
<div class="shrink-0 w-40 md:w-56 lg:w-64 max-w-70 mx-auto md:mx-0 flex flex-col gap-8">
|
||||||
<div
|
<div
|
||||||
@@ -497,11 +497,15 @@ useHead(() => ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment Section -->
|
||||||
|
<div class="mt-10">
|
||||||
|
<ClientOnly>
|
||||||
|
<CommentSection :source="activeSource" :slug="String(route.params.slug)" :movie-name="movie?.name" />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ClientOnly>
|
|
||||||
<CommentSection :source="activeSource" :slug="String(route.params.slug)" :movie-name="movie?.name" />
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ useHead(() => ({
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-semibold text-white">{{ formatEpisodeName(activeEpisode?.name, selectedEpisode)
|
<span class="font-semibold text-white">{{ formatEpisodeName(activeEpisode?.name, selectedEpisode)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="mx-2 text-white/30">•</span>
|
<span class="mx-2 text-white/30">•</span>
|
||||||
<span class="text-white/60">{{ serverLabel(activeServer, selectedServer) }}</span>
|
<span class="text-white/60">{{ serverLabel(activeServer, selectedServer) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,7 +658,7 @@ useHead(() => ({
|
|||||||
class="inline-flex self-center sm:self-start items-center gap-1.5 px-3 py-1.5 rounded-full mt-2 border bg-cinek-500/10 border-cinek-500/20 text-cinek-500">
|
class="inline-flex self-center sm:self-start items-center gap-1.5 px-3 py-1.5 rounded-full mt-2 border bg-cinek-500/10 border-cinek-500/20 text-cinek-500">
|
||||||
<Loader2 class="size-3.5 animate-spin" />
|
<Loader2 class="size-3.5 animate-spin" />
|
||||||
<span class="text-xs font-medium">Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total
|
<span class="text-xs font-medium">Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total
|
||||||
}} tập</span>
|
}} tập</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -758,9 +758,11 @@ useHead(() => ({
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<div class="mb-3">
|
||||||
<CommentSection :source="currentMovieSource" :slug="String(route.params.slug)" :movie-name="movie?.name" />
|
<ClientOnly>
|
||||||
</ClientOnly>
|
<CommentSection :source="currentMovieSource" :slug="String(route.params.slug)" :movie-name="movie?.name" />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
@@ -894,5 +896,4 @@ useHead(() => ({
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(90deg, rgb(250 204 21) v-bind('`${progressPercent}%`'), transparent 0);
|
background: linear-gradient(90deg, rgb(250 204 21) v-bind('`${progressPercent}%`'), transparent 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { comments, users, commentVotes } from '../../database/schema'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const { source = '', slug, userId } = query
|
const { source = '', slug, userId, limit = '20', offset = '0' } = query
|
||||||
|
const pageLimit = Math.min(Math.max(Number(limit), 1), 100)
|
||||||
|
const pageOffset = Math.max(Number(offset), 0)
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
throw createError({ statusCode: 400, message: 'Thiếu slug phim' })
|
throw createError({ statusCode: 400, message: 'Thiếu slug phim' })
|
||||||
@@ -20,6 +22,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
whereConditions.push(eq(comments.source, String(source)))
|
whereConditions.push(eq(comments.source, String(source)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count total comments
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(comments)
|
||||||
|
.where(and(...whereConditions))
|
||||||
|
|
||||||
// Fetch pinned comments first, then regular ones
|
// Fetch pinned comments first, then regular ones
|
||||||
const results = await db
|
const results = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -43,7 +51,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
.leftJoin(users, eq(comments.userId, users.id))
|
.leftJoin(users, eq(comments.userId, users.id))
|
||||||
.where(and(...whereConditions))
|
.where(and(...whereConditions))
|
||||||
.orderBy(sql`${comments.pinned} DESC, ${comments.createdAt} DESC`)
|
.orderBy(sql`${comments.pinned} DESC, ${comments.createdAt} DESC`)
|
||||||
.limit(100)
|
.limit(pageLimit)
|
||||||
|
.offset(pageOffset)
|
||||||
|
|
||||||
const commentIds = results.map(r => r.id)
|
const commentIds = results.map(r => r.id)
|
||||||
|
|
||||||
@@ -150,5 +159,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
userVote: userVotes[r.id] || 0,
|
userVote: userVotes[r.id] || 0,
|
||||||
replies: (repliesByParent[r.id] || []).map(attachChildren),
|
replies: (repliesByParent[r.id] || []).map(attachChildren),
|
||||||
})),
|
})),
|
||||||
|
total: Number(count),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user