mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 13:27:46 +00:00
feat: add episode total handling and display across movie pages
- Implemented `getEpisodeDisplay` function to format episode information for movies. - Updated movie display components to show episode information using the new function. - Added `episodeTotal` field to the movies schema and updated related API endpoints to handle this new field. - Enhanced movie synchronization process to fetch and store episode totals from external sources. - Created a new error page component for better user experience on 404 errors. - Added migration scripts to update the database schema for episode totals.
This commit is contained in:
@@ -12,6 +12,7 @@ const props = defineProps<{
|
||||
year?: number
|
||||
time?: string
|
||||
episode?: string
|
||||
episodeTotal?: string
|
||||
quality?: string
|
||||
lang?: string
|
||||
categories?: string[]
|
||||
@@ -20,6 +21,21 @@ const props = defineProps<{
|
||||
}
|
||||
}>()
|
||||
|
||||
const episodeDisplay = computed(() => {
|
||||
const ep = props.movie.episode
|
||||
const total = props.movie.episodeTotal
|
||||
if (!ep) return undefined
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
})
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const isDescriptionExpanded = ref(false)
|
||||
const previewStyle = ref<Record<string, string>>({})
|
||||
@@ -82,16 +98,20 @@ onBeforeUnmount(() => {
|
||||
@mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide">
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:ring-yellow-300/60">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
||||
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||
<p class="mt-1 truncate text-xs text-yellow-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||
<p class="mt-1 truncate text-xs text-yellow-100">{{ episodeDisplay || movie.year || movie.quality }}</p>
|
||||
</div>
|
||||
<span v-if="movie.sources?.length && movie.sources.length > 1"
|
||||
class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.sources.length }} server
|
||||
</span>
|
||||
<span v-if="episodeDisplay"
|
||||
class="absolute right-2 top-2 rounded bg-red-600/90 px-2 py-1 text-xs font-black text-white backdrop-blur-sm">
|
||||
{{ episodeDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -103,7 +123,7 @@ onBeforeUnmount(() => {
|
||||
class="fixed z-90 hidden origin-top-left overflow-hidden rounded-xl bg-[#07111d] text-white shadow-2xl shadow-black/60 ring-1 ring-yellow-300/45 sm:block"
|
||||
:style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide">
|
||||
<div class="relative h-44 overflow-hidden">
|
||||
<img :src="movie.poster || movie.thumb" :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 object-top">
|
||||
<div class="absolute inset-0 bg-linear-to-t from-[#07111d] via-[#07111d]/30 to-transparent" />
|
||||
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||
<h3 class="line-clamp-2 text-xl font-black leading-tight text-white">
|
||||
@@ -135,8 +155,8 @@ onBeforeUnmount(() => {
|
||||
movie.quality }}</span>
|
||||
<span v-if="movie.lang" class="rounded bg-yellow-300 px-2.5 py-1.5 text-slate-950">{{ movie.lang }}</span>
|
||||
<span v-if="movie.time" class="rounded bg-white px-2.5 py-1.5 text-slate-950">{{ movie.time }}</span>
|
||||
<span v-if="movie.episode" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{
|
||||
movie.episode }}</span>
|
||||
<span v-if="episodeDisplay" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{
|
||||
episodeDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Home, Search } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
error: {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
|
||||
function handleSearch() {
|
||||
if (keyword.value.trim()) {
|
||||
router.push({ path: '/phim', query: { q: keyword.value.trim() } })
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: `${props.error?.statusCode || 404} - Không tìm thấy trang - CineK`,
|
||||
meta: [
|
||||
{ name: 'robots', content: 'noindex' },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen overflow-hidden bg-black">
|
||||
<AppHeader />
|
||||
|
||||
<section
|
||||
class="relative mx-auto flex min-h-[80vh] max-w-390 items-center justify-center px-4 pt-24 sm:px-6 lg:px-8 xl:px-10">
|
||||
<!-- Background glow -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 size-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-yellow-400/5 blur-3xl" />
|
||||
<div
|
||||
class="absolute left-1/4 top-1/3 size-64 -translate-x-1/2 -translate-y-1/2 rounded-full bg-yellow-300/5 blur-2xl" />
|
||||
</div>
|
||||
|
||||
<!-- Dot pattern -->
|
||||
<div class="absolute inset-0 pointer-events-none opacity-20 hidden md:block"
|
||||
style="background-image:radial-gradient(rgba(250,204,21,0.3) 0.5px, transparent 1px);background-size:4px 4px">
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 w-full max-w-xl text-center">
|
||||
<!-- 404 Number -->
|
||||
<div class="relative mb-8">
|
||||
<span class="text-[10rem] font-black leading-none tracking-tighter text-white sm:text-[12rem]">
|
||||
{{ error?.statusCode || 404 }}
|
||||
</span>
|
||||
<div class="absolute inset-0 blur-3xl opacity-20">
|
||||
<span class="text-[10rem] font-black leading-none tracking-tighter text-yellow-400 sm:text-[12rem]">
|
||||
{{ error?.statusCode || 404 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Error message -->
|
||||
<p class="mt-3 text-base leading-relaxed text-slate-400 sm:text-lg">
|
||||
Trang bạn đang tìm không tồn tại hoặc đã bị xóa.
|
||||
</p>
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-8 flex items-center justify-center gap-3">
|
||||
<button type="button"
|
||||
class="inline-flex h-12 items-center gap-2 rounded-xl bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300"
|
||||
@click="goHome">
|
||||
<Home class="size-4" />
|
||||
Về trang chủ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AppFooter />
|
||||
</main>
|
||||
</template>
|
||||
+224
-7
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { BarChart3, Film, RefreshCw, TrendingUp, X } from 'lucide-vue-next'
|
||||
import { BarChart3, Film, Play, RefreshCw, TrendingUp, Tv, Users, X } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
@@ -35,6 +35,36 @@ async function handleSync() {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function percentOf(value: number, total: number) {
|
||||
if (!total) return 0
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
|
||||
// Donut chart helpers
|
||||
function donutSegment(value: number, total: number, startAngle: number, radius = 40) {
|
||||
if (!total || !value) return ''
|
||||
const angle = (value / total) * 360
|
||||
const startRad = ((startAngle - 90) * Math.PI) / 180
|
||||
const endRad = (((startAngle + angle) - 90) * Math.PI) / 180
|
||||
const x1 = 50 + radius * Math.cos(startRad)
|
||||
const y1 = 50 + radius * Math.sin(startRad)
|
||||
const x2 = 50 + radius * Math.cos(endRad)
|
||||
const y2 = 50 + radius * Math.sin(endRad)
|
||||
const largeArc = angle > 180 ? 1 : 0
|
||||
return `M 50 50 L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`
|
||||
}
|
||||
|
||||
// Bar chart max value
|
||||
const maxViews = computed(() => {
|
||||
const movies = stats.value?.topMovies || []
|
||||
return Math.max(...movies.map(m => m.views || 0), 1)
|
||||
})
|
||||
|
||||
function barWidth(views: number) {
|
||||
if (!maxViews.value) return 0
|
||||
return Math.max((views / maxViews.value) * 100, 4)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,7 +82,8 @@ async function handleSync() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Stats Cards -->
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -68,7 +99,7 @@ async function handleSync() {
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-400">Phim đang hiển thị</p>
|
||||
<p class="text-sm font-semibold text-slate-400">Đang hiển thị</p>
|
||||
<p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-green-400/10 text-green-400">
|
||||
@@ -80,16 +111,202 @@ async function handleSync() {
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-400">Phim chưa hiển thị</p>
|
||||
<p class="mt-2 text-3xl font-black text-slate-400">{{ stats?.inactive?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-sm font-semibold text-slate-400">Phim bộ</p>
|
||||
<p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-slate-400/10 text-slate-400">
|
||||
<BarChart3 class="size-5" />
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-blue-400/10 text-blue-400">
|
||||
<Tv class="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-400">Phim lẻ</p>
|
||||
<p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-purple-400/10 text-purple-400">
|
||||
<Play class="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-3">
|
||||
<!-- Donut Chart: Source Distribution -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Phân bổ nguồn phim</p>
|
||||
<div class="mt-4 flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<svg viewBox="0 0 100 100" class="size-44">
|
||||
<!-- OPhim segment -->
|
||||
<template v-if="stats?.ophim">
|
||||
<path :d="donutSegment(stats.ophim, stats.total, 0)" fill="#facc15" />
|
||||
</template>
|
||||
<!-- NguonC segment -->
|
||||
<template v-if="stats?.nguonc">
|
||||
<path :d="donutSegment(stats.nguonc, stats.total, (stats.ophim || 0) / (stats.total || 1) * 360)" fill="#4ade80" />
|
||||
</template>
|
||||
<!-- KKPhim segment -->
|
||||
<template v-if="stats?.kkphim">
|
||||
<path :d="donutSegment(stats.kkphim, stats.total, ((stats.ophim || 0) + (stats.nguonc || 0)) / (stats.total || 1) * 360)" fill="#60a5fa" />
|
||||
</template>
|
||||
<!-- Inner circle for donut effect -->
|
||||
<circle cx="50" cy="50" r="24" fill="#0f172a" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 grid place-items-center">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-[10px] text-slate-400">total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-yellow-400" />
|
||||
<span class="text-slate-300">OPhim</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.ophim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.ophim || 0, stats?.total || 0) }}%)</span></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-green-400" />
|
||||
<span class="text-slate-300">NguonC</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.nguonc || 0 }} <span class="text-slate-500">({{ percentOf(stats?.nguonc || 0, stats?.total || 0) }}%)</span></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-blue-400" />
|
||||
<span class="text-slate-300">KKPhim</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.kkphim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.kkphim || 0, stats?.total || 0) }}%)</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Donut Chart: Series vs Single -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Phim bộ vs Phim lẻ</p>
|
||||
<div class="mt-4 flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<svg viewBox="0 0 100 100" class="size-44">
|
||||
<template v-if="stats?.series">
|
||||
<path :d="donutSegment(stats.series, stats.total, 0)" fill="#3b82f6" />
|
||||
</template>
|
||||
<template v-if="stats?.single">
|
||||
<path :d="donutSegment(stats.single, stats.total, (stats.series || 0) / (stats.total || 1) * 360)" fill="#a855f7" />
|
||||
</template>
|
||||
<circle cx="50" cy="50" r="24" fill="#0f172a" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 grid place-items-center">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-[10px] text-slate-400">total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-blue-500" />
|
||||
<span class="text-slate-300">Phim bộ</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.series || 0 }} <span class="text-slate-500">({{ percentOf(stats?.series || 0, stats?.total || 0) }}%)</span></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-purple-500" />
|
||||
<span class="text-slate-300">Phim lẻ</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.single || 0 }} <span class="text-slate-500">({{ percentOf(stats?.single || 0, stats?.total || 0) }}%)</span></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-green-400" />
|
||||
<span class="text-slate-300">Đang hiển thị</span>
|
||||
</div>
|
||||
<span class="font-semibold text-green-400">{{ stats?.active || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-slate-500" />
|
||||
<span class="text-slate-300">Chưa hiển thị</span>
|
||||
</div>
|
||||
<span class="font-semibold text-slate-400">{{ stats?.inactive || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bar Chart: Top Movies by Views -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Top 5 phim nhiều lượt xem</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-for="(movie, i) in stats?.topMovies || []" :key="movie.slug">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 text-center text-xs font-black" :class="i === 0 ? 'text-yellow-400' : i === 1 ? 'text-slate-300' : i === 2 ? 'text-amber-600' : 'text-slate-500'">{{ i + 1 }}</span>
|
||||
<span class="truncate text-xs text-white">{{ movie.name }}</span>
|
||||
</div>
|
||||
<span class="text-xs font-semibold text-slate-400">{{ movie.views?.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="i === 0 ? 'bg-yellow-400' : i === 1 ? 'bg-slate-300' : i === 2 ? 'bg-amber-500' : 'bg-slate-500'"
|
||||
:style="{ width: barWidth(movie.views || 0) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!stats?.topMovies?.length" class="text-center text-xs text-slate-500">Chưa có dữ liệu</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 border-t border-white/10 pt-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-400">Tổng lượt xem</span>
|
||||
<span class="text-lg font-black text-white">{{ stats?.totalViews?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active vs Inactive Comparison Bar -->
|
||||
<div class="mb-6 rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Trạng thái hiển thị</p>
|
||||
<div class="mt-4 flex gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-green-400" />
|
||||
<span class="text-white">Đang hiển thị</span>
|
||||
</div>
|
||||
<span class="font-semibold text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
<div class="h-4 overflow-hidden rounded-full bg-white/10">
|
||||
<div class="h-full rounded-full bg-green-400 transition-all duration-500" :style="{ width: percentOf(stats?.active || 0, stats?.total || 0) + '%' }" />
|
||||
</div>
|
||||
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.active || 0, stats?.total || 0) }}%</p>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-slate-500" />
|
||||
<span class="text-white">Chưa hiển thị</span>
|
||||
</div>
|
||||
<span class="font-semibold text-slate-400">{{ stats?.inactive?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
<div class="h-4 overflow-hidden rounded-full bg-white/10">
|
||||
<div class="h-full rounded-full bg-slate-500 transition-all duration-500" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" />
|
||||
</div>
|
||||
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
|
||||
|
||||
@@ -12,6 +12,8 @@ useHead({
|
||||
const searchInput = ref('')
|
||||
const debouncedKeyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const sourceFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const currentPage = ref(1)
|
||||
const syncing = ref(false)
|
||||
const syncOpen = ref(false)
|
||||
@@ -34,11 +36,13 @@ const { data, refresh } = await useFetch('/api/admin/movies', {
|
||||
page: currentPage.value,
|
||||
keyword: debouncedKeyword.value,
|
||||
status: statusFilter.value,
|
||||
source: sourceFilter.value,
|
||||
type: typeFilter.value,
|
||||
limit: 20,
|
||||
})),
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
watch([statusFilter, sourceFilter, typeFilter], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
@@ -121,10 +125,23 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
</div>
|
||||
<select v-model="statusFilter"
|
||||
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
|
||||
<option value="" class="bg-slate-900">Tất cả</option>
|
||||
<option value="" class="bg-slate-900">Tất cả trạng thái</option>
|
||||
<option value="active" class="bg-slate-900">Đang hiển thị</option>
|
||||
<option value="inactive" class="bg-slate-900">Chưa hiển thị</option>
|
||||
</select>
|
||||
<select v-model="sourceFilter"
|
||||
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
|
||||
<option value="" class="bg-slate-900">Tất cả nguồn</option>
|
||||
<option value="ophim" class="bg-slate-900">OPhim</option>
|
||||
<option value="nguonc" class="bg-slate-900">NguonC</option>
|
||||
<option value="kkphim" class="bg-slate-900">KKPhim</option>
|
||||
</select>
|
||||
<select v-model="typeFilter"
|
||||
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
|
||||
<option value="" class="bg-slate-900">Tất cả loại</option>
|
||||
<option value="series" class="bg-slate-900">Phim bộ</option>
|
||||
<option value="single" class="bg-slate-900">Phim lẻ</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
@@ -134,6 +151,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
<th class="w-12 px-4 py-3 text-center">STT</th>
|
||||
<th class="px-4 py-3 text-center">Phim</th>
|
||||
<th class="px-4 py-3 text-center">Nguồn</th>
|
||||
<th class="px-4 py-3 text-center">Tập</th>
|
||||
<th class="px-4 py-3 text-center">Lượt xem</th>
|
||||
<th class="px-4 py-3 text-center">Trạng thái</th>
|
||||
<th class="px-4 py-3 text-center">Chỉnh sửa</th>
|
||||
@@ -156,6 +174,12 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
{{ movie.source?.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
<template v-if="movie.episode && movie.episodeTotal && !movie.episode.includes('/')">
|
||||
<span class="font-semibold text-yellow-300">Tập {{ movie.episode.replace(/[^0-9]/g, '') }}/{{ movie.episodeTotal.replace(/[^0-9]/g, '') }}</span>
|
||||
</template>
|
||||
<span v-else>{{ movie.episode || '-' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
{{ (movie.views || 0).toLocaleString() }}
|
||||
</td>
|
||||
@@ -192,7 +216,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!movies.length">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-sm text-slate-400">
|
||||
<td colspan="8" class="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{{ debouncedKeyword ? 'Không tìm thấy phim nào.' : 'Chưa có phim nào. Bấm "Đồng bộ từ API" để lấy phim.' }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+129
-87
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Heart, Info, Play, Trash2 } from 'lucide-vue-next'
|
||||
import { ChevronLeft, ChevronRight, Heart, Info, Play, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { WatchHistoryItem } from '~/composables/useWatchHistory'
|
||||
|
||||
const { data, pending, error } = useFetch('/api/movies', {
|
||||
@@ -150,6 +150,21 @@ function movieLink(movie: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeDisplay(movie: any) {
|
||||
const ep = movie?.episode
|
||||
const total = movie?.episodeTotal
|
||||
if (!ep) return undefined
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
function selectHero(index: number) {
|
||||
heroIndex.value = index
|
||||
}
|
||||
@@ -286,99 +301,126 @@ useHead({
|
||||
<main class="min-h-screen overflow-hidden bg-black">
|
||||
<AppHeader />
|
||||
|
||||
<section v-if="hero"
|
||||
class="relative h-155 min-h-155 overflow-hidden pt-20 sm:h-170 sm:min-h-170 sm:pt-24 lg:h-180 lg:min-h-190 lg:pt-0">
|
||||
<section v-if="hero" class="relative w-full h-[70vh] sm:h-[80vh] md:h-[85vh] lg:h-[90vh] overflow-hidden">
|
||||
<TransitionGroup name="hero-fade">
|
||||
<img v-for="(slide, index) in heroSlides" v-show="index === heroIndex" :key="`${slide.source}-${slide.slug}`"
|
||||
:src="slide.poster || slide.thumb" :alt="slide.name"
|
||||
class="absolute inset-0 h-full w-full object-cover object-center opacity-72 lg:object-[72%_center]">
|
||||
class="absolute inset-0 h-full w-full object-cover object-top lg:object-[72%_center]">
|
||||
</TransitionGroup>
|
||||
|
||||
<div class="absolute inset-0 bg-linear-to-r from-black via-black/76 to-black/14" />
|
||||
<div class="absolute inset-0 bg-linear-to-t from-black via-black/16 to-black/34" />
|
||||
<!-- Dot pattern overlay (desktop) -->
|
||||
<div class="absolute inset-0 pointer-events-none opacity-30 hidden md:block"
|
||||
style="background-image:radial-gradient(rgba(0,0,0,0.4) 0.4px, transparent 1px);background-size:3px 3px">
|
||||
</div>
|
||||
|
||||
<!-- Mobile gradient overlay -->
|
||||
<div class="absolute inset-0 pointer-events-none md:hidden" style="background:linear-gradient(to top, #0f111a 5%, rgba(15,17,21,0.6) 30%, transparent 60%),
|
||||
radial-gradient(ellipse at center, transparent 60%, rgba(15,17,26,0.7) 100%)">
|
||||
</div>
|
||||
|
||||
<!-- Desktop gradient overlay -->
|
||||
<div class="absolute inset-0 pointer-events-none hidden md:block" style="background:linear-gradient(to right, rgba(15,17,26,0.6) 0%, rgba(15,17,26,0.1) 30%, transparent 60%),
|
||||
linear-gradient(to top, #0f111a 0%, transparent 40%),
|
||||
radial-gradient(ellipse at center, transparent 65%, rgba(15,17,26,0.8) 100%)">
|
||||
</div>
|
||||
|
||||
<!-- Content overlay -->
|
||||
<div class="absolute inset-0 z-20 flex items-end pb-0 md:pb-[6vw] lg:pb-[5vw] xl:pb-32 pointer-events-none">
|
||||
<div class="max-w-350 w-full mx-auto px-4 md:px-8 pointer-events-none">
|
||||
<div
|
||||
class="max-w-xl mx-auto md:mx-0 md:max-w-[50vw] lg:max-w-2xl flex flex-col items-center md:items-start text-center md:text-left">
|
||||
<!-- Title -->
|
||||
<div class="mb-1 md:mb-4 lg:mb-6">
|
||||
<NuxtLink :to="movieLink(hero)" class="md:pointer-events-none" aria-label="Xem chi tiết phim">
|
||||
<h1 class="text-lg md:text-3xl lg:text-5xl font-black leading-tight text-white drop-shadow-2xl">
|
||||
{{ hero.name }}
|
||||
</h1>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Original name -->
|
||||
<p class="text-xs md:text-sm lg:text-base font-medium text-[#FECF59] mb-2 md:mb-3 lg:mb-4 drop-shadow">
|
||||
{{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }}
|
||||
</p>
|
||||
|
||||
<!-- Badges (mobile) -->
|
||||
<div
|
||||
class="flex justify-center md:justify-start items-center flex-wrap gap-2 mb-3 md:mb-4 text-[10px] md:text-xs text-white/90">
|
||||
<div v-if="hero.rating"
|
||||
class="flex items-center text-[11px] font-bold rounded overflow-hidden border border-solid border-[rgba(1,180,228,0.5)]">
|
||||
<span class="bg-[#01B4E4] text-white px-1.5 py-0.5">TMDb</span>
|
||||
<span class="bg-[rgba(1,180,228,0.1)] text-white px-1.5 py-0.5">{{ hero.rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
<span v-if="hero.quality"
|
||||
class="inline-flex items-center justify-center rounded-sm text-[#141414] font-black leading-none tracking-normal h-[22px] px-2 text-[11px]"
|
||||
style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%)">
|
||||
{{ hero.quality }}
|
||||
</span>
|
||||
<span v-if="getEpisodeDisplay(hero)" class="px-2 py-0.75 rounded border border-white bg-black/40">
|
||||
{{ getEpisodeDisplay(hero) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Genre tags (desktop only) -->
|
||||
<div v-if="hero.categories?.length"
|
||||
class="hidden md:flex justify-center md:justify-start items-center flex-wrap gap-2 mb-4">
|
||||
<span v-for="category in hero.categories.slice(0, 4)" :key="category"
|
||||
class="text-[10px] md:text-xs text-white/80 bg-white/10 px-2 py-0.75 rounded-md">
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description (desktop only) -->
|
||||
<div class="hidden md:block mb-4 lg:mb-8 max-w-sm lg:max-w-lg">
|
||||
<p class="text-xs lg:text-sm text-white font-light leading-relaxed line-clamp-2 lg:line-clamp-3">
|
||||
{{ heroDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (desktop only) -->
|
||||
<div class="hidden md:flex justify-center md:justify-start items-center gap-4 pointer-events-auto relative z-40">
|
||||
<!-- Watch Now button -->
|
||||
<NuxtLink :to="movieLink(hero)"
|
||||
class="w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 text-[#0f1115] rounded-full flex items-center justify-center transition-transform hover:scale-105 shadow-[0_0_15px_rgba(254,207,89,0.5)] shrink-0 pointer-events-auto"
|
||||
style="background:linear-gradient(39deg, rgb(254, 207, 89), rgb(255, 241, 204))" aria-label="Xem ngay">
|
||||
<Play class="size-6 md:size-7 lg:size-8 fill-current" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Info + Add to List combo button -->
|
||||
<div
|
||||
class="flex bg-white/5 border border-white/20 rounded-full backdrop-blur-md h-10 md:h-12 lg:h-14 items-center overflow-hidden pointer-events-auto">
|
||||
<!-- Add to list button -->
|
||||
<button type="button"
|
||||
class="group/btn w-16 md:w-20 h-full flex items-center justify-center transition-all hover:bg-white/10"
|
||||
aria-label="Thêm vào danh sách">
|
||||
<Plus class="size-5 md:size-6 text-white" />
|
||||
</button>
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-white/30"></div>
|
||||
<!-- Info/details button -->
|
||||
<NuxtLink :to="movieLink(hero)"
|
||||
class="group/link w-16 md:w-20 h-full flex items-center justify-center transition-colors text-white hover:text-[#FECF59]"
|
||||
aria-label="Thông tin phim">
|
||||
<Info class="size-6 md:size-7 text-white fill-none stroke-current group-hover/link:text-[#FECF59] transition-all" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail navigation -->
|
||||
<div
|
||||
class="relative mx-auto h-full max-w-390 px-4 pb-22 pt-20 sm:px-6 sm:pb-26 sm:pt-14 lg:px-8 lg:pb-0 lg:pt-38 xl:px-10">
|
||||
<div class="max-w-140">
|
||||
<h1 class="text-4xl font-black leading-tight text-white drop-shadow-2xl sm:text-5xl lg:text-[50px]">
|
||||
{{ hero.name }}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm font-black text-yellow-300 sm:text-base">
|
||||
{{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-1.5 text-[13px] font-black text-white sm:gap-2">
|
||||
<span v-if="hero.rating"
|
||||
class="rounded-md bg-yellow-400 px-2.5 py-1.5 text-slate-950 shadow-lg shadow-yellow-950/30">
|
||||
TMDb {{ hero.rating.toFixed(1) }}
|
||||
</span>
|
||||
<span v-if="hero.quality" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
|
||||
{{ hero.quality }}
|
||||
</span>
|
||||
<span v-if="hero.lang" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
|
||||
{{ hero.lang }}
|
||||
</span>
|
||||
<span v-if="hero.year" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
|
||||
{{ hero.year }}
|
||||
</span>
|
||||
<span v-if="hero.episode" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
|
||||
{{ hero.episode }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hero.categories?.length" class="mt-2 flex flex-wrap gap-1.5 text-[11px] font-black text-white">
|
||||
<span v-for="category in hero.categories.slice(0, 4)" :key="category"
|
||||
class="rounded-md bg-white/12 px-2.5 py-1.5 backdrop-blur">
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 line-clamp-3 max-w-130 text-base font-bold leading-7 text-white drop-shadow">
|
||||
{{ heroDescription }}
|
||||
</p>
|
||||
|
||||
<div class="mt-9 flex items-center gap-3">
|
||||
<NuxtLink :to="movieLink(hero)"
|
||||
class="grid size-14 shrink-0 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-2xl shadow-yellow-950/50 transition hover:scale-105 hover:bg-yellow-300 sm:size-16"
|
||||
aria-label="Xem ngay">
|
||||
<Play class="ml-1 size-7 fill-current" />
|
||||
</NuxtLink>
|
||||
<button type="button"
|
||||
class="grid size-10 shrink-0 place-items-center rounded-full bg-white/15 text-white backdrop-blur transition hover:bg-white/25 sm:size-11"
|
||||
aria-label="Yêu thích">
|
||||
<Heart class="size-4 sm:size-5" />
|
||||
</button>
|
||||
<NuxtLink :to="movieLink(hero)"
|
||||
class="grid size-10 shrink-0 place-items-center rounded-full bg-white/15 text-white backdrop-blur transition hover:bg-white/25 sm:size-11"
|
||||
aria-label="Thông tin phim">
|
||||
<Info class="size-4 sm:size-5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-14 right-4 hidden max-w-[calc(100%-2rem)] items-center gap-3 sm:flex sm:right-6 lg:bottom-18 lg:right-8 xl:right-10">
|
||||
<button type="button"
|
||||
class="hidden size-9 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15"
|
||||
aria-label="Phim trước" @click="previousHero">
|
||||
<ChevronLeft class="size-4 sm:size-5" />
|
||||
</button>
|
||||
|
||||
<div class="no-scrollbar flex gap-3 overflow-x-auto pb-2">
|
||||
<button v-for="(slide, index) in heroSlides" :key="`${slide.source}-${slide.slug}-thumb`" type="button"
|
||||
class="relative h-14.5 w-26 shrink-0 overflow-hidden rounded-md border bg-slate-900 shadow-lg shadow-black/30 transition"
|
||||
:class="index === heroIndex ? 'border-white ring-2 ring-white/40' : 'border-white/15 opacity-70 hover:opacity-100'"
|
||||
@click="selectHero(index)">
|
||||
<img :src="slide.thumb || slide.poster" :alt="slide.name" class="h-full w-full object-cover">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="hidden size-9 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15"
|
||||
aria-label="Phim tiếp theo" @click="nextHero">
|
||||
<ChevronRight class="size-4 sm:size-5" />
|
||||
</button>
|
||||
</div>
|
||||
class="absolute bottom-4 md:bottom-8 left-1/2 -translate-x-1/2 md:left-auto md:right-8 md:translate-x-0 z-30 flex gap-2 overflow-x-auto max-w-[calc(100%-2rem)] md:max-w-md lg:max-w-lg pb-2 md:pb-0 snap-x px-2 pointer-events-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
|
||||
style="scrollbar-width:none;-ms-overflow-style:none">
|
||||
<button v-for="(slide, index) in heroSlides" :key="`${slide.source}-${slide.slug}-thumb`" type="button"
|
||||
class="relative shrink-0 w-[14vw] sm:w-[10vw] md:w-[7vw] lg:w-[6vw] xl:w-[5vw] max-w-10 md:max-w-18.75 lg:max-w-21.25 aspect-square md:aspect-video transition-all duration-300 rounded-full md:rounded-lg overflow-hidden snap-center transform-gpu border-2"
|
||||
:class="index === heroIndex ? 'border-white/90 opacity-100 scale-100 shadow-md' : 'border-transparent opacity-80 scale-95 hover:scale-100 hover:opacity-100'"
|
||||
style="-webkit-mask-image:-webkit-radial-gradient(white, black)" :aria-label="`Chuyển đến phim ${index + 1}`"
|
||||
@click="selectHero(index)">
|
||||
<img :src="slide.thumb || slide.poster" :alt="slide.name"
|
||||
class="absolute inset-0 h-full w-full object-cover rounded-full md:rounded-lg">
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ function formatWeekday(dateKey: string) {
|
||||
return weekdays[date.getDay()] || ''
|
||||
}
|
||||
|
||||
function getEpisodeDisplay(movie: any) {
|
||||
const ep = movie?.episode
|
||||
const total = movie?.episodeTotal
|
||||
if (!ep) return 'Mới cập nhật'
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// Build map ngày -> phim
|
||||
const moviesByDate = computed(() => {
|
||||
const map = new Map<string, any[]>()
|
||||
@@ -190,14 +205,14 @@ useHead({
|
||||
<NuxtLink v-for="movie in selectedDay.items" :key="`${movie.source}-${movie.slug}`"
|
||||
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }"
|
||||
class="schedule-card">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
||||
class="schedule-card-image">
|
||||
<div class="schedule-card-body">
|
||||
<h2 class="schedule-card-title">
|
||||
{{ movie.name }}
|
||||
</h2>
|
||||
<p class="schedule-card-episode">
|
||||
{{ movie.episode || 'Mới cập nhật' }}
|
||||
{{ getEpisodeDisplay(movie) }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -236,7 +236,7 @@ useHead(() => ({
|
||||
<div class="relative mx-auto max-w-390 px-3 pb-8 pt-40 sm:px-6 sm:pb-12 sm:pt-52 lg:px-8 lg:pt-56 xl:px-10">
|
||||
<div class="lg:grid lg:grid-cols-[22rem_minmax(0,1fr)] lg:gap-6">
|
||||
<aside class="mb-3 flex flex-col items-center text-center lg:mb-6 lg:items-start lg:text-left">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
||||
class="mx-auto aspect-2/3 w-29 rounded-md object-cover shadow-xl shadow-black/40 sm:w-52 lg:w-full">
|
||||
|
||||
<h1 class="mt-4 text-[1.35rem] font-black leading-tight sm:text-2xl lg:mt-5">{{ movie.name }}</h1>
|
||||
|
||||
@@ -72,6 +72,21 @@ function movieLink(movie: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeDisplay(movie: any) {
|
||||
const ep = movie.episode
|
||||
const total = movie.episodeTotal
|
||||
if (!ep) return undefined
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
page.value += 1
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
@@ -164,15 +179,20 @@ useHead({
|
||||
:to="movieLink(movie)" class="group">
|
||||
<div
|
||||
class="relative aspect-2/3 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:-translate-y-1 group-hover:ring-yellow-300/60">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
||||
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||
<p class="mt-1 truncate text-xs text-yellow-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||
<p class="mt-1 truncate text-xs text-yellow-100">{{ getEpisodeDisplay(movie) || movie.year || movie.quality }}</p>
|
||||
</div>
|
||||
<span v-if="movie.sources?.length > 1" class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
<span v-if="movie.sources?.length > 1"
|
||||
class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.sources.length }} server
|
||||
</span>
|
||||
<span v-if="getEpisodeDisplay(movie)"
|
||||
class="absolute right-2 top-2 rounded bg-red-600/90 px-2 py-1 text-xs font-black text-white backdrop-blur-sm">
|
||||
{{ getEpisodeDisplay(movie) }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
@@ -1289,7 +1289,7 @@ useHead(() => ({
|
||||
<div class="mt-5 grid gap-6 lg:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<section>
|
||||
<div class="flex gap-4">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
||||
class="hidden h-28 w-20 rounded-md object-cover sm:block">
|
||||
<div>
|
||||
<h1 class="text-[1.35rem] font-black leading-tight sm:text-2xl">{{ movie.name }}</h1>
|
||||
|
||||
Reference in New Issue
Block a user