mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 12:27:47 +00:00
feat: update comment section styles and improve loading states
This commit is contained in:
+144
-138
@@ -384,9 +384,9 @@ watch(() => props.slug, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-10 border-t border-white/10 pt-8">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<AppIcon name="message-square" class="size-5 text-yellow-300" />
|
||||
<div class="mt-10 border-white/10 pt-8">
|
||||
<div class="flex items-center gap-1 mb-6">
|
||||
<AppIcon name="message-square" class="size-10" />
|
||||
<h2 class="text-xl font-bold text-white">Bình luận</h2>
|
||||
<span class="text-sm text-slate-400">({{ comments.length }})</span>
|
||||
</div>
|
||||
@@ -421,7 +421,8 @@ watch(() => props.slug, () => {
|
||||
lộ</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<button type="button" @click="openGifPicker('main')" class="comment-gif-btn flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 transition">
|
||||
<button type="button" @click="openGifPicker('main')"
|
||||
class="comment-gif-btn flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 transition">
|
||||
<AppIcon name="image" class="size-4" /><span class="text-white/50">GIF</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -449,106 +450,111 @@ watch(() => props.slug, () => {
|
||||
|
||||
<div v-if="isCommentsLoading" class="space-y-4">
|
||||
<div v-for="i in 2" :key="i" class="flex items-start gap-3 p-4 bg-[#13151f] rounded-xl animate-pulse">
|
||||
<div class="size-10 rounded-full bg-white/5 shrink-0" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-white/5 rounded w-20" />
|
||||
<div class="h-3 bg-white/5 rounded w-full" />
|
||||
<div class="h-3 bg-white/5 rounded w-3/4" />
|
||||
<div class="size-10 rounded-full bg-white/5 shrink-0" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-white/5 rounded w-20" />
|
||||
<div class="h-3 bg-white/5 rounded w-full" />
|
||||
<div class="h-3 bg-white/5 rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransitionGroup v-else-if="comments.length" name="comment-list" tag="div" class="space-y-1">
|
||||
<div v-for="comment in comments" :key="comment.id" class="mb-2">
|
||||
<div class="transition"
|
||||
:class="comment.pinned ? 'rounded-xl p-4 bg-yellow-500/5 border border-yellow-500/20' : 'py-4 border-b border-white/5'">
|
||||
<div v-if="comment.pinned" class="flex items-center gap-1.5 mb-3 text-xs text-yellow-400/80 font-medium">
|
||||
<AppIcon name="pin" class="size-3" /> Ghim bởi Admin
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="shrink-0 relative">
|
||||
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center"
|
||||
:class="comment.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''">
|
||||
<img v-if="comment.userAvatar" :src="comment.userAvatar" class="size-10 rounded-full object-cover"
|
||||
alt="" />
|
||||
<AppIcon name="user" v-else class="size-5 text-slate-500" />
|
||||
</div>
|
||||
<div v-if="comment.userRole === 'admin'"
|
||||
class="absolute -top-0.5 -left-0.5 size-3.5 bg-yellow-400 rounded-full flex items-center justify-center ring-2 ring-[#0d0f17]">
|
||||
<svg class="size-2 text-slate-950" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z"/></svg>
|
||||
</div>
|
||||
<TransitionGroup v-else-if="comments.length" name="comment-list" tag="div" class="space-y-1">
|
||||
<div v-for="comment in comments" :key="comment.id" class="mb-2">
|
||||
<div class="transition"
|
||||
:class="comment.pinned ? 'rounded-xl p-4 bg-yellow-500/5 border border-yellow-500/20' : 'py-4 border-b border-white/5'">
|
||||
<div v-if="comment.pinned" class="flex items-center gap-1.5 mb-3 text-xs text-yellow-400/80 font-medium">
|
||||
<AppIcon name="pin" class="size-3" /> Ghim bởi Admin
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span v-if="comment.pinned"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-cinek-500/20 text-cinek-400">
|
||||
<AppIcon name="pin" class="size-3" />Đã ghim
|
||||
</span>
|
||||
<span v-if="comment.userRole === 'admin'"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span>
|
||||
<span class="text-sm font-semibold text-white">{{ comment.userName }}</span>
|
||||
<AppIcon name="venus" v-if="comment.userGender === 'female'" class="size-3.5 text-pink-400" />
|
||||
<AppIcon name="mars" v-if="comment.userGender === 'male'" class="size-3.5 text-blue-400" />
|
||||
<AppIcon name="minus" v-if="comment.userGender === 'other'" class="size-3.5 text-purple-400" />
|
||||
<span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span>
|
||||
<div class="flex gap-3">
|
||||
<div class="shrink-0 relative">
|
||||
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center"
|
||||
:class="comment.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''">
|
||||
<img v-if="comment.userAvatar" :src="comment.userAvatar" class="size-10 rounded-full object-cover"
|
||||
alt="" />
|
||||
<AppIcon name="user" v-else class="size-5 text-slate-500" />
|
||||
</div>
|
||||
<div v-if="comment.userRole === 'admin'"
|
||||
class="absolute -top-0.5 -left-0.5 size-3.5 bg-yellow-400 rounded-full flex items-center justify-center ring-2 ring-[#0d0f17]">
|
||||
<svg class="size-2 text-slate-950" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm text-slate-300 whitespace-pre-wrap wrap-break-word cursor-pointer select-none py-1.5 transition-all duration-200"
|
||||
:class="comment.anonymous && !revealedComments.has(comment.id) ? 'blur-sm opacity-60' : ''"
|
||||
v-html="formatMentions(comment.content)"
|
||||
@click="comment.anonymous && (revealedComments.has(comment.id) ? revealedComments.delete(comment.id) : revealedComments.add(comment.id))">
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2.5">
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<AppIcon name="thumbs-up" class="size-3.5" /><span v-if="comment.likeCount">{{ comment.likeCount }}</span>
|
||||
</button>
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<AppIcon name="thumbs-down" class="size-3.5" /><span v-if="comment.dislikeCount">{{ comment.dislikeCount }}</span>
|
||||
</button>
|
||||
<button type="button" @click="startReply(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
|
||||
<AppIcon name="corner-down-left" class="size-3.5" /><span>Trả lời</span>
|
||||
</button>
|
||||
<button v-if="user && user.role === 'admin'" type="button" @click="handleTogglePin(comment.id)"
|
||||
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'">
|
||||
<AppIcon name="pin" class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span>
|
||||
</button>
|
||||
<button v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button"
|
||||
@click="handleDeleteComment(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-600 hover:text-red-400">
|
||||
<AppIcon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isReplyingTo === comment.id" class="mt-4">
|
||||
<div class="rounded-xl border border-white/10 bg-[#13151f] p-4">
|
||||
<textarea v-model="replyContent[comment.id]" rows="2" maxlength="1000"
|
||||
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition"
|
||||
placeholder="Viết trả lời..." />
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" @click="isReplyAnonymous[comment.id] = !isReplyAnonymous[comment.id]"
|
||||
class="relative w-9 h-5 rounded-full transition-colors"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'translate-x-4' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span v-if="comment.pinned"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-cinek-500/20 text-cinek-400">
|
||||
<AppIcon name="pin" class="size-3" />Đã ghim
|
||||
</span>
|
||||
<span v-if="comment.userRole === 'admin'"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span>
|
||||
<span class="text-sm font-semibold text-white">{{ comment.userName }}</span>
|
||||
<AppIcon name="venus" v-if="comment.userGender === 'female'" class="size-3.5 text-pink-400" />
|
||||
<AppIcon name="mars" v-if="comment.userGender === 'male'" class="size-3.5 text-blue-400" />
|
||||
<AppIcon name="minus" v-if="comment.userGender === 'other'" class="size-3.5 text-purple-400" />
|
||||
<span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm text-slate-300 whitespace-pre-wrap wrap-break-word cursor-pointer select-none py-1.5 transition-all duration-200"
|
||||
:class="comment.anonymous && !revealedComments.has(comment.id) ? 'blur-sm opacity-60' : ''"
|
||||
v-html="formatMentions(comment.content)"
|
||||
@click="comment.anonymous && (revealedComments.has(comment.id) ? revealedComments.delete(comment.id) : revealedComments.add(comment.id))">
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2.5">
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<AppIcon name="thumbs-up" class="size-3.5" /><span v-if="comment.likeCount">{{ comment.likeCount
|
||||
}}</span>
|
||||
</button>
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<AppIcon name="thumbs-down" class="size-3.5" /><span v-if="comment.dislikeCount">{{
|
||||
comment.dislikeCount }}</span>
|
||||
</button>
|
||||
<button type="button" @click="startReply(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
|
||||
<AppIcon name="corner-down-left" class="size-3.5" /><span>Trả lời</span>
|
||||
</button>
|
||||
<button v-if="user && user.role === 'admin'" type="button" @click="handleTogglePin(comment.id)"
|
||||
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'">
|
||||
<AppIcon name="pin" class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span>
|
||||
</button>
|
||||
<button v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button"
|
||||
@click="handleDeleteComment(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-600 hover:text-red-400">
|
||||
<AppIcon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isReplyingTo === comment.id" class="mt-4">
|
||||
<div class="rounded-xl border border-white/10 bg-[#13151f] p-4">
|
||||
<textarea v-model="replyContent[comment.id]" rows="2" maxlength="1000"
|
||||
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition"
|
||||
placeholder="Viết trả lời..." />
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" @click="isReplyAnonymous[comment.id] = !isReplyAnonymous[comment.id]"
|
||||
class="relative w-9 h-5 rounded-full transition-colors"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'translate-x-4' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<button type="button" @click="openGifPicker(comment.id)"
|
||||
class="reply-gif-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition"
|
||||
:class="'reply-gif-btn-' + comment.id">
|
||||
<AppIcon name="image" class="size-4" /><span>GIF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<button type="button" @click="openGifPicker(comment.id)"
|
||||
class="reply-gif-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition"
|
||||
:class="'reply-gif-btn-' + comment.id">
|
||||
<AppIcon name="image" class="size-4" /><span>GIF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-white/30">{{ (replyContent[comment.id] || '').length }} / 1000</span>
|
||||
<button type="button" @click="cancelReply(comment.id)"
|
||||
@@ -560,58 +566,58 @@ watch(() => props.slug, () => {
|
||||
<AppIcon name="send" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comment.replies?.length" class="mt-4">
|
||||
<button @click="toggleExpand(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
|
||||
<AppIcon name="chevron-up" v-if="expandedComments.has(comment.id)" class="size-3" />
|
||||
<AppIcon name="chevron-down" 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>
|
||||
</button>
|
||||
<div class="replies-container pl-3 border-l-2 border-white/10"
|
||||
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
|
||||
<div class="space-y-3">
|
||||
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||
:is-submitting-reply="isSubmittingReply" :open-gif-picker="openGifPicker"
|
||||
@start-reply="startReply" @cancel-reply="cancelReply"
|
||||
@submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
|
||||
<div v-if="comment.replies?.length" class="mt-4">
|
||||
<button @click="toggleExpand(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
|
||||
<AppIcon name="chevron-up" v-if="expandedComments.has(comment.id)" class="size-3" />
|
||||
<AppIcon name="chevron-down" 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>
|
||||
</button>
|
||||
<div class="replies-container pl-3 border-l-2 border-white/10"
|
||||
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
|
||||
<div class="space-y-3">
|
||||
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
||||
:is-replying-to="isReplyingTo" :reply-content="replyContent"
|
||||
:is-reply-anonymous="isReplyAnonymous" :is-submitting-reply="isSubmittingReply"
|
||||
:open-gif-picker="openGifPicker" @start-reply="startReply" @cancel-reply="cancelReply"
|
||||
@submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 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">
|
||||
<AppIcon name="loader" 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>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 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">
|
||||
<AppIcon name="loader" 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="!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">
|
||||
<AppIcon name="message-square" 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>
|
||||
</div>
|
||||
|
||||
<div v-if="!comments.length && !isCommentsLoading"
|
||||
class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AppIcon name="message-square" 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>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<GifPicker ref="gifPickerRef" v-model="isGifPickerOpen" @select="insertGif" />
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<GifPicker ref="gifPickerRef" v-model="isGifPickerOpen" @select="insertGif" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+8
-26
@@ -1,36 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { WatchHistoryItem } from '~/composables/useWatchHistory'
|
||||
|
||||
const { data, pending, error } = useFetch('/api/movies', {
|
||||
query: {
|
||||
page: 1,
|
||||
},
|
||||
lazy: true,
|
||||
default: () => ({
|
||||
items: [],
|
||||
sources: [],
|
||||
}),
|
||||
const { data, pending, error } = await useFetch('/api/movies', {
|
||||
query: { page: 1 },
|
||||
default: () => ({ items: [], sources: [] }),
|
||||
})
|
||||
|
||||
const { data: additionalData } = useFetch('/api/movies', {
|
||||
query: {
|
||||
page: 2,
|
||||
},
|
||||
lazy: true,
|
||||
default: () => ({
|
||||
items: [],
|
||||
sources: [],
|
||||
}),
|
||||
const { data: additionalData } = await useFetch('/api/movies', {
|
||||
query: { page: 2 },
|
||||
default: () => ({ items: [], sources: [] }),
|
||||
})
|
||||
|
||||
const { data: sectionsData } = useFetch('/api/home/sections', {
|
||||
lazy: true,
|
||||
default: () => ({
|
||||
recentComments: [],
|
||||
trending: [],
|
||||
mostRated: [],
|
||||
hotGenres: [],
|
||||
}),
|
||||
const { data: sectionsData } = await useFetch('/api/home/sections', {
|
||||
default: () => ({ recentComments: [], trending: [], mostRated: [], hotGenres: [] }),
|
||||
})
|
||||
|
||||
const movies = computed(() => data.value?.items ?? [])
|
||||
|
||||
@@ -196,7 +196,8 @@ useHead(() => ({
|
||||
<!-- Synopsis (desktop) -->
|
||||
<div class="hidden md:block">
|
||||
<h3 class="text-[16px] font-semibold text-white mb-3">Nội dung phim</h3>
|
||||
<p class="text-sm text-slate-300 leading-relaxed" :class="contentExpanded ? '' : 'line-clamp-4'">
|
||||
<p class="text-sm text-slate-300 leading-relaxed" :class="contentExpanded ? '' : 'line-clamp-4'"
|
||||
style="text-align: justify;">
|
||||
{{ movie.content || 'Đang cập nhật nội dung phim.' }}
|
||||
</p>
|
||||
<button v-if="movie.content" type="button"
|
||||
|
||||
+12
-32
@@ -38,14 +38,15 @@ let hlsFallbackTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let touchStartX = 0, touchStartY = 0, touchStartAt = 0
|
||||
let lastTapAt = 0, lastTapX = 0, holdRestoreRate = 1, ignoreNextTap = false
|
||||
let hlsFatalRetryCount = 0, autoOrientationFullscreen = false
|
||||
let draftAutoSaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let hlsPlayer: Hls | undefined
|
||||
let progressTimer: ReturnType<typeof setInterval> | undefined
|
||||
let lastSavedProgress = 0
|
||||
const { loadWatchHistory: fetchWatchHistory, saveWatchHistory: persistWatchHistory } = useWatchHistory()
|
||||
const { isFavorite, saveFavorite, removeFavorite, saveWatchLater } = useMovieLibrary()
|
||||
|
||||
const { data: movie, pending, error } = useFetch(() => `/api/movies/${route.params.slug}`, {
|
||||
lazy: true, default: () => ({}),
|
||||
const { data: movie, pending, error } = await useFetch(() => `/api/movies/${route.params.slug}`, {
|
||||
default: () => null,
|
||||
})
|
||||
|
||||
const currentMovieSource = computed(() => String(movie.value?.source || ''))
|
||||
@@ -83,17 +84,13 @@ function formatEpisodeName(name?: string, index = 0) {
|
||||
}
|
||||
|
||||
function serverLabel(server: any, index: number) {
|
||||
const source = String(server?.source || '').toUpperCase()
|
||||
const name = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
|
||||
const sameSourceServers = servers.value.filter((item: any) => item.source === server?.source)
|
||||
const sourceServerNumber = sameSourceServers.findIndex((item: any) => item === server) + 1
|
||||
if (source && sameSourceServers.length > 1) return `${source} #${sourceServerNumber || index + 1}`
|
||||
if (source) return source
|
||||
return name || `Server ${index + 1}`
|
||||
const label = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
|
||||
return label || `Server ${index + 1}`
|
||||
}
|
||||
|
||||
function subtitleLabel(server: any, index: number) {
|
||||
return String(server?.name || `Vietsub #${index + 1}`).replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
|
||||
const label = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
|
||||
return label || `Vietsub #${index + 1}`
|
||||
}
|
||||
|
||||
function episodeLink(index: number) {
|
||||
@@ -434,7 +431,7 @@ useHead(() => ({
|
||||
|
||||
<!-- Watch Page -->
|
||||
<template v-else>
|
||||
<section class="mx-auto max-w-350 px-4 pt-16 sm:px-6 lg:px-8 xl:px-10">
|
||||
<section class="mx-auto max-w-350 mt-4 px-4 pt-16 sm:px-6 lg:px-8 xl:px-10">
|
||||
<!-- Player -->
|
||||
<div class="-mx-4 mb-6 overflow-hidden sm:mx-0 sm:rounded-xl">
|
||||
<div ref="playerViewportRef" class="relative aspect-video bg-slate-950">
|
||||
@@ -599,31 +596,25 @@ useHead(() => ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main + Sidebar Grid -->
|
||||
<!-- Main + Sidebar Grid -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
|
||||
<!-- Left Column -->
|
||||
<div class="min-w-0">
|
||||
|
||||
<!-- Movie Info Card -->
|
||||
<div class="mb-6 flex items-start gap-6 lg:gap-8">
|
||||
<div
|
||||
class="hidden shrink-0 aspect-2/3 w-32 overflow-hidden rounded-xl shadow-2xl sm:block md:w-40 lg:w-37.5">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name" class="h-full w-full object-cover">
|
||||
</div>
|
||||
|
||||
<div class="flex w-full min-w-0 flex-1 flex-col gap-4 pt-1 text-center sm:text-left">
|
||||
<div>
|
||||
<h1 class="mb-2 text-2xl font-bold leading-tight text-white md:text-[28px]">
|
||||
{{ movie.name }}
|
||||
</h1>
|
||||
|
||||
<h2 class="text-sm font-medium text-cinek-500 opacity-90 md:text-[15px]">
|
||||
{{ movie.originName }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||
<span v-if="movie.rating"
|
||||
@@ -635,24 +626,20 @@ useHead(() => ({
|
||||
{{ movie.rating.toFixed(1) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span v-if="movie.quality"
|
||||
class="inline-flex h-5.5 items-center justify-center rounded-sm px-2 text-[11px] font-black"
|
||||
style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%);color:#141414">
|
||||
{{ movie.quality }}
|
||||
</span>
|
||||
|
||||
<span v-if="movie.year"
|
||||
class="rounded border border-white/20 px-2 py-0.5 text-[11px] font-medium text-white/90">
|
||||
{{ movie.year }}
|
||||
</span>
|
||||
|
||||
<span v-if="episodeDisplay"
|
||||
class="rounded border border-white/20 px-2 py-0.5 text-[11px] font-medium text-white/90">
|
||||
{{ episodeDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<div v-if="movie.categories?.length"
|
||||
class="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||
@@ -661,7 +648,6 @@ useHead(() => ({
|
||||
{{ cat }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Episode Progress -->
|
||||
<div v-if="episodeProgress?.total"
|
||||
class="mt-2 inline-flex self-center items-center gap-1.5 rounded-full border border-cinek-500/20 bg-cinek-500/10 px-3 py-1.5 text-cinek-500 sm:self-start">
|
||||
@@ -670,16 +656,13 @@ useHead(() => ({
|
||||
Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total }} tập
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-2 text-left">
|
||||
<p class="line-clamp-3 text-[13px] leading-relaxed text-white/70 md:text-sm"
|
||||
:class="descriptionExpanded ? 'line-clamp-none' : ''">
|
||||
:class="descriptionExpanded ? 'line-clamp-none' : ''" style="text-align: justify">
|
||||
{{ movie.content || 'Đang cập nhật nội dung phim.' }}
|
||||
</p>
|
||||
|
||||
<NuxtLink
|
||||
:to="{ path: `/phim/${route.params.slug}` }"
|
||||
<NuxtLink :to="{ path: `/phim/${route.params.slug}` }"
|
||||
class="mt-1 inline-flex items-center gap-1 text-[13px] text-cinek-500 hover:underline">
|
||||
Thông tin phim
|
||||
<AppIcon name="chevron-right" class="size-4" />
|
||||
@@ -690,17 +673,14 @@ useHead(() => ({
|
||||
|
||||
<!-- Episode Section -->
|
||||
<div id="episodes-section" class="mb-8 scroll-mt-24">
|
||||
|
||||
<!-- Header: Season + Server -->
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
|
||||
<!-- Season -->
|
||||
<div class="relative flex min-w-0 items-center gap-1.5 sm:gap-2">
|
||||
<div class="flex shrink-0 items-center gap-1 text-[12px] font-semibold text-white/50">
|
||||
<AppIcon name="layers" class="size-4" />
|
||||
Phần:
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="flex items-center gap-1.5 rounded-md border border-white/30 bg-white/5 px-3 py-1.5 text-sm font-medium text-white">
|
||||
<span>Phần 1</span>
|
||||
@@ -710,6 +690,7 @@ useHead(() => ({
|
||||
<!-- Server -->
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="hidden shrink-0 text-[13px] font-medium text-white/60 sm:inline-block">
|
||||
<AppIcon name="server" class="size-4 inline-block mr-1 align-text-bottom" />
|
||||
Chọn Server:
|
||||
</span>
|
||||
|
||||
@@ -777,8 +758,7 @@ useHead(() => ({
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1">
|
||||
<NuxtLink v-for="rel in relatedMovies" :key="rel.slug"
|
||||
:to="`/phim/${rel.slug}`"
|
||||
<NuxtLink v-for="rel in relatedMovies" :key="rel.slug" :to="`/phim/${rel.slug}`"
|
||||
class="group flex gap-3 rounded-lg bg-[#1A1A24] p-2 transition hover:bg-white/5">
|
||||
<img :src="rel.thumb || rel.poster" :alt="rel.name" class="h-16 w-12 shrink-0 rounded object-cover">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user