Add database migration snapshots for comment_votes, comments, movies, and users tables

- Created 0009_snapshot.json with initial structure for comment_votes, comments, movies, and users tables.
- Created 0010_snapshot.json with updated structure for comments, including new fields: pinned and spoiler.
- Created 0011_snapshot.json with further updates to comments, adding anonymous field and refining existing structures.
This commit is contained in:
ngthanhvu
2026-07-24 00:06:03 -04:00
parent 70d8fc5bfa
commit 3d075f320c
22 changed files with 2832 additions and 74 deletions
+364
View File
@@ -0,0 +1,364 @@
<script setup lang="ts">
import { CornerDownLeft, Crown, Loader2, MessageSquare, Send, ThumbsDown, ThumbsUp, Trash2, User } from 'lucide-vue-next'
const props = defineProps<{
source: string
slug: string
movieName?: string
}>()
const { user } = useAuth()
const { fetchComments, postComment, deleteComment, voteComment } = useComments()
const { getDraft, saveDraft, deleteDraft } = useCommentDraft()
const commentContent = ref('')
const isCommentSubmitting = ref(false)
const hasDraft = ref(false)
const isAnonymous = ref(false)
let draftAutoSaveTimer: ReturnType<typeof setTimeout> | undefined
const comments = ref<any[]>([])
const isCommentsLoading = ref(false)
const replyContent = ref<Record<number, string>>({})
const isReplyingTo = ref<number | null>(null)
const expandedComments = ref<Set<number>>(new Set())
async function loadComments() {
if (!props.slug) return
isCommentsLoading.value = true
try {
comments.value = await fetchComments(props.source, props.slug, user.value?.id)
} finally {
isCommentsLoading.value = false
}
}
function loadCommentDraft() {
const draft = getDraft(props.slug, props.source)
if (draft) {
commentContent.value = draft.content
hasDraft.value = true
}
}
function triggerAutoSaveDraft() {
if (draftAutoSaveTimer) clearTimeout(draftAutoSaveTimer)
draftAutoSaveTimer = setTimeout(() => {
if (!commentContent.value.trim()) return
saveDraft(props.slug, props.source, commentContent.value.trim(), props.movieName)
hasDraft.value = true
}, 1000)
}
function handleDeleteDraft() {
saveDraft(props.slug, props.source, '', props.movieName)
deleteDraft(props.slug, props.source)
commentContent.value = ''
hasDraft.value = false
}
async function handleSubmitComment() {
if (!commentContent.value.trim() || isCommentSubmitting.value || !user.value) return
isCommentSubmitting.value = true
try {
await postComment(props.source, props.slug, commentContent.value.trim(), props.movieName, undefined, false, isAnonymous.value)
deleteDraft(props.slug, props.source)
commentContent.value = ''
hasDraft.value = false
isAnonymous.value = false
await loadComments()
} catch (e: any) {
console.error('Lỗi:', e)
} finally {
isCommentSubmitting.value = false
}
}
async function handleDeleteComment(id: number) {
try {
await deleteComment(id)
await loadComments()
} catch (e: any) {
console.error('Lỗi:', e)
}
}
function startReply(commentId: number) {
if (!user.value) return
isReplyingTo.value = commentId
replyContent.value[commentId] = ''
}
function cancelReply() {
isReplyingTo.value = null
}
async function submitReply(commentId: number) {
const content = replyContent.value[commentId]?.trim()
if (!content || !user.value) return
try {
await postComment(props.source, props.slug, content, props.movieName, commentId, false, isAnonymous.value)
replyContent.value[commentId] = ''
isReplyingTo.value = null
isAnonymous.value = false
await loadComments()
} catch (e: any) {
console.error('Lỗi:', e)
}
}
async function handleVote(commentId: number, vote: number, isReply = false, parentId?: number) {
if (!user.value) return
try {
const result = await voteComment(commentId, vote) as any
if (isReply && parentId) {
const comment = comments.value.find((c: any) => c.id === parentId)
const reply = comment?.replies?.find((r: any) => r.id === commentId)
if (reply) {
reply.userVote = result.vote
reply.likeCount = result.likeCount
reply.dislikeCount = result.dislikeCount
}
} else {
const comment = comments.value.find((c: any) => c.id === commentId)
if (comment) {
comment.userVote = result.vote
comment.likeCount = result.likeCount
comment.dislikeCount = result.dislikeCount
}
}
} catch {
console.error('Lỗi')
}
}
function toggleExpand(commentId: number) {
const s = new Set(expandedComments.value)
if (s.has(commentId)) s.delete(commentId)
else s.add(commentId)
expandedComments.value = s
}
function timeAgo(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (seconds < 60) return 'Vừa xong'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes} phút trước`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} giờ trước`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} ngày trước`
return date.toLocaleDateString('vi-VN')
}
onMounted(() => {
loadCommentDraft()
loadComments()
})
watch(() => props.slug, () => {
loadCommentDraft()
loadComments()
})
</script>
<template>
<div class="mt-10 border-t border-white/10 pt-8">
<div class="flex items-center gap-3 mb-6">
<MessageSquare class="size-5 text-yellow-300" />
<h2 class="text-xl font-bold text-white">Bình luận</h2>
<span class="text-sm text-slate-400">({{ comments.length }})</span>
</div>
<div v-if="!user" class="flex items-center gap-3 mb-8 p-4 bg-[#13151f] border border-white/5 rounded-xl">
<p class="text-sm text-slate-400">Đăng nhập để bình luận về phim này.</p>
</div>
<div v-else class="mb-8 p-4 rounded-xl bg-[#13151f] border border-white/5">
<div class="flex gap-3">
<div class="size-9 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5">
<img v-if="user?.avatar" :src="user.avatar" class="size-9 rounded-full object-cover" alt="" />
<User v-else class="size-5 text-slate-500" />
</div>
<div class="flex-1 min-w-0">
<textarea
v-model="commentContent"
@input="triggerAutoSaveDraft"
placeholder="Viết bình luận..."
rows="3"
maxlength="1000"
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition"
/>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">Công khai</span>
<button
type="button"
@click="isAnonymous = !isAnonymous"
class="relative w-9 h-5 rounded-full transition-colors"
:class="isAnonymous ? '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="isAnonymous ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
<span class="text-xs" :class="isAnonymous ? 'text-cinek-400' : 'text-slate-500'">Ẩn danh</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-600">{{ commentContent.length }} / 1000</span>
<button
type="button"
:disabled="!commentContent.trim() || isCommentSubmitting"
@click="handleSubmitComment"
class="flex items-center gap-1.5 rounded-lg bg-cinek-500 px-4 py-1.5 text-xs font-bold text-slate-950 hover:bg-cinek-400 disabled:cursor-not-allowed disabled:opacity-40 transition"
>
<Loader2 v-if="isCommentSubmitting" class="size-3.5 animate-spin" />
<Send class="size-3.5" v-else />
<span>Gửi</span>
</button>
</div>
</div>
<div v-if="hasDraft" class="mt-2 flex items-center gap-2">
<span class="text-xs text-yellow-400/70 flex items-center gap-1">
<span class="size-1.5 rounded-full bg-yellow-400/50 inline-block" />
nháp đã lưu
</span>
<button type="button" @click="handleDeleteDraft" class="text-xs text-slate-500 hover:text-red-400 transition">Xóa nháp</button>
</div>
</div>
</div>
</div>
<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>
</div>
</div>
<div v-else-if="comments.length" class="space-y-1">
<div v-for="comment in comments" :key="comment.id">
<div v-if="comment.pinned" class="flex items-center gap-1.5 mb-2 px-2">
<span class="text-xs font-bold text-cinek-400">📌 Ghim bởi Admin</span>
</div>
<div class="rounded-xl p-4 border-t border-white/5" :class="comment.pinned ? 'bg-yellow-500/10 border border-yellow-500/20' : ''">
<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' && !comment.anonymous ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''">
<img v-if="comment.userAvatar && !comment.anonymous" :src="comment.userAvatar" class="size-10 rounded-full object-cover" alt="" />
<User v-else class="size-5 text-slate-500" />
</div>
<div v-if="comment.userRole === 'admin' && !comment.anonymous" class="absolute -top-1 -left-1 size-4 bg-cinek-500 rounded-full flex items-center justify-center">
<Crown class="size-2.5 text-slate-950" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span v-if="comment.userRole === 'admin' && !comment.anonymous" 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>
<span v-if="comment.anonymous" class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-white/10 text-slate-400">Ẩn danh</span>
<span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span>
</div>
<p class="text-sm text-slate-300 whitespace-pre-wrap break-words">{{ comment.content }}</p>
<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'">
<ThumbsUp class="size-3.5" /><span>{{ comment.likeCount || 0 }}</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'">
<ThumbsDown class="size-3.5" /><span>{{ comment.dislikeCount || 0 }}</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">
<CornerDownLeft class="size-3.5" /><span>Trả lời</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">
<Trash2 class="size-3.5" />
</button>
</div>
<div v-if="isReplyingTo === comment.id" class="mt-3 flex items-start gap-2">
<div class="size-7 rounded-full bg-white/10 flex items-center justify-center shrink-0"><User class="size-3.5 text-slate-500" /></div>
<div class="flex-1">
<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 gap-2 mt-2">
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">Công khai</span>
<button type="button" @click="isAnonymous = !isAnonymous" class="relative w-8 h-4 rounded-full transition-colors" :class="isAnonymous ? 'bg-cinek-500' : 'bg-white/20'">
<span class="absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow transition-transform" :class="isAnonymous ? 'translate-x-4' : 'translate-x-0'" />
</button>
<span class="text-xs" :class="isAnonymous ? 'text-cinek-400' : 'text-slate-500'">Ẩn danh</span>
</div>
<button type="button" @click="submitReply(comment.id)" class="px-3 py-1 rounded-lg bg-cinek-500 text-xs font-bold text-slate-950 hover:bg-cinek-400 transition">Trả lời</button>
<button type="button" @click="cancelReply" class="px-3 py-1 rounded-lg text-xs text-slate-500 hover:text-white transition">Hủy</button>
</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">
<span>{{ expandedComments.has(comment.id) ? '▲' : '▼' }}</span>
<span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị ${comment.replies.length} phản hồi` }}</span>
</button>
<div class="space-y-3 pl-3 border-r-2 border-white/10" :class="!expandedComments.has(comment.id) ? 'max-h-0 overflow-hidden' : ''">
<div v-for="reply in comment.replies" :key="reply.id" class="flex items-start gap-2">
<div class="shrink-0 relative">
<div class="size-7 rounded-full bg-white/10 flex items-center justify-center" :class="reply.userRole === 'admin' && !reply.anonymous ? 'ring-2 ring-yellow-400 ring-offset-1 ring-offset-[#0d0f17]' : ''">
<img v-if="reply.userAvatar && !reply.anonymous" :src="reply.userAvatar" class="size-7 rounded-full object-cover" alt="" />
<User v-else class="size-3.5 text-slate-500" />
</div>
<div v-if="reply.userRole === 'admin' && !reply.anonymous" class="absolute -top-0.5 -left-0.5 size-3.5 bg-cinek-500 rounded-full flex items-center justify-center">
<Crown class="size-2 text-slate-950" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-0.5">
<span v-if="reply.userRole === 'admin' && !reply.anonymous" class="px-1.5 py-0.5 rounded text-[9px] font-black bg-yellow-400 text-slate-900">ADMIN</span>
<span class="text-xs font-semibold text-white">{{ reply.userName }}</span>
<span v-if="reply.anonymous" class="px-1 py-0.5 rounded text-[9px] font-medium bg-white/10 text-slate-400">Ẩn danh</span>
<span class="text-xs text-slate-600">{{ timeAgo(reply.createdAt) }}</span>
</div>
<p class="text-xs text-slate-400 whitespace-pre-wrap break-words">{{ reply.content }}</p>
<div class="flex items-center gap-3 mt-1.5">
<button type="button" @click="handleVote(reply.id, reply.userVote === 1 ? 0 : 1, true, comment.id)" class="flex items-center gap-1 text-xs" :class="reply.userVote === 1 ? 'text-cinek-400' : 'text-slate-600 hover:text-slate-400'">
<ThumbsUp class="size-3" /><span>{{ reply.likeCount || 0 }}</span>
</button>
<button type="button" @click="handleVote(reply.id, reply.userVote === -1 ? 0 : -1, true, comment.id)" class="flex items-center gap-1 text-xs" :class="reply.userVote === -1 ? 'text-red-400' : 'text-slate-600 hover:text-slate-400'">
<ThumbsDown class="size-3" /><span>{{ reply.dislikeCount || 0 }}</span>
</button>
<button type="button" @click="startReply(reply.id)" class="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400">
<CornerDownLeft class="size-3" /><span>Trả lời</span>
</button>
<button v-if="user && (user.id === reply.userId || user.role === 'admin')" type="button" @click="handleDeleteComment(reply.id)" class="text-xs text-slate-600 hover:text-red-400">
<Trash2 class="size-3" />
</button>
</div>
<div v-if="isReplyingTo === reply.id" class="mt-2 flex items-start gap-2">
<div class="size-6 rounded-full bg-white/10 flex items-center justify-center shrink-0"><User class="size-3 text-slate-500" /></div>
<div class="flex-1">
<textarea v-model="replyContent[reply.id]" rows="2" maxlength="1000" class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-2 py-1.5 text-xs 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 gap-2 mt-1.5">
<button type="button" @click="submitReply(reply.id)" class="px-2 py-0.5 rounded bg-cinek-500 text-[10px] font-bold text-slate-950 hover:bg-cinek-400 transition">Trả lời</button>
<button type="button" @click="cancelReply" class="px-2 py-0.5 rounded text-[10px] text-slate-500 hover:text-white transition">Hủy</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare class="size-10 text-white/10 mb-3" />
<p class="text-slate-400 text-sm">Chưa bình luận nào.</p>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
export type CommentDraft = {
slug: string
source: string
content: string
movieName?: string
savedAt: number
}
const draftKey = 'cinek-comment-drafts'
function readDrafts(): CommentDraft[] {
if (!import.meta.client) return []
try {
const raw = window.localStorage.getItem(draftKey)
const drafts = raw ? JSON.parse(raw) : []
return Array.isArray(drafts) ? drafts : []
} catch {
return []
}
}
function writeDrafts(drafts: CommentDraft[]) {
if (!import.meta.client) return
window.localStorage.setItem(draftKey, JSON.stringify(drafts))
}
function findDraftKey(slug: string, source: string) {
return `${source}::${slug}`
}
export function useCommentDraft() {
function getDraft(slug: string, source: string): CommentDraft | null {
const drafts = readDrafts()
const key = findDraftKey(slug, source)
return drafts.find(d => findDraftKey(d.slug, d.source) === key) || null
}
function saveDraft(slug: string, source: string, content: string, movieName?: string) {
const drafts = readDrafts()
const key = findDraftKey(slug, source)
const filtered = drafts.filter(d => findDraftKey(d.slug, d.source) !== key)
filtered.unshift({ slug, source, content, movieName, savedAt: Date.now() })
writeDrafts(filtered.slice(0, 50))
}
function deleteDraft(slug: string, source: string) {
const drafts = readDrafts()
const key = findDraftKey(slug, source)
const filtered = drafts.filter(d => findDraftKey(d.slug, d.source) !== key)
writeDrafts(filtered)
}
function getAllDrafts(): CommentDraft[] {
return readDrafts()
}
function clearAllDrafts() {
if (import.meta.client) {
window.localStorage.removeItem(draftKey)
}
}
function clearExpiredDrafts(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
const drafts = readDrafts().filter(d => Date.now() - d.savedAt < maxAgeMs)
writeDrafts(drafts)
}
return { getDraft, saveDraft, deleteDraft, getAllDrafts, clearAllDrafts, clearExpiredDrafts }
}
+83
View File
@@ -0,0 +1,83 @@
export type CommentReply = {
id: number
userId: number
userName: string
userAvatar?: string
parentId: number
content: string
likeCount: number
dislikeCount: number
createdAt: string
userVote: number
pinned: boolean
spoiler: boolean
anonymous: boolean
}
export type Comment = {
id: number
userId: number
userName: string
userAvatar?: string
source: string
slug: string
movieName?: string
content: string
likeCount: number
dislikeCount: number
createdAt: string
userVote: number
replies: CommentReply[]
pinned: boolean
spoiler: boolean
anonymous: boolean
}
export function useComments() {
async function fetchComments(source: string, slug: string, userId?: number) {
try {
const data = await $fetch('/api/comments', {
query: { source, slug, userId },
})
return data.items as Comment[]
} catch {
return [] as Comment[]
}
}
async function postComment(
source: string,
slug: string,
content: string,
movieName?: string,
parentId?: number,
spoiler?: boolean,
anonymous?: boolean,
) {
return $fetch('/api/comments', {
method: 'POST',
body: { source, slug, content, movieName, parentId, spoiler, anonymous },
})
}
async function deleteComment(id: number) {
return $fetch(`/api/comments/${id}`, {
method: 'DELETE',
})
}
async function voteComment(commentId: number, vote: number) {
return $fetch('/api/comments/vote', {
method: 'POST',
body: { commentId, vote },
})
}
async function togglePinComment(id: number) {
return $fetch(`/api/comments/${id}/pin`, {
method: 'POST',
})
}
return { fetchComments, postComment, deleteComment, voteComment, togglePinComment }
}
+15 -37
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ChevronDown, Heart, MessageCircle, Play, Plus, Server, Share2, Star, Video } from 'lucide-vue-next'
import { ChevronDown, ChevronRight, Heart, MessageSquare, Play, Plus, Server, Share2, Star, Video } from 'lucide-vue-next'
const route = useRoute()
const requestedSource = computed(() => String(route.query.source || 'nguonc'))
@@ -31,26 +31,8 @@ const activeServer = computed(() => servers.value[selectedServer.value])
const activeSource = computed(() => String(activeServer.value?.source || movie.value?.source || route.query.source || 'nguonc'))
const activeSourceSlug = computed(() => String(activeServer.value?.sourceSlug || movie.value?.slug || route.params.slug))
const activeSourceServerIndex = computed(() => Number(activeServer.value?.sourceServerIndex ?? 0))
const sourceOptions = computed(() =>
(movie.value?.sources || [{ source: activeSource.value, slug: activeSourceSlug.value }])
.map((source: any) => ({
label: String(source.source || '').toUpperCase(),
value: source.source,
slug: source.slug,
}))
.filter((source: any) => source.value && source.slug),
)
const actors = computed(() => movie.value?.actors ?? [])
const actorSummary = computed(() => actors.value.map((actor: any) => actor.name).filter(Boolean).slice(0, 6).join(', '))
const firstWatchLink = computed(() => ({
path: `/xem/${activeSourceSlug.value}`,
query: {
source: activeSource.value,
srcs: requestedSources.value || undefined,
server: activeSourceServerIndex.value,
ep: 1,
},
}))
const episodeCount = computed(() => {
const total = servers.value.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0)
return total || undefined
@@ -67,6 +49,14 @@ const libraryItem = computed(() => {
updatedAt: Date.now(),
}
})
const firstWatchLink = computed(() => ({
path: `/xem/${activeSourceSlug.value}`,
query: { source: activeSource.value, srcs: requestedSources.value || undefined, server: activeSourceServerIndex.value, ep: 1 },
}))
function actorInitial(name: string) {
return name.trim().charAt(0).toUpperCase()
}
function episodeLink(index: number) {
return {
@@ -102,10 +92,6 @@ function formatEpisodeName(name: string, index: number) {
return /^\d+$/.test(label) ? `Tập ${label}` : label
}
function actorInitial(name: string) {
return name.trim().charAt(0).toUpperCase()
}
function flashActionMessage(message: string) {
actionMessage.value = message
window.setTimeout(() => {
@@ -159,6 +145,8 @@ async function shareMovie() {
onMounted(async () => {
await refreshFavoriteState()
// loadCommentDraft()
// await loadComments()
await $fetch(`/api/movies/${route.params.slug}/view`, {
method: 'POST',
query: { source: route.query.source },
@@ -426,7 +414,7 @@ useHead(() => ({
</div>
<!-- Tabs: Episodes / Actors (inside info column) -->
<div class="mt-6 border-t border-white/10 pt-6">
<div class="mt-6 border-white/10 pt-6">
<div class="flex items-center gap-6 border-b border-white/10 mb-6 overflow-x-auto">
<button type="button"
class="pb-3 text-[13px] md:text-[15px] font-semibold transition-colors relative whitespace-nowrap"
@@ -511,19 +499,9 @@ useHead(() => ({
</div>
</div>
</div>
<!-- Comment Section -->
<div class="mt-10 border-t border-white/10 pt-8">
<div class="flex items-center gap-3 mb-6">
<MessageCircle class="size-5 text-yellow-300" />
<h2 class="text-xl font-bold text-white">Bình luận</h2>
</div>
<div
class="flex flex-col items-center justify-center py-10 text-center border border-white/10 rounded-xl bg-[#191b24]">
<MessageCircle class="size-10 text-slate-500 mb-3" />
<p class="text-sm text-slate-400">Tính năng bình luận đang được phát triển.</p>
</div>
</div>
<ClientOnly>
<CommentSection :source="activeSource" :slug="String(route.params.slug)" :movie-name="movie?.name" />
</ClientOnly>
</div>
</template>
</div>
+15 -36
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type Hls from 'hls.js'
import {
Bug, CircleHelp, FastForward, Heart, Layers, Loader2, Maximize, Pause,
PictureInPicture2, Play, Plus, Rewind, Settings, Share2, SkipForward,
Star, Volume2, VolumeX, Captions, Languages, ChevronDown, ChevronRight,
AlertTriangle, BadgeCheck, Bug, ChevronRight, CircleHelp, CornerDownLeft, Crown, Eye, EyeOff, FastForward, Heart, Image, Layers,
Loader2, LogIn, Maximize, MessageCircle, MessageSquare, Pause, PictureInPicture2, Pin, Play, Plus, Rewind,
Send, Settings, Share2, SkipForward, Star, ThumbsDown, ThumbsUp, Trash2, User, Volume2, VolumeX, Captions, Languages,
} from 'lucide-vue-next'
const route = useRoute()
@@ -55,29 +55,17 @@ const { data: movie, pending, error } = useFetch(() => `/api/movies/${route.para
watch: [() => route.query.source, () => route.query.srcs],
})
const currentMovieSource = computed(() => String(route.query.source || movie.value?.source || ''))
const libraryItem = computed(() => movie.value ? {
source: currentMovieSource.value, slug: String(route.params.slug),
name: movie.value.name, originName: movie.value.originName, thumb: movie.value.thumb, poster: movie.value.poster, updatedAt: Date.now(),
} : null)
const servers = computed(() => movie.value?.servers ?? [])
const activeServer = computed(() => servers.value[selectedServer.value] ?? servers.value[0])
const activeSubtitle = computed(() => activeServer.value?.channels?.[selectedSubtitle.value] ?? activeServer.value)
const activeEpisode = computed(() => activeSubtitle.value?.episodes?.[selectedEpisode.value] ?? activeSubtitle.value?.episodes?.[0])
const hlsPlayerUrl = computed(() => activeEpisode.value?.linkM3u8 || '')
const actorSummary = computed(() => (movie.value?.actors ?? []).map((a: any) => a.name).filter(Boolean).slice(0, 6).join(', '))
const durationSeconds = computed(() => Math.floor(videoDuration.value || parseDurationSeconds(movie.value?.time || '')))
const hasNextEpisode = computed(() => Boolean(activeSubtitle.value?.episodes?.[selectedEpisode.value + 1]))
const episodeProgress = computed(() => {
const total = movie.value?.episodeTotal ? Number(String(movie.value.episodeTotal).replace(/[^0-9]/g, '')) : 0
const available = activeSubtitle.value?.episodes?.length || 0
return { available, total }
})
function getProxyUrl(url: string) { const encoded = btoa(url); return `/api/proxy-m3u8/${encoded}` }
const progressPercent = computed(() => durationSeconds.value && progressSeconds.value ? Math.min(Math.max((progressSeconds.value / durationSeconds.value) * 100, 0), 100) : 0)
const shouldShowHlsControls = computed(() => (controlsVisible.value || !isVideoPlaying.value) && !(isHlsFullscreen.value && edgeProgressVisible.value && isVideoPlaying.value))
const skipIntroSeconds = 85
const skipOutroSeconds = computed(() => Math.max((durationSeconds.value || 0) - 85, 0))
const libraryItem = computed(() => movie.value ? {
source: String(route.query.source || movie.value.source || ''), slug: String(route.params.slug),
name: movie.value.name, originName: movie.value.originName, thumb: movie.value.thumb, poster: movie.value.poster, updatedAt: Date.now(),
} : null)
// Related movies
const { data: relatedData } = await useFetch('/api/movies', {
@@ -414,6 +402,7 @@ onBeforeUnmount(() => {
if (hasStarted.value) saveWatchHistory(); stopProgressTimer()
if (controlsHideTimer) clearTimeout(controlsHideTimer); if (edgeProgressTimer) clearTimeout(edgeProgressTimer)
if (singleTapTimer) clearTimeout(singleTapTimer); if (holdSpeedTimer) clearTimeout(holdSpeedTimer)
if (draftAutoSaveTimer) clearTimeout(draftAutoSaveTimer)
clearHlsFallbackTimer(); destroyHlsPlayer()
if (import.meta.client) { document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('keydown', handleKeyboardShortcut); document.removeEventListener('fullscreenchange', updateFullscreenState); document.removeEventListener('webkitfullscreenchange', updateFullscreenState); window.removeEventListener('orientationchange', handleOrientationFullscreen); window.removeEventListener('resize', handleOrientationFullscreen); screen.orientation?.removeEventListener?.('change', handleOrientationFullscreen) }
})
@@ -665,7 +654,7 @@ useHead(() => ({
</div>
<!-- Episode Progress -->
<div v-if="episodeProgress.total"
<div v-if="episodeProgress?.total"
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" />
<span class="text-xs font-medium">Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total
@@ -769,20 +758,9 @@ useHead(() => ({
</aside>
</div>
<!-- Comment Section -->
<div class="mb-12">
<div class="flex items-center gap-4 mb-6">
<div class="flex items-center gap-2.5">
<h2 class="text-xl font-bold text-white">Bình luận</h2>
</div>
</div>
<div class="flex items-center gap-3 mb-8 p-4 bg-[#1a1a24] border border-white/10 rounded-xl">
<p class="text-sm text-slate-400">Đăng nhập để bình luận về phim này.</p>
</div>
<div class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-slate-400 text-sm">Chưa bình luận nào.</p>
</div>
</div>
<ClientOnly>
<CommentSection :source="currentMovieSource" :slug="String(route.params.slug)" :movie-name="movie?.name" />
</ClientOnly>
</section>
</template>
</main>
@@ -916,4 +894,5 @@ useHead(() => ({
inset: 0;
background: linear-gradient(90deg, rgb(250 204 21) v-bind('`${progressPercent}%`'), transparent 0);
}
</style>