mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 16:07:47 +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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { movies } from '../../database/schema'
|
||||
import { desc, eq, like, sql } from 'drizzle-orm'
|
||||
import { desc, eq, like, and, sql } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = useDb()
|
||||
@@ -9,17 +9,18 @@ export default defineEventHandler(async (event) => {
|
||||
const offset = (page - 1) * limit
|
||||
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
|
||||
const status = typeof query.status === 'string' ? query.status : ''
|
||||
const source = typeof query.source === 'string' ? query.source : ''
|
||||
const type = typeof query.type === 'string' ? query.type : ''
|
||||
|
||||
let whereClause = undefined
|
||||
if (keyword && status === 'active') {
|
||||
whereClause = sql`${like(movies.name, `%${keyword}%`)} AND ${movies.active} = true`
|
||||
} else if (keyword) {
|
||||
whereClause = like(movies.name, `%${keyword}%`)
|
||||
} else if (status === 'active') {
|
||||
whereClause = eq(movies.active, true)
|
||||
} else if (status === 'inactive') {
|
||||
whereClause = eq(movies.active, false)
|
||||
}
|
||||
const conditions: any[] = []
|
||||
|
||||
if (keyword) conditions.push(like(movies.name, `%${keyword}%`))
|
||||
if (status === 'active') conditions.push(eq(movies.active, true))
|
||||
else if (status === 'inactive') conditions.push(eq(movies.active, false))
|
||||
if (source) conditions.push(eq(movies.source, source))
|
||||
if (type) conditions.push(eq(movies.type, type))
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { movies } from '../../database/schema'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { sql, eq } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const db = useDb()
|
||||
@@ -8,11 +8,34 @@ export default defineEventHandler(async () => {
|
||||
total: sql<number>`count(*)`,
|
||||
active: sql<number>`SUM(CASE WHEN ${movies.active} = true THEN 1 ELSE 0 END)`,
|
||||
inactive: sql<number>`SUM(CASE WHEN ${movies.active} = false THEN 1 ELSE 0 END)`,
|
||||
series: sql<number>`SUM(CASE WHEN ${movies.type} = 'series' THEN 1 ELSE 0 END)`,
|
||||
single: sql<number>`SUM(CASE WHEN ${movies.type} = 'single' THEN 1 ELSE 0 END)`,
|
||||
totalViews: sql<number>`COALESCE(SUM(${movies.views}), 0)`,
|
||||
ophim: sql<number>`SUM(CASE WHEN ${movies.source} = 'ophim' THEN 1 ELSE 0 END)`,
|
||||
nguonc: sql<number>`SUM(CASE WHEN ${movies.source} = 'nguonc' THEN 1 ELSE 0 END)`,
|
||||
kkphim: sql<number>`SUM(CASE WHEN ${movies.source} = 'kkphim' THEN 1 ELSE 0 END)`,
|
||||
}).from(movies)
|
||||
|
||||
const topMovies = await db.select({
|
||||
name: movies.name,
|
||||
views: movies.views,
|
||||
slug: movies.slug,
|
||||
source: movies.source,
|
||||
}).from(movies)
|
||||
.where(eq(movies.active, true))
|
||||
.orderBy(sql`${movies.views} DESC`)
|
||||
.limit(5)
|
||||
|
||||
return {
|
||||
total: stats?.total ?? 0,
|
||||
active: stats?.active ?? 0,
|
||||
inactive: stats?.inactive ?? 0,
|
||||
series: stats?.series ?? 0,
|
||||
single: stats?.single ?? 0,
|
||||
totalViews: stats?.totalViews ?? 0,
|
||||
ophim: stats?.ophim ?? 0,
|
||||
nguonc: stats?.nguonc ?? 0,
|
||||
kkphim: stats?.kkphim ?? 0,
|
||||
topMovies: topMovies || [],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
import { movies } from '../../database/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { getOphimKoreanMovies, getNguoncKoreanMovies, getKkphimKoreanMovies } from '../../utils/movies'
|
||||
import {
|
||||
getOphimKoreanMovies,
|
||||
getNguoncKoreanMovies,
|
||||
getKkphimKoreanMovies,
|
||||
getOphimDetail,
|
||||
getNguoncDetail,
|
||||
getKkphimDetail,
|
||||
} from '../../utils/movies'
|
||||
|
||||
const SYNC_PAGES = 10
|
||||
const ALL_SOURCES = ['ophim', 'nguonc', 'kkphim'] as const
|
||||
const DETAIL_CONCURRENCY = 5
|
||||
|
||||
const detailFetchers = {
|
||||
ophim: getOphimDetail,
|
||||
nguonc: getNguoncDetail,
|
||||
kkphim: getKkphimDetail,
|
||||
}
|
||||
|
||||
async function fetchEpisodeTotal(source: string, slug: string): Promise<string | undefined> {
|
||||
const fetcher = detailFetchers[source as keyof typeof detailFetchers]
|
||||
if (!fetcher) return undefined
|
||||
try {
|
||||
const detail = await fetcher(slug)
|
||||
return detail.episodeTotal || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichWithEpisodeTotal(moviesList: any[]): Promise<void> {
|
||||
const moviesNeedTotal = moviesList.filter((m) => m.type !== 'single' && !m.episodeTotal && m.slug)
|
||||
|
||||
for (let i = 0; i < moviesNeedTotal.length; i += DETAIL_CONCURRENCY) {
|
||||
const batch = moviesNeedTotal.slice(i, i + DETAIL_CONCURRENCY)
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((movie) => fetchEpisodeTotal(movie.source, movie.slug)),
|
||||
)
|
||||
results.forEach((result, idx) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
batch[idx].episodeTotal = result.value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = useDb()
|
||||
@@ -38,6 +79,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
await enrichWithEpisodeTotal(allMovies)
|
||||
|
||||
let created = 0
|
||||
let updated = 0
|
||||
|
||||
@@ -59,6 +102,7 @@ export default defineEventHandler(async (event) => {
|
||||
year: movie.year || null,
|
||||
time: movie.time || null,
|
||||
episode: movie.episode || null,
|
||||
episodeTotal: movie.episodeTotal || null,
|
||||
quality: movie.quality || null,
|
||||
lang: movie.lang || null,
|
||||
type: movie.type || null,
|
||||
@@ -82,6 +126,7 @@ export default defineEventHandler(async (event) => {
|
||||
year: movie.year || null,
|
||||
time: movie.time || null,
|
||||
episode: movie.episode || null,
|
||||
episodeTotal: movie.episodeTotal || null,
|
||||
quality: movie.quality || null,
|
||||
lang: movie.lang || null,
|
||||
type: movie.type || null,
|
||||
|
||||
@@ -72,6 +72,7 @@ function mapMovieToResponse(movie: any) {
|
||||
year: movie.year || undefined,
|
||||
time: movie.time || undefined,
|
||||
episode: movie.episode || undefined,
|
||||
episodeTotal: movie.episodeTotal || undefined,
|
||||
quality: movie.quality || undefined,
|
||||
lang: movie.lang || undefined,
|
||||
type: movie.type || undefined,
|
||||
|
||||
@@ -19,7 +19,9 @@ export default defineEventHandler(async (event) => {
|
||||
.where(and(eq(movies.slug, slug), eq(movies.source, source), eq(movies.active, true)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing.length) {
|
||||
let recordToUpdate = existing.length ? existing[0] : null
|
||||
|
||||
if (!recordToUpdate) {
|
||||
const fallback = await db
|
||||
.select()
|
||||
.from(movies)
|
||||
@@ -29,9 +31,22 @@ export default defineEventHandler(async (event) => {
|
||||
if (!fallback.length) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Phim không tồn tại hoặc đã bị ẩn' })
|
||||
}
|
||||
recordToUpdate = fallback[0]
|
||||
}
|
||||
|
||||
const refs = parseSourceRefs(query.sources || query.srcs, query.source, slug)
|
||||
|
||||
return await getMovieDetailGroup(refs)
|
||||
const detail = await getMovieDetailGroup(refs)
|
||||
|
||||
if (detail.episodeTotal && recordToUpdate) {
|
||||
try {
|
||||
await db
|
||||
.update(movies)
|
||||
.set({ episodeTotal: detail.episodeTotal })
|
||||
.where(eq(movies.id, recordToUpdate.id))
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return detail
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `movies` ADD `episode_total` varchar(100);
|
||||
@@ -0,0 +1,336 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "0c944d62-4add-4bdb-b5cf-764479ba4b22",
|
||||
"prevId": "12998654-722b-4627-9970-1f407a7d7edd",
|
||||
"tables": {
|
||||
"movies": {
|
||||
"name": "movies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"origin_name": {
|
||||
"name": "origin_name",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumb": {
|
||||
"name": "thumb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"poster": {
|
||||
"name": "poster",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"year": {
|
||||
"name": "year",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time": {
|
||||
"name": "time",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"episode": {
|
||||
"name": "episode",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"episode_total": {
|
||||
"name": "episode_total",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"quality": {
|
||||
"name": "quality",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lang": {
|
||||
"name": "lang",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"views": {
|
||||
"name": "views",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"countries": {
|
||||
"name": "countries",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sources": {
|
||||
"name": "sources",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"custom_poster": {
|
||||
"name": "custom_poster",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"custom_thumb": {
|
||||
"name": "custom_thumb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"custom_content": {
|
||||
"name": "custom_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"custom_episodes": {
|
||||
"name": "custom_episodes",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"api_updated_at": {
|
||||
"name": "api_updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"synced_at": {
|
||||
"name": "synced_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"movies_id": {
|
||||
"name": "movies_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@
|
||||
"when": 1784709136940,
|
||||
"tag": "0005_illegal_purple_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1784780104334,
|
||||
"tag": "0006_sour_retro_girl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const movies = mysqlTable('movies', {
|
||||
year: int('year'),
|
||||
time: varchar('time', { length: 100 }),
|
||||
episode: varchar('episode', { length: 100 }),
|
||||
episodeTotal: varchar('episode_total', { length: 100 }),
|
||||
quality: varchar('quality', { length: 50 }),
|
||||
lang: varchar('lang', { length: 50 }),
|
||||
type: varchar('type', { length: 50 }),
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface NormalizedMovie {
|
||||
year?: number
|
||||
time?: string
|
||||
episode?: string
|
||||
episodeTotal?: string
|
||||
quality?: string
|
||||
lang?: string
|
||||
type?: string
|
||||
@@ -294,6 +295,7 @@ export function normalizeOphimMovie(movie: any, pathImage = OPHIM_IMAGE): Normal
|
||||
year: Number(movie?.year) || undefined,
|
||||
time: text(movie?.time),
|
||||
episode: text(movie?.episode_current),
|
||||
episodeTotal: movie?.episode_total ? String(movie.episode_total).trim() || undefined : undefined,
|
||||
quality: text(movie?.quality),
|
||||
lang: text(movie?.lang),
|
||||
type: text(movie?.type),
|
||||
@@ -316,6 +318,7 @@ export function normalizeKkphimMovie(movie: any, pathImage = KKPHIM_IMAGE): Norm
|
||||
year: Number(movie?.year) || undefined,
|
||||
time: text(movie?.time),
|
||||
episode: text(movie?.episode_current),
|
||||
episodeTotal: movie?.episode_total != null ? String(movie.episode_total).trim() || undefined : undefined,
|
||||
quality: text(movie?.quality),
|
||||
lang: text(movie?.lang),
|
||||
type: text(movie?.type),
|
||||
@@ -340,6 +343,9 @@ function normalizeNguoncMovie(movie: any): NormalizedMovie {
|
||||
year: Number(movie?.year || movie?.release_year) || undefined,
|
||||
time: text(movie?.time || movie?.duration),
|
||||
episode: text(movie?.episode_current || movie?.current_episode),
|
||||
episodeTotal: (movie?.episode_total || movie?.total_episodes) != null
|
||||
? String(movie.episode_total || movie.total_episodes).trim() || undefined
|
||||
: undefined,
|
||||
quality: text(movie?.quality),
|
||||
lang: text(movie?.language || movie?.lang),
|
||||
type: text(movie?.type),
|
||||
|
||||
Reference in New Issue
Block a user