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:
@@ -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>
|
||||
Reference in New Issue
Block a user