mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 14:27:47 +00:00
feat: implement core Nuxt application with film browsing, search, and detail page functionality using OPhim and NguonC APIs.
This commit is contained in:
+4
-4
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtWelcome />
|
||||
</div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
<AppFooter />
|
||||
<ScrollToTop />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700;800;900&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-skyflix-50: #effaff;
|
||||
--color-skyflix-100: #dff5ff;
|
||||
--color-skyflix-300: #6ed0ff;
|
||||
--color-skyflix-500: #0ea5e9;
|
||||
--color-skyflix-600: #0284c7;
|
||||
--color-skyflix-950: #07111f;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 15% 0%, rgba(14, 165, 233, 0.28), transparent 34rem),
|
||||
linear-gradient(135deg, #061321 0%, #081827 46%, #020617 100%);
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input {
|
||||
font-family: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.7rem;
|
||||
width: 0.7rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(56, 189, 248, 0.55);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
}
|
||||
|
||||
.hero-fade-enter-active,
|
||||
.hero-fade-leave-active {
|
||||
transition: opacity 700ms ease, transform 900ms ease;
|
||||
}
|
||||
|
||||
.hero-fade-enter-from,
|
||||
.hero-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { CirclePlay, Globe2, MessageCircle, Music2, Send } from 'lucide-vue-next'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const socialLinks = [
|
||||
{ label: 'Telegram', icon: Send },
|
||||
{ label: 'Chat', icon: MessageCircle },
|
||||
{ label: 'Website', icon: Globe2 },
|
||||
{ label: 'Cộng đồng', icon: MessageCircle },
|
||||
{ label: 'TikTok', icon: Music2 },
|
||||
{ label: 'Video', icon: CirclePlay },
|
||||
]
|
||||
|
||||
const linkGroups = [
|
||||
['Domain dự phòng', 'Điều khoản sử dụng'],
|
||||
['Hỏi-Đáp', 'Giới thiệu'],
|
||||
['Chính sách bảo mật', 'Liên hệ'],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-white/10 bg-[#07090f] text-white">
|
||||
<div class="mx-auto max-w-[1560px] px-4 py-10 sm:px-6 lg:px-8 xl:px-10">
|
||||
<div class="flex flex-col gap-8 lg:flex-row lg:items-center">
|
||||
<div class="flex shrink-0 items-center gap-7">
|
||||
<AppLogo />
|
||||
<span class="hidden h-14 w-px bg-white/10 sm:block" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="item in socialLinks"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="grid size-12 place-items-center rounded-full bg-sky-400 text-slate-950 shadow-lg shadow-sky-950/30 transition hover:-translate-y-0.5 hover:bg-white"
|
||||
:aria-label="item.label"
|
||||
>
|
||||
<component :is="item.icon" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid gap-8 text-sm font-bold sm:grid-cols-3 lg:max-w-3xl">
|
||||
<div v-for="(group, index) in linkGroups" :key="index" class="space-y-5">
|
||||
<NuxtLink
|
||||
v-for="link in group"
|
||||
:key="link"
|
||||
to="/phim"
|
||||
class="block text-slate-100 transition hover:text-sky-200"
|
||||
>
|
||||
{{ link }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-10 max-w-4xl text-sm leading-7 text-slate-300">
|
||||
KRPHIM - Trang xem phim Hàn Quốc online chất lượng cao miễn phí Vietsub, thuyết minh, lồng tiếng full HD. Kho phim mới cập nhật liên tục từ OPhim và NguồnC với giao diện xanh da trời hiện đại.
|
||||
</p>
|
||||
|
||||
<p class="mt-7 text-sm text-slate-400">
|
||||
© {{ currentYear }} KRPHIM
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Trang chủ', to: '/' },
|
||||
{ label: 'Duyệt phim', to: '/phim' },
|
||||
{ label: 'Phim bộ', to: '/phim?type=series' },
|
||||
{ label: 'Phim lẻ', to: '/phim?type=single' },
|
||||
]
|
||||
|
||||
function isActive(to: string) {
|
||||
if (to === '/') return route.path === '/'
|
||||
return route.fullPath === to || route.path === to.split('?')[0] && route.fullPath.includes(to.split('?')[1] || '')
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
const q = keyword.value.trim()
|
||||
router.push({
|
||||
path: '/phim',
|
||||
query: q ? { q } : undefined,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/10 bg-slate-950/78 backdrop-blur-xl">
|
||||
<nav class="mx-auto grid max-w-[1560px] grid-cols-[auto_1fr] items-center gap-4 px-4 py-3 sm:px-6 lg:grid-cols-[auto_1fr_auto] lg:px-8 xl:px-10">
|
||||
<AppLogo />
|
||||
|
||||
<div class="no-scrollbar order-3 col-span-2 flex items-center justify-center gap-4 overflow-x-auto text-sm font-semibold text-slate-200 lg:order-none lg:col-span-1 lg:gap-7">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="shrink-0 transition hover:text-sky-200"
|
||||
:class="isActive(item.to) ? 'text-sky-200' : 'text-slate-200'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="ml-auto flex w-full max-w-xs items-center rounded-full border border-white/10 bg-white/8 px-4 py-2 shadow-2xl shadow-sky-950/20 sm:max-w-sm"
|
||||
@submit.prevent="submitSearch"
|
||||
>
|
||||
<Search class="mr-3 size-4 shrink-0 text-sky-200" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
placeholder="Tìm phim Hàn Quốc..."
|
||||
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400"
|
||||
>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { Play } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="group inline-flex h-10 min-w-[132px] shrink-0 flex-row flex-nowrap items-center gap-2.5 whitespace-nowrap"
|
||||
aria-label="KR PHIM"
|
||||
>
|
||||
<span class="grid size-9 shrink-0 place-items-center rounded-lg border border-sky-200/80 bg-black/30 text-white shadow-lg shadow-black/20 transition group-hover:border-sky-300 group-hover:text-sky-200">
|
||||
<Play class="ml-0.5 size-5 fill-current" />
|
||||
</span>
|
||||
<span class="inline-flex items-baseline text-2xl font-black uppercase leading-none tracking-tight">
|
||||
<span class="text-sky-300">KR</span><span class="text-white">PHIM</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { Circle, Heart, Play } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
movie: {
|
||||
source: string
|
||||
slug: string
|
||||
name: string
|
||||
originName?: string
|
||||
thumb?: string
|
||||
poster?: string
|
||||
year?: number
|
||||
time?: string
|
||||
episode?: string
|
||||
quality?: string
|
||||
lang?: string
|
||||
categories?: string[]
|
||||
countries?: string[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const previewStyle = ref<Record<string, string>>({})
|
||||
let hideTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const movieLink = computed(() => ({
|
||||
path: `/phim/${props.movie.slug}`,
|
||||
query: { source: props.movie.source },
|
||||
}))
|
||||
|
||||
function showPreview(event: MouseEvent) {
|
||||
if (window.matchMedia('(max-width: 639px)').matches) return
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const width = 408
|
||||
const height = 344
|
||||
const gap = 14
|
||||
const left = Math.min(Math.max(rect.left - 10, 16), window.innerWidth - width - 16)
|
||||
const top = Math.min(Math.max(rect.top - gap, 86), window.innerHeight - height - 16)
|
||||
|
||||
previewStyle.value = {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
}
|
||||
isPreviewVisible.value = true
|
||||
}
|
||||
|
||||
function scheduleHide() {
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
isPreviewVisible.value = false
|
||||
}, 90)
|
||||
}
|
||||
|
||||
function keepPreview() {
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="movieLink"
|
||||
class="group relative block h-[19.5rem] w-44 shrink-0 snap-start transition duration-300 hover:z-30 hover:-translate-y-1 sm:h-[20.5rem] sm:w-52 xl:w-56"
|
||||
@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-sky-300/60">
|
||||
<img :src="movie.thumb || movie.poster" :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-gradient-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-sky-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||
</div>
|
||||
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.source }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-180 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-120 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="isPreviewVisible"
|
||||
:to="movieLink"
|
||||
class="fixed z-[90] hidden origin-top-left overflow-hidden rounded-xl bg-[#1a0b14] text-white shadow-2xl shadow-black/60 ring-1 ring-sky-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">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-[#1a0b14] via-[#1a0b14]/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">
|
||||
{{ movie.name }}
|
||||
</h3>
|
||||
<p v-if="movie.originName" class="mt-1 truncate text-sm font-semibold text-slate-200">
|
||||
{{ movie.originName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-3">
|
||||
<div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2">
|
||||
<span class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-sky-400 px-4 text-sm font-black text-slate-950 transition hover:bg-white">
|
||||
<Play class="size-4 fill-current" />
|
||||
Xem ngay
|
||||
</span>
|
||||
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white">
|
||||
<Heart class="size-5 fill-current" />
|
||||
</span>
|
||||
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white">
|
||||
<Circle class="size-5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs font-black">
|
||||
<span v-if="movie.quality" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{ movie.quality }}</span>
|
||||
<span v-if="movie.lang" class="rounded bg-sky-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>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 line-clamp-2 text-xs font-bold leading-6 text-slate-200">
|
||||
<template v-if="movie.countries?.length">{{ movie.countries.slice(0, 2).join(' • ') }}</template>
|
||||
<template v-if="movie.countries?.length && movie.categories?.length"> • </template>
|
||||
<template v-if="movie.categories?.length">{{ movie.categories.slice(0, 3).join(' • ') }}</template>
|
||||
<template v-if="!movie.countries?.length && !movie.categories?.length">Phim Hàn Quốc Vietsub mới cập nhật</template>
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUp } from 'lucide-vue-next'
|
||||
|
||||
const isVisible = ref(false)
|
||||
|
||||
function updateVisibility() {
|
||||
isVisible.value = window.scrollY > 420
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateVisibility()
|
||||
window.addEventListener('scroll', updateVisibility, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', updateVisibility)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-3 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-3 opacity-0"
|
||||
>
|
||||
<button
|
||||
v-if="isVisible"
|
||||
type="button"
|
||||
class="fixed bottom-6 right-5 z-50 grid size-12 place-items-center rounded-full bg-sky-400 text-slate-950 shadow-2xl shadow-sky-950/40 ring-1 ring-white/20 transition hover:-translate-y-0.5 hover:bg-white sm:bottom-8 sm:right-8"
|
||||
aria-label="Lên đầu trang"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ArrowUp class="size-6" />
|
||||
</button>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { BookPlus, ChevronLeft, ChevronRight, Heart, Info, Play } from 'lucide-vue-next'
|
||||
|
||||
const { data, pending, error } = await useFetch('/api/movies', {
|
||||
query: {
|
||||
page: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const movies = computed(() => data.value?.items ?? [])
|
||||
const heroIndex = ref(0)
|
||||
const heroSlides = computed(() => movies.value.slice(0, 6))
|
||||
const hero = computed(() => heroSlides.value[heroIndex.value] ?? movies.value[0])
|
||||
const sourceStatus = computed(() => data.value?.sources ?? [])
|
||||
|
||||
let heroTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function selectHero(index: number) {
|
||||
heroIndex.value = index
|
||||
}
|
||||
|
||||
function nextHero() {
|
||||
if (!heroSlides.value.length) return
|
||||
heroIndex.value = (heroIndex.value + 1) % heroSlides.value.length
|
||||
}
|
||||
|
||||
function previousHero() {
|
||||
if (!heroSlides.value.length) return
|
||||
heroIndex.value = (heroIndex.value - 1 + heroSlides.value.length) % heroSlides.value.length
|
||||
}
|
||||
|
||||
watch(heroSlides, (slides) => {
|
||||
if (heroIndex.value >= slides.length) {
|
||||
heroIndex.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
heroTimer = setInterval(nextHero, 6500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (heroTimer) clearInterval(heroTimer)
|
||||
})
|
||||
|
||||
const rows = computed(() => [
|
||||
{
|
||||
title: 'Mới cập nhật',
|
||||
to: '/phim',
|
||||
items: movies.value.slice(0, 12),
|
||||
},
|
||||
{
|
||||
title: 'Phim bộ Hàn Quốc',
|
||||
to: '/phim?type=series',
|
||||
items: movies.value.filter((movie: any) => movie.type !== 'single').slice(0, 12),
|
||||
},
|
||||
{
|
||||
title: 'Phim lẻ & đặc biệt',
|
||||
to: '/phim?type=single',
|
||||
items: movies.value.filter((movie: any) => movie.type === 'single' || movie.episode === 'Full').slice(0, 12),
|
||||
},
|
||||
])
|
||||
|
||||
useHead({
|
||||
title: 'KR Phim - Phim Hàn Quốc',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Xem phim Hàn Quốc Vietsub từ OPhim và NguồnC với giao diện xanh da trời kiểu Netflix.',
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
<section v-if="hero" class="relative h-[680px] min-h-[680px] overflow-hidden pt-28 sm:h-[720px] sm:min-h-[720px] lg:h-[760px] lg:min-h-[760px] lg:pt-16">
|
||||
<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"
|
||||
>
|
||||
</TransitionGroup>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/76 to-slate-950/10" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/20 to-slate-950/30" />
|
||||
<div class="absolute inset-y-0 right-0 hidden w-1/2 bg-gradient-to-l from-sky-950/18 to-transparent lg:block" />
|
||||
|
||||
<div class="relative mx-auto grid h-full max-w-[1560px] content-center px-4 pb-28 pt-16 sm:px-6 lg:px-8 xl:px-10">
|
||||
<div class="max-w-5xl">
|
||||
<p class="mb-3 text-sm font-semibold text-sky-200">
|
||||
Chỉ phim Hàn Quốc
|
||||
</p>
|
||||
<h1 class="max-w-5xl text-3xl font-black leading-tight text-white drop-shadow-2xl sm:text-5xl lg:text-6xl">
|
||||
{{ hero.name }}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-lg text-sky-100">
|
||||
{{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }}
|
||||
</p>
|
||||
<div class="mt-5 flex flex-wrap gap-2 text-sm font-bold text-slate-100">
|
||||
<span v-if="hero.rating" class="rounded bg-black/35 px-3 py-2 ring-1 ring-white/10">IMDb {{ hero.rating.toFixed(1) }}</span>
|
||||
<span v-if="hero.quality" class="rounded bg-sky-300 px-3 py-2 text-slate-950">{{ hero.quality }}</span>
|
||||
<span v-if="hero.lang" class="rounded bg-white px-3 py-2 text-slate-950">{{ hero.lang }}</span>
|
||||
<span v-if="hero.year" class="rounded bg-black/35 px-3 py-2 ring-1 ring-white/10">{{ hero.year }}</span>
|
||||
<span v-if="hero.episode" class="rounded bg-black/35 px-3 py-2 ring-1 ring-white/10">{{ hero.episode }}</span>
|
||||
</div>
|
||||
<div v-if="hero.categories?.length" class="mt-4 flex flex-wrap gap-2 text-sm font-semibold text-white">
|
||||
<span
|
||||
v-for="category in hero.categories.slice(0, 4)"
|
||||
:key="category"
|
||||
class="rounded bg-white/12 px-3 py-1.5 backdrop-blur"
|
||||
>
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-100 sm:text-base">
|
||||
{{ hero.originName ? `${hero.originName}. ` : '' }}{{ hero.time || 'Phim Hàn Quốc Vietsub mới cập nhật.' }}
|
||||
</p>
|
||||
<div class="mt-8 flex items-center gap-4">
|
||||
<NuxtLink
|
||||
:to="{ path: `/phim/${hero.slug}`, query: { source: hero.source } }"
|
||||
class="grid size-16 shrink-0 place-items-center rounded-full bg-sky-300 text-slate-950 shadow-2xl shadow-sky-950/50 transition hover:scale-105 hover:bg-white"
|
||||
aria-label="Xem ngay"
|
||||
>
|
||||
<Play class="size-7 fill-current" />
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-12 shrink-0 place-items-center rounded-full border border-white/20 bg-black/28 text-white backdrop-blur transition hover:bg-white/16"
|
||||
aria-label="Yêu thích"
|
||||
>
|
||||
<Heart class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-12 shrink-0 place-items-center rounded-full border border-white/20 bg-black/28 text-white backdrop-blur transition hover:bg-white/16"
|
||||
aria-label="Thêm vào thư viện"
|
||||
>
|
||||
<BookPlus class="size-5" />
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="{ path: `/phim/${hero.slug}`, query: { source: hero.source } }"
|
||||
class="grid size-12 shrink-0 place-items-center rounded-full border border-white/20 bg-black/28 text-white backdrop-blur transition hover:bg-white/16"
|
||||
aria-label="Thông tin phim"
|
||||
>
|
||||
<Info class="size-5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-12 right-4 flex max-w-[calc(100%-2rem)] items-center gap-3 sm:right-6 lg:right-8">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden size-10 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15 sm:grid"
|
||||
aria-label="Phim trước"
|
||||
@click="previousHero"
|
||||
>
|
||||
<ChevronLeft class="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 w-28 shrink-0 overflow-hidden rounded-md border bg-slate-900 shadow-lg shadow-black/30 transition sm:h-16 sm:w-32"
|
||||
:class="index === heroIndex ? 'border-sky-300 ring-2 ring-sky-300/45' : '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">
|
||||
<span class="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1 py-0.5 text-[10px] font-semibold text-white">
|
||||
{{ slide.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="hidden size-10 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15 sm:grid"
|
||||
aria-label="Phim tiếp theo"
|
||||
@click="nextHero"
|
||||
>
|
||||
<ChevronRight class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="mx-auto flex min-h-screen max-w-7xl items-center px-4 pt-36 sm:px-6 lg:px-8 lg:pt-24">
|
||||
<div>
|
||||
<h1 class="text-4xl font-black text-white">KR Phim</h1>
|
||||
<p class="mt-3 text-slate-300">Đang tải kho phim Hàn Quốc...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="relative z-10 mx-auto -mt-10 max-w-[1560px] px-4 pb-16 sm:px-6 lg:px-8 xl:px-10">
|
||||
<div v-if="sourceStatus.length" class="mb-8 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||
<span
|
||||
v-for="source in sourceStatus"
|
||||
:key="source.name"
|
||||
class="rounded-full border border-white/10 bg-white/8 px-3 py-1"
|
||||
>
|
||||
{{ source.name }}: {{ source.ok ? 'sẵn sàng' : 'đang bị chặn' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-md border border-red-300/30 bg-red-500/12 p-4 text-red-100">
|
||||
Không tải được dữ liệu phim. Vui lòng thử lại sau.
|
||||
</p>
|
||||
|
||||
<div v-if="pending" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div v-for="item in 12" :key="item" class="aspect-[2/3] animate-pulse rounded-md bg-white/10" />
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-for="(row, index) in rows"
|
||||
v-show="row.items.length"
|
||||
:id="index === 0 ? 'latest' : index === 1 ? 'series' : 'movies'"
|
||||
:key="row.title"
|
||||
class="mb-12"
|
||||
>
|
||||
<div class="mb-4 flex items-end justify-between gap-4">
|
||||
<h2 class="text-2xl font-extrabold text-white">{{ row.title }}</h2>
|
||||
<NuxtLink :to="row.to" class="text-sm font-semibold text-sky-200 hover:text-white">
|
||||
Xem thêm
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="no-scrollbar -mx-4 flex snap-x gap-4 overflow-x-auto px-4 pb-5">
|
||||
<HomeMovieCard
|
||||
v-for="movie in row.items"
|
||||
:key="`${movie.source}-${movie.slug}`"
|
||||
:movie="movie"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const selectedServer = ref(0)
|
||||
const selectedEpisode = ref(0)
|
||||
|
||||
const { data: movie, pending, error } = await useFetch(`/api/movies/${route.params.slug}`, {
|
||||
query: {
|
||||
source: route.query.source,
|
||||
},
|
||||
})
|
||||
|
||||
const servers = computed(() => movie.value?.servers ?? [])
|
||||
const activeServer = computed(() => servers.value[selectedServer.value])
|
||||
const activeEpisode = computed(() => activeServer.value?.episodes?.[selectedEpisode.value])
|
||||
const playerUrl = computed(() => activeEpisode.value?.linkEmbed || activeEpisode.value?.linkM3u8 || '')
|
||||
const episodeCount = computed(() => servers.value.reduce((total: number, server: any) => total + (server.episodes?.length || 0), 0))
|
||||
|
||||
watch(selectedServer, () => {
|
||||
selectedEpisode.value = 0
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
title: movie.value ? `${movie.value.name} - KR Phim` : 'Đang tải phim - KR Phim',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: movie.value?.content || 'Xem phim Hàn Quốc Vietsub.',
|
||||
},
|
||||
],
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 text-white">
|
||||
<AppHeader />
|
||||
|
||||
<div v-if="pending" class="mx-auto grid min-h-screen max-w-7xl gap-8 px-4 pt-36 sm:px-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:px-8 lg:pt-28">
|
||||
<div class="aspect-video animate-pulse rounded-md bg-white/10" />
|
||||
<div class="h-96 animate-pulse rounded-md bg-white/10" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || !movie" class="mx-auto flex min-h-screen max-w-4xl items-center px-4 pt-36 lg:pt-24">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black">Không mở được phim</h1>
|
||||
<p class="mt-3 text-slate-300">Nguồn phim có thể đang chặn request hoặc phim chưa có tập xem.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="relative pt-32 lg:pt-16">
|
||||
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="absolute inset-0 h-full w-full object-cover opacity-25">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-slate-950/70 via-slate-950 to-slate-950" />
|
||||
|
||||
<div class="relative mx-auto grid max-w-7xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:px-8">
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-md bg-black shadow-2xl shadow-sky-950/40 ring-1 ring-white/10">
|
||||
<div class="aspect-video">
|
||||
<iframe
|
||||
v-if="playerUrl"
|
||||
:src="playerUrl"
|
||||
:title="`${movie.name} - ${activeEpisode?.name || 'Tập phim'}`"
|
||||
class="h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
<div v-else class="flex h-full items-center justify-center bg-slate-900 px-6 text-center text-slate-300">
|
||||
Phim này chưa có link xem khả dụng từ API.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="mb-3 text-sm font-semibold uppercase tracking-wide text-sky-300">{{ movie.source }} · {{ movie.lang || 'Vietsub' }}</p>
|
||||
<h1 class="text-3xl font-black leading-tight sm:text-5xl">{{ movie.name }}</h1>
|
||||
<p v-if="movie.originName" class="mt-2 text-lg text-slate-300">{{ movie.originName }}</p>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2 text-sm text-slate-100">
|
||||
<span v-if="movie.year" class="rounded-full bg-white/10 px-3 py-1">{{ movie.year }}</span>
|
||||
<span v-if="movie.quality" class="rounded-full bg-white/10 px-3 py-1">{{ movie.quality }}</span>
|
||||
<span v-if="movie.episode" class="rounded-full bg-sky-400/20 px-3 py-1 text-sky-100">{{ movie.episode }}</span>
|
||||
<span v-if="movie.time" class="rounded-full bg-white/10 px-3 py-1">{{ movie.time }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="movie.content" class="mt-6 max-w-4xl text-base leading-8 text-slate-200">
|
||||
{{ movie.content }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<p v-if="movie.categories?.length"><span class="font-bold text-white">Thể loại:</span> {{ movie.categories.join(', ') }}</p>
|
||||
<p v-if="movie.countries?.length"><span class="font-bold text-white">Quốc gia:</span> {{ movie.countries.join(', ') }}</p>
|
||||
<p v-if="movie.actors?.length"><span class="font-bold text-white">Diễn viên:</span> {{ movie.actors.slice(0, 8).join(', ') }}</p>
|
||||
<p v-if="movie.directors?.length"><span class="font-bold text-white">Đạo diễn:</span> {{ movie.directors.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="lg:sticky lg:top-24 lg:self-start">
|
||||
<div class="rounded-md border border-white/10 bg-white/8 p-4 shadow-2xl shadow-black/20 backdrop-blur-xl">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-xl font-extrabold">Danh sách tập</h2>
|
||||
<span class="text-sm text-slate-300">{{ episodeCount }} tập</span>
|
||||
</div>
|
||||
|
||||
<div v-if="servers.length > 1" class="mt-4 flex gap-2 overflow-x-auto pb-2">
|
||||
<button
|
||||
v-for="(server, index) in servers"
|
||||
:key="server.name"
|
||||
type="button"
|
||||
class="shrink-0 rounded-md px-3 py-2 text-sm font-semibold"
|
||||
:class="selectedServer === index ? 'bg-sky-300 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"
|
||||
@click="selectedServer = index"
|
||||
>
|
||||
{{ server.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeServer?.episodes?.length" class="mt-4 grid max-h-[32rem] grid-cols-3 gap-2 overflow-y-auto pr-1 sm:grid-cols-4 lg:grid-cols-3">
|
||||
<button
|
||||
v-for="(episode, index) in activeServer.episodes"
|
||||
:key="`${episode.name}-${index}`"
|
||||
type="button"
|
||||
class="min-h-11 rounded-md px-3 py-2 text-sm font-bold transition"
|
||||
:class="selectedEpisode === index ? 'bg-sky-300 text-slate-950' : 'bg-slate-900/80 text-slate-100 hover:bg-sky-400/20'"
|
||||
@click="selectedEpisode = index"
|
||||
>
|
||||
{{ episode.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else class="mt-4 rounded-md border border-white/10 bg-slate-900/70 p-4 text-sm text-slate-300">
|
||||
Chưa có tập xem từ nguồn này.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Search } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const page = ref(Math.max(Number(route.query.page || 1), 1))
|
||||
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||
const debouncedKeyword = ref(keyword.value.trim())
|
||||
const type = ref(typeof route.query.type === 'string' ? route.query.type : 'all')
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
watch(keyword, (value) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedKeyword.value = value.trim()
|
||||
page.value = 1
|
||||
}, 420)
|
||||
})
|
||||
|
||||
watch(type, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch([page, debouncedKeyword, type], () => {
|
||||
router.replace({
|
||||
query: {
|
||||
page: page.value > 1 ? page.value : undefined,
|
||||
q: debouncedKeyword.value || undefined,
|
||||
type: type.value !== 'all' ? type.value : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const { data, pending, error } = await useFetch('/api/movies', {
|
||||
query: computed(() => ({
|
||||
page: page.value,
|
||||
keyword: debouncedKeyword.value || undefined,
|
||||
})),
|
||||
})
|
||||
|
||||
const movies = computed(() => data.value?.items ?? [])
|
||||
const filteredMovies = computed(() => {
|
||||
if (type.value === 'series') {
|
||||
return movies.value.filter((movie: any) => movie.type !== 'single')
|
||||
}
|
||||
|
||||
if (type.value === 'single') {
|
||||
return movies.value.filter((movie: any) => movie.type === 'single' || movie.episode === 'Full')
|
||||
}
|
||||
|
||||
return movies.value
|
||||
})
|
||||
const totalPages = computed(() => Number(data.value?.pagination?.totalPages || data.value?.pagination?.total_pages || 0))
|
||||
const sourceStatus = computed(() => data.value?.sources ?? [])
|
||||
|
||||
function nextPage() {
|
||||
page.value += 1
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
page.value = Math.max(1, page.value - 1)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Duyệt phim Hàn Quốc - KR Phim',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Tìm kiếm và lọc phim Hàn Quốc Vietsub trên KR Phim.',
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950">
|
||||
<AppHeader />
|
||||
|
||||
<section class="mx-auto max-w-[1560px] px-4 pb-16 pt-36 sm:px-6 lg:px-8 lg:pt-28 xl:px-10">
|
||||
<div class="mb-8 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-sky-200">Kho phim Hàn Quốc</p>
|
||||
<h1 class="mt-2 text-3xl font-black text-white sm:text-5xl">
|
||||
Duyệt phim
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row lg:max-w-3xl">
|
||||
<div class="flex min-w-0 flex-1 items-center rounded-full border border-white/10 bg-white/8 px-4 py-3 shadow-2xl shadow-sky-950/20">
|
||||
<Search class="mr-3 size-4 shrink-0 text-sky-200" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
placeholder="Tìm phim Hàn Quốc..."
|
||||
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 rounded-full border border-white/10 bg-white/8 p-1 text-sm font-bold text-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 transition"
|
||||
:class="type === 'all' ? 'bg-sky-300 text-slate-950' : 'hover:text-white'"
|
||||
@click="type = 'all'"
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 transition"
|
||||
:class="type === 'series' ? 'bg-sky-300 text-slate-950' : 'hover:text-white'"
|
||||
@click="type = 'series'"
|
||||
>
|
||||
Phim bộ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 transition"
|
||||
:class="type === 'single' ? 'bg-sky-300 text-slate-950' : 'hover:text-white'"
|
||||
@click="type = 'single'"
|
||||
>
|
||||
Phim lẻ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceStatus.length" class="mb-8 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||
<span
|
||||
v-for="source in sourceStatus"
|
||||
:key="source.name"
|
||||
class="rounded-full border border-white/10 bg-white/8 px-3 py-1"
|
||||
>
|
||||
{{ source.name }}: {{ source.ok ? 'sẵn sàng' : 'đang bị chặn' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-md border border-red-300/30 bg-red-500/12 p-4 text-red-100">
|
||||
Không tải được dữ liệu phim. Vui lòng thử lại sau.
|
||||
</p>
|
||||
|
||||
<div v-if="pending" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<div v-for="item in 18" :key="item" class="aspect-[2/3] animate-pulse rounded-md bg-white/10" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<NuxtLink
|
||||
v-for="movie in filteredMovies"
|
||||
:key="`${movie.source}-${movie.slug}`"
|
||||
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }"
|
||||
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-sky-300/60">
|
||||
<img :src="movie.thumb || movie.poster" :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-gradient-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-sky-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||
</div>
|
||||
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.source }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="!pending && !filteredMovies.length" class="rounded-md border border-white/10 bg-white/8 p-8 text-center text-slate-300">
|
||||
Không có phim phù hợp.
|
||||
</div>
|
||||
|
||||
<div v-if="movies.length" class="flex items-center justify-center gap-3 pt-10">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="page === 1"
|
||||
class="grid size-11 place-items-center rounded-full border border-white/15 bg-white/8 text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="Trang trước"
|
||||
@click="previousPage"
|
||||
>
|
||||
<ChevronLeft class="size-5" />
|
||||
</button>
|
||||
<span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages }}</template></span>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-11 place-items-center rounded-full bg-sky-300 text-slate-950 hover:bg-white"
|
||||
aria-label="Trang sau"
|
||||
@click="nextPage"
|
||||
>
|
||||
<ChevronRight class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user