feat: update comment section styles and improve loading states

This commit is contained in:
ngthanhvu
2026-07-31 03:14:13 -04:00
parent e429f67e0b
commit 91574aa72c
4 changed files with 166 additions and 197 deletions
+17 -11
View File
@@ -384,9 +384,9 @@ watch(() => props.slug, () => {
</script> </script>
<template> <template>
<div class="mt-10 border-t border-white/10 pt-8"> <div class="mt-10 border-white/10 pt-8">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-1 mb-6">
<AppIcon name="message-square" class="size-5 text-yellow-300" /> <AppIcon name="message-square" class="size-10" />
<h2 class="text-xl font-bold text-white">Bình luận</h2> <h2 class="text-xl font-bold text-white">Bình luận</h2>
<span class="text-sm text-slate-400">({{ comments.length }})</span> <span class="text-sm text-slate-400">({{ comments.length }})</span>
</div> </div>
@@ -421,7 +421,8 @@ watch(() => props.slug, () => {
lộ</span> lộ</span>
</div> </div>
<div class="relative"> <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> <AppIcon name="image" class="size-4" /><span class="text-white/50">GIF</span>
</button> </button>
</div> </div>
@@ -475,7 +476,9 @@ watch(() => props.slug, () => {
</div> </div>
<div v-if="comment.userRole === 'admin'" <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]"> 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> <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> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -503,12 +506,14 @@ watch(() => props.slug, () => {
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)" <button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)"
class="flex items-center gap-1 text-xs" class="flex items-center gap-1 text-xs"
:class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'"> :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> <AppIcon name="thumbs-up" class="size-3.5" /><span v-if="comment.likeCount">{{ comment.likeCount
}}</span>
</button> </button>
<button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)" <button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)"
class="flex items-center gap-1 text-xs" class="flex items-center gap-1 text-xs"
:class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'"> :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> <AppIcon name="thumbs-down" class="size-3.5" /><span v-if="comment.dislikeCount">{{
comment.dislikeCount }}</span>
</button> </button>
<button type="button" @click="startReply(comment.id)" <button type="button" @click="startReply(comment.id)"
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">
@@ -536,7 +541,8 @@ watch(() => props.slug, () => {
<button type="button" @click="isReplyAnonymous[comment.id] = !isReplyAnonymous[comment.id]" <button type="button" @click="isReplyAnonymous[comment.id] = !isReplyAnonymous[comment.id]"
class="relative w-9 h-5 rounded-full transition-colors" class="relative w-9 h-5 rounded-full transition-colors"
:class="(isReplyAnonymous[comment.id] ?? false) ? 'bg-cinek-500' : 'bg-white/20'"> :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" <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'" /> :class="(isReplyAnonymous[comment.id] ?? false) ? 'translate-x-4' : 'translate-x-0'" />
</button> </button>
<span class="text-xs text-slate-500">Tiết lộ</span> <span class="text-xs text-slate-500">Tiết lộ</span>
@@ -575,9 +581,9 @@ watch(() => props.slug, () => {
: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" :open-gif-picker="openGifPicker" :is-reply-anonymous="isReplyAnonymous" :is-submitting-reply="isSubmittingReply"
@start-reply="startReply" @cancel-reply="cancelReply" :open-gif-picker="openGifPicker" @start-reply="startReply" @cancel-reply="cancelReply"
@submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" /> @submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
</div> </div>
</div> </div>
+8 -26
View File
@@ -1,36 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WatchHistoryItem } from '~/composables/useWatchHistory' import type { WatchHistoryItem } from '~/composables/useWatchHistory'
const { data, pending, error } = useFetch('/api/movies', { const { data, pending, error } = await useFetch('/api/movies', {
query: { query: { page: 1 },
page: 1, default: () => ({ items: [], sources: [] }),
},
lazy: true,
default: () => ({
items: [],
sources: [],
}),
}) })
const { data: additionalData } = useFetch('/api/movies', { const { data: additionalData } = await useFetch('/api/movies', {
query: { query: { page: 2 },
page: 2, default: () => ({ items: [], sources: [] }),
},
lazy: true,
default: () => ({
items: [],
sources: [],
}),
}) })
const { data: sectionsData } = useFetch('/api/home/sections', { const { data: sectionsData } = await useFetch('/api/home/sections', {
lazy: true, default: () => ({ recentComments: [], trending: [], mostRated: [], hotGenres: [] }),
default: () => ({
recentComments: [],
trending: [],
mostRated: [],
hotGenres: [],
}),
}) })
const movies = computed(() => data.value?.items ?? []) const movies = computed(() => data.value?.items ?? [])
+2 -1
View File
@@ -196,7 +196,8 @@ useHead(() => ({
<!-- Synopsis (desktop) --> <!-- Synopsis (desktop) -->
<div class="hidden md:block"> <div class="hidden md:block">
<h3 class="text-[16px] font-semibold text-white mb-3">Nội dung phim</h3> <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.' }} {{ movie.content || 'Đang cập nhật nội dung phim.' }}
</p> </p>
<button v-if="movie.content" type="button" <button v-if="movie.content" type="button"
+12 -32
View File
@@ -38,14 +38,15 @@ let hlsFallbackTimer: ReturnType<typeof setTimeout> | undefined
let touchStartX = 0, touchStartY = 0, touchStartAt = 0 let touchStartX = 0, touchStartY = 0, touchStartAt = 0
let lastTapAt = 0, lastTapX = 0, holdRestoreRate = 1, ignoreNextTap = false let lastTapAt = 0, lastTapX = 0, holdRestoreRate = 1, ignoreNextTap = false
let hlsFatalRetryCount = 0, autoOrientationFullscreen = false let hlsFatalRetryCount = 0, autoOrientationFullscreen = false
let draftAutoSaveTimer: ReturnType<typeof setTimeout> | undefined
let hlsPlayer: Hls | undefined let hlsPlayer: Hls | undefined
let progressTimer: ReturnType<typeof setInterval> | undefined let progressTimer: ReturnType<typeof setInterval> | undefined
let lastSavedProgress = 0 let lastSavedProgress = 0
const { loadWatchHistory: fetchWatchHistory, saveWatchHistory: persistWatchHistory } = useWatchHistory() const { loadWatchHistory: fetchWatchHistory, saveWatchHistory: persistWatchHistory } = useWatchHistory()
const { isFavorite, saveFavorite, removeFavorite, saveWatchLater } = useMovieLibrary() const { isFavorite, saveFavorite, removeFavorite, saveWatchLater } = useMovieLibrary()
const { data: movie, pending, error } = useFetch(() => `/api/movies/${route.params.slug}`, { const { data: movie, pending, error } = await useFetch(() => `/api/movies/${route.params.slug}`, {
lazy: true, default: () => ({}), default: () => null,
}) })
const currentMovieSource = computed(() => String(movie.value?.source || '')) const currentMovieSource = computed(() => String(movie.value?.source || ''))
@@ -83,17 +84,13 @@ function formatEpisodeName(name?: string, index = 0) {
} }
function serverLabel(server: any, index: number) { function serverLabel(server: any, index: number) {
const source = String(server?.source || '').toUpperCase() const label = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
const name = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim() return label || `Server ${index + 1}`
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}`
} }
function subtitleLabel(server: any, index: number) { 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) { function episodeLink(index: number) {
@@ -434,7 +431,7 @@ useHead(() => ({
<!-- Watch Page --> <!-- Watch Page -->
<template v-else> <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 --> <!-- Player -->
<div class="-mx-4 mb-6 overflow-hidden sm:mx-0 sm:rounded-xl"> <div class="-mx-4 mb-6 overflow-hidden sm:mx-0 sm:rounded-xl">
<div ref="playerViewportRef" class="relative aspect-video bg-slate-950"> <div ref="playerViewportRef" class="relative aspect-video bg-slate-950">
@@ -599,31 +596,25 @@ useHead(() => ({
</div> </div>
</div> </div>
<!-- Main + Sidebar Grid -->
<!-- Main + Sidebar Grid --> <!-- Main + Sidebar Grid -->
<div class="mt-4 grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_300px]"> <div class="mt-4 grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_300px]">
<!-- Left Column --> <!-- Left Column -->
<div class="min-w-0"> <div class="min-w-0">
<!-- Movie Info Card --> <!-- Movie Info Card -->
<div class="mb-6 flex items-start gap-6 lg:gap-8"> <div class="mb-6 flex items-start gap-6 lg:gap-8">
<div <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"> 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"> <img :src="movie.thumb || movie.poster" :alt="movie.name" class="h-full w-full object-cover">
</div> </div>
<div class="flex w-full min-w-0 flex-1 flex-col gap-4 pt-1 text-center sm:text-left"> <div class="flex w-full min-w-0 flex-1 flex-col gap-4 pt-1 text-center sm:text-left">
<div> <div>
<h1 class="mb-2 text-2xl font-bold leading-tight text-white md:text-[28px]"> <h1 class="mb-2 text-2xl font-bold leading-tight text-white md:text-[28px]">
{{ movie.name }} {{ movie.name }}
</h1> </h1>
<h2 class="text-sm font-medium text-cinek-500 opacity-90 md:text-[15px]"> <h2 class="text-sm font-medium text-cinek-500 opacity-90 md:text-[15px]">
{{ movie.originName }} {{ movie.originName }}
</h2> </h2>
</div> </div>
<!-- Badges --> <!-- Badges -->
<div class="flex flex-wrap items-center justify-center gap-2 sm:justify-start"> <div class="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
<span v-if="movie.rating" <span v-if="movie.rating"
@@ -635,24 +626,20 @@ useHead(() => ({
{{ movie.rating.toFixed(1) }} {{ movie.rating.toFixed(1) }}
</span> </span>
</span> </span>
<span v-if="movie.quality" <span v-if="movie.quality"
class="inline-flex h-5.5 items-center justify-center rounded-sm px-2 text-[11px] font-black" 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"> style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%);color:#141414">
{{ movie.quality }} {{ movie.quality }}
</span> </span>
<span v-if="movie.year" <span v-if="movie.year"
class="rounded border border-white/20 px-2 py-0.5 text-[11px] font-medium text-white/90"> class="rounded border border-white/20 px-2 py-0.5 text-[11px] font-medium text-white/90">
{{ movie.year }} {{ movie.year }}
</span> </span>
<span v-if="episodeDisplay" <span v-if="episodeDisplay"
class="rounded border border-white/20 px-2 py-0.5 text-[11px] font-medium text-white/90"> class="rounded border border-white/20 px-2 py-0.5 text-[11px] font-medium text-white/90">
{{ episodeDisplay }} {{ episodeDisplay }}
</span> </span>
</div> </div>
<!-- Genres --> <!-- Genres -->
<div v-if="movie.categories?.length" <div v-if="movie.categories?.length"
class="flex flex-wrap items-center justify-center gap-2 sm:justify-start"> class="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
@@ -661,7 +648,6 @@ useHead(() => ({
{{ cat }} {{ cat }}
</span> </span>
</div> </div>
<!-- Episode Progress --> <!-- Episode Progress -->
<div v-if="episodeProgress?.total" <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"> 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 Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total }} tập
</span> </span>
</div> </div>
<!-- Description --> <!-- Description -->
<div class="mt-2 text-left"> <div class="mt-2 text-left">
<p class="line-clamp-3 text-[13px] leading-relaxed text-white/70 md:text-sm" <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.' }} {{ movie.content || 'Đang cập nhật nội dung phim.' }}
</p> </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"> class="mt-1 inline-flex items-center gap-1 text-[13px] text-cinek-500 hover:underline">
Thông tin phim Thông tin phim
<AppIcon name="chevron-right" class="size-4" /> <AppIcon name="chevron-right" class="size-4" />
@@ -690,17 +673,14 @@ useHead(() => ({
<!-- Episode Section --> <!-- Episode Section -->
<div id="episodes-section" class="mb-8 scroll-mt-24"> <div id="episodes-section" class="mb-8 scroll-mt-24">
<!-- Header: Season + Server --> <!-- Header: Season + Server -->
<div class="mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<!-- Season --> <!-- Season -->
<div class="relative flex min-w-0 items-center gap-1.5 sm:gap-2"> <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"> <div class="flex shrink-0 items-center gap-1 text-[12px] font-semibold text-white/50">
<AppIcon name="layers" class="size-4" /> <AppIcon name="layers" class="size-4" />
Phần: Phần:
</div> </div>
<button type="button" <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"> 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> <span>Phần 1</span>
@@ -710,6 +690,7 @@ useHead(() => ({
<!-- Server --> <!-- Server -->
<div class="flex min-w-0 items-center gap-2"> <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"> <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: Chọn Server:
</span> </span>
@@ -777,8 +758,7 @@ useHead(() => ({
</h3> </h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1"> <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" <NuxtLink v-for="rel in relatedMovies" :key="rel.slug" :to="`/phim/${rel.slug}`"
:to="`/phim/${rel.slug}`"
class="group flex gap-3 rounded-lg bg-[#1A1A24] p-2 transition hover:bg-white/5"> 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"> <img :src="rel.thumb || rel.poster" :alt="rel.name" class="h-16 w-12 shrink-0 rounded object-cover">