feat: implement core Nuxt application with film browsing, search, and detail page functionality using OPhim and NguonC APIs.

This commit is contained in:
thanhvudaynee
2026-05-17 20:30:19 +07:00
parent 90791df93d
commit 34d25df560
18 changed files with 2907 additions and 145 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<NuxtRouteAnnouncer />
<NuxtPage />
<AppFooter />
<ScrollToTop />
</template>
+63
View File
@@ -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;
}
+66
View File
@@ -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 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>
+60
View File
@@ -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>
+18
View File
@@ -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>
+148
View File
@@ -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>
+43
View File
@@ -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>
+244
View File
@@ -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>
+139
View File
@@ -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 thể đang chặn request hoặc phim chưa 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 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 tập xem từ nguồn này.
</p>
</div>
</aside>
</div>
</section>
</template>
</main>
</template>
+200
View File
@@ -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 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>