mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 16:07:47 +00:00
Refactor components for improved type safety and code consistency
- Updated AppHeader.vue to use TypeScript interfaces for member menu items and removed unnecessary type assertions. - Modified AuthModal.vue to enhance styling and improve accessibility with consistent class names and structure. - Enhanced CommentReplies.vue to support deleting replies with a new prop and improved event emission. - Adjusted GifPicker.vue to specify types for fetched data, ensuring better type safety. - Refined HomeMovieCard.vue to handle mouse and focus events more robustly, improving user interaction. - Corrected layout in chinh-sach-bao-mat.vue for better responsiveness and readability. - Updated index.vue to streamline gradient overlays and button positioning for a cleaner UI. - Improved [slug].vue to handle server channels and subtitles more dynamically, enhancing playback experience.
This commit is contained in:
@@ -29,14 +29,16 @@ const navItems = [
|
|||||||
{ label: 'Phim lẻ', to: '/phim?type=single' },
|
{ label: 'Phim lẻ', to: '/phim?type=single' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
interface MemberMenuItem { label: string, icon: string, to: string }
|
||||||
|
|
||||||
const memberMenuItems = computed(() => {
|
const memberMenuItems = computed(() => {
|
||||||
const items = [
|
const items: MemberMenuItem[] = [
|
||||||
{ label: 'Trang cá nhân', icon: 'user' as const, to: '/thanh-vien' },
|
{ label: 'Trang cá nhân', icon: 'user', to: '/thanh-vien' },
|
||||||
{ label: 'Yêu thích', icon: 'heart' as const, to: '/yeu-thich' },
|
{ label: 'Yêu thích', icon: 'heart', to: '/yeu-thich' },
|
||||||
{ label: 'Lịch sử', icon: 'history' as const, to: '/lich-su' },
|
{ label: 'Lịch sử', icon: 'history', to: '/lich-su' },
|
||||||
]
|
]
|
||||||
if (user.value?.role === 'admin') {
|
if (user.value?.role === 'admin') {
|
||||||
items.push({ label: 'Trang quản trị', icon: 'settings' as const, to: '/admin' })
|
items.push({ label: 'Trang quản trị', icon: 'settings', to: '/admin' })
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
@@ -143,7 +145,7 @@ watch(() => route.path, () => {
|
|||||||
</div>
|
</div>
|
||||||
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
|
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
|
||||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/4 hover:text-white">
|
class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/4 hover:text-white">
|
||||||
<AppIcon :name="item.icon" class="size-4 shrink-0 text-white/50" />
|
<AppIcon :name="item.icon as any" class="size-4 shrink-0 text-white/50" />
|
||||||
<span class="text-[13px] font-medium text-white/70">{{ item.label }}</span>
|
<span class="text-[13px] font-medium text-white/70">{{ item.label }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="my-1.5 border-t border-white/6" />
|
<div class="my-1.5 border-t border-white/6" />
|
||||||
@@ -203,7 +205,7 @@ watch(() => route.path, () => {
|
|||||||
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
|
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
|
||||||
class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-semibold text-slate-200 transition hover:bg-white/8 hover:text-white"
|
class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-semibold text-slate-200 transition hover:bg-white/8 hover:text-white"
|
||||||
@click="closeMobileMenu">
|
@click="closeMobileMenu">
|
||||||
<AppIcon :name="item.icon" class="size-4 shrink-0" />
|
<AppIcon :name="item.icon as any" class="size-4 shrink-0" />
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="my-2 border-t border-white/10" />
|
<div class="my-2 border-t border-white/10" />
|
||||||
|
|||||||
@@ -75,10 +75,11 @@ watch(open, (val) => {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="auth-modal">
|
<Transition name="auth-modal">
|
||||||
<div v-if="open" class="auth-modal-overlay fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
<div v-if="open" class="auth-modal-overlay fixed inset-0 z-9999 flex items-center justify-center p-4">
|
||||||
<div class="auth-modal-backdrop absolute inset-0 bg-[#0a0a0f]/90 backdrop-blur-md" @click="closeModal" />
|
<div class="auth-modal-backdrop absolute inset-0 bg-[#0a0a0f]/90 backdrop-blur-md" @click="closeModal" />
|
||||||
|
|
||||||
<div class="auth-modal-content relative w-full max-w-[820px] overflow-hidden rounded-[20px] border border-white/[0.08] bg-[#12121a] shadow-[0_25px_60px_rgba(0,0,0,0.5)]">
|
<div
|
||||||
|
class="auth-modal-content relative w-full max-w-205 overflow-hidden rounded-[20px] border border-white/8 bg-[#12121a] shadow-[0_25px_60px_rgba(0,0,0,0.5)]">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="absolute right-4 top-4 z-20 grid h-8 w-8 place-items-center rounded-full bg-black/20 text-white/70 backdrop-blur-sm transition hover:bg-black/40 hover:text-white"
|
class="absolute right-4 top-4 z-20 grid h-8 w-8 place-items-center rounded-full bg-black/20 text-white/70 backdrop-blur-sm transition hover:bg-black/40 hover:text-white"
|
||||||
@click="closeModal">
|
@click="closeModal">
|
||||||
@@ -87,13 +88,14 @@ watch(open, (val) => {
|
|||||||
|
|
||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row">
|
||||||
<div class="w-full md:w-1/2">
|
<div class="w-full md:w-1/2">
|
||||||
<div class="relative h-1.5 bg-gradient-to-r from-[#FFD166] via-[#FFB800] to-[#FFD166] md:hidden" />
|
<div class="relative h-1.5 bg-linear-to-r from-[#FFD166] via-[#FFB800] to-[#FFD166] md:hidden" />
|
||||||
|
|
||||||
<div class="p-6 pb-4">
|
<div class="p-6 pb-4">
|
||||||
<div v-if="mode !== 'forgot'" class="mb-1 flex items-center gap-2.5">
|
<div v-if="mode !== 'forgot'" class="mb-1 flex items-center gap-2.5">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFD166]/10">
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFD166]/10">
|
||||||
<svg class="size-5 text-[#FFD166]" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="size-5 text-[#FFD166]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<path
|
||||||
|
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -111,7 +113,7 @@ watch(open, (val) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mode !== 'forgot'" class="mt-5 flex gap-1 rounded-full bg-white/[0.04] p-1">
|
<div v-if="mode !== 'forgot'" class="mt-5 flex gap-1 rounded-full bg-white/4 p-1">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="relative flex-1 rounded-full px-4 py-2.5 text-sm font-semibold transition-all duration-200"
|
class="relative flex-1 rounded-full px-4 py-2.5 text-sm font-semibold transition-all duration-200"
|
||||||
:class="mode === 'login' ? 'bg-[#FFD166] text-[#0f111a] shadow-[0_4px_15px_rgba(255,209,102,0.25)]' : 'text-white/50 hover:text-white/80'"
|
:class="mode === 'login' ? 'bg-[#FFD166] text-[#0f111a] shadow-[0_4px_15px_rgba(255,209,102,0.25)]' : 'text-white/50 hover:text-white/80'"
|
||||||
@@ -151,29 +153,30 @@ watch(open, (val) => {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<AppIcon name="mail" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
<AppIcon name="mail" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
||||||
<input v-model="email" type="email" required placeholder="Email của bạn"
|
<input v-model="email" type="email" required placeholder="Email của bạn"
|
||||||
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
|
class="h-12 w-full rounded-xl border border-white/8 bg-white/4 pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/6 focus:ring-2 focus:ring-[#FFD166]/10">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else :key="mode" class="space-y-3">
|
<div v-else :key="mode" class="space-y-3">
|
||||||
<Transition name="form-field">
|
<Transition name="form-field">
|
||||||
<div v-if="mode === 'register'" class="relative">
|
<div v-if="mode === 'register'" class="relative">
|
||||||
<AppIcon name="user-round" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
<AppIcon name="user-round"
|
||||||
|
class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
||||||
<input v-model="name" type="text" placeholder="Tên hiển thị"
|
<input v-model="name" type="text" placeholder="Tên hiển thị"
|
||||||
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
|
class="h-12 w-full rounded-xl border border-white/8 bg-white/4 pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/6 focus:ring-2 focus:ring-[#FFD166]/10">
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<AppIcon name="mail" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
<AppIcon name="mail" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
||||||
<input v-model="email" type="email" required placeholder="Email hoặc số điện thoại"
|
<input v-model="email" type="email" required placeholder="Email hoặc số điện thoại"
|
||||||
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
|
class="h-12 w-full rounded-xl border border-white/8 bg-white/4 pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/6 focus:ring-2 focus:ring-[#FFD166]/10">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<AppIcon name="lock" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
<AppIcon name="lock" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
|
||||||
<input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6"
|
<input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6"
|
||||||
:placeholder="mode === 'login' ? 'Mật khẩu' : 'Mật khẩu (tối thiểu 6 ký tự)'"
|
:placeholder="mode === 'login' ? 'Mật khẩu' : 'Mật khẩu (tối thiểu 6 ký tự)'"
|
||||||
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-11 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
|
class="h-12 w-full rounded-xl border border-white/8 bg-white/4 pl-11 pr-11 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/6 focus:ring-2 focus:ring-[#FFD166]/10">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
|
||||||
@click="showPassword = !showPassword">
|
@click="showPassword = !showPassword">
|
||||||
@@ -189,56 +192,71 @@ watch(open, (val) => {
|
|||||||
:disabled="loading">
|
:disabled="loading">
|
||||||
<svg v-if="loading" class="mr-2 size-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg v-if="loading" class="mr-2 size-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
<path class="opacity-75" fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ loading ? 'Đang xử lý...' : (mode === 'login' ? 'Đăng nhập' : mode === 'forgot' ? 'Gửi link đặt lại mật khẩu' : 'Tạo tài khoản') }}
|
{{ loading ? 'Đang xử lý...' : (mode === 'login' ? 'Đăng nhập' : mode === 'forgot' ?
|
||||||
|
'Gửi link đặt lại mật khẩu' :
|
||||||
|
'Tạo tài khoản') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="mode === 'login'" class="mt-3 text-right">
|
<div v-if="mode === 'login'" class="mt-3 text-right">
|
||||||
<button type="button"
|
<button type="button" class="text-xs text-white/40 transition hover:text-[#FFD166]"
|
||||||
class="text-xs text-white/40 transition hover:text-[#FFD166]"
|
|
||||||
@click="mode = 'forgot'">
|
@click="mode = 'forgot'">
|
||||||
Quên mật khẩu?
|
Quên mật khẩu?
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mode !== 'forgot'" class="mt-5 flex items-center gap-3">
|
<div v-if="mode !== 'forgot'" class="mt-5 flex items-center gap-3">
|
||||||
<div class="h-px flex-1 bg-white/[0.08]" />
|
<div class="h-px flex-1 bg-white/8" />
|
||||||
<span class="text-xs text-white/30">hoặc</span>
|
<span class="text-xs text-white/30">hoặc</span>
|
||||||
<div class="h-px flex-1 bg-white/[0.08]" />
|
<div class="h-px flex-1 bg-white/8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mode !== 'forgot'" class="mt-4 grid grid-cols-2 gap-2.5">
|
<div v-if="mode !== 'forgot'" class="mt-4 grid grid-cols-2 gap-2.5">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex h-11 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.08] hover:text-white">
|
class="flex h-11 items-center justify-center gap-2 rounded-xl border border-white/8 bg-white/4 text-sm font-medium text-white/70 transition-all hover:border-white/15 hover:bg-white/8 hover:text-white">
|
||||||
<svg class="size-4" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
<path
|
||||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
fill="#4285F4" />
|
||||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853" />
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05" />
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335" />
|
||||||
</svg>
|
</svg>
|
||||||
Google
|
Google
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex h-11 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.08] hover:text-white">
|
class="flex h-11 items-center justify-center gap-2 rounded-xl border border-white/8 bg-white/4 text-sm font-medium text-white/70 transition-all hover:border-white/15 hover:bg-white/8 hover:text-white">
|
||||||
<svg class="size-4" fill="#1877F2" viewBox="0 0 24 24">
|
<svg class="size-4" fill="#1877F2" viewBox="0 0 24 24">
|
||||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
<path
|
||||||
|
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||||
</svg>
|
</svg>
|
||||||
Facebook
|
Facebook
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="form-message">
|
<Transition name="form-message">
|
||||||
<p v-if="error" class="mt-4 flex items-center justify-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
|
<p v-if="error"
|
||||||
|
class="mt-4 flex items-center justify-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
|
||||||
<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<Transition name="form-message">
|
<Transition name="form-message">
|
||||||
<p v-if="success" class="mt-4 flex items-center justify-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
|
<p v-if="success"
|
||||||
|
class="mt-4 flex items-center justify-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
|
||||||
<AppIcon name="check" class="size-4 shrink-0" />
|
<AppIcon name="check" class="size-4 shrink-0" />
|
||||||
{{ success }}
|
{{ success }}
|
||||||
</p>
|
</p>
|
||||||
@@ -247,16 +265,18 @@ watch(open, (val) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-modal-image relative hidden overflow-hidden md:block md:w-1/2">
|
<div class="auth-modal-image relative hidden overflow-hidden md:block md:w-1/2">
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] via-[#16213e] to-[#0f3460]" />
|
<div class="absolute inset-0 bg-linear-to-br from-[#1a1a2e] via-[#16213e] to-[#0f3460]" />
|
||||||
<div class="absolute inset-0 opacity-30">
|
<div class="absolute inset-0 opacity-30">
|
||||||
<div class="absolute top-1/4 left-1/4 h-32 w-32 rounded-full bg-[#FFD166]/20 blur-3xl" />
|
<div class="absolute top-1/4 left-1/4 h-32 w-32 rounded-full bg-[#FFD166]/20 blur-3xl" />
|
||||||
<div class="absolute bottom-1/3 right-1/4 h-40 w-40 rounded-full bg-[#e94560]/20 blur-3xl" />
|
<div class="absolute bottom-1/3 right-1/4 h-40 w-40 rounded-full bg-[#e94560]/20 blur-3xl" />
|
||||||
<div class="absolute top-1/2 right-1/3 h-24 w-24 rounded-full bg-[#FFD166]/15 blur-2xl" />
|
<div class="absolute top-1/2 right-1/3 h-24 w-24 rounded-full bg-[#FFD166]/15 blur-2xl" />
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 flex flex-col items-center justify-center p-8">
|
<div class="absolute inset-0 flex flex-col items-center justify-center p-8">
|
||||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#FFD166]/10 backdrop-blur-sm">
|
<div
|
||||||
|
class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#FFD166]/10 backdrop-blur-sm">
|
||||||
<svg class="size-8 text-[#FFD166]" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="size-8 text-[#FFD166]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
|
<path
|
||||||
|
d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-lg font-bold text-white drop-shadow-lg">
|
<p class="text-center text-lg font-bold text-white drop-shadow-lg">
|
||||||
@@ -293,8 +313,13 @@ watch(open, (val) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
@@ -302,6 +327,7 @@ watch(open, (val) => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px) scale(0.95);
|
transform: translateY(20px) scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
|||||||
replyContent: Record<number, string>
|
replyContent: Record<number, string>
|
||||||
isReplyAnonymous: Record<number, boolean>
|
isReplyAnonymous: Record<number, boolean>
|
||||||
isSubmittingReply?: Record<number, boolean>
|
isSubmittingReply?: Record<number, boolean>
|
||||||
|
isDeleting?: Map<number, boolean>
|
||||||
openGifPicker?: (id: number) => void
|
openGifPicker?: (id: number) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -131,9 +132,11 @@ function formatMentions(content: string) {
|
|||||||
<div v-if="reply.replies?.length" class="mt-3 pl-4 border-l-2 border-white/10">
|
<div v-if="reply.replies?.length" class="mt-3 pl-4 border-l-2 border-white/10">
|
||||||
<CommentReplies :replies="reply.replies" :depth="depth + 1" :user="user" :parent-id="reply.id"
|
<CommentReplies :replies="reply.replies" :depth="depth + 1" :user="user" :parent-id="reply.id"
|
||||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||||
:is-submitting-reply="isSubmittingReply" :open-gif-picker="openGifPicker"
|
:is-submitting-reply="isSubmittingReply" :is-deleting="isDeleting" :open-gif-picker="openGifPicker"
|
||||||
@start-reply="emit('start-reply', $event)" @cancel-reply="emit('cancel-reply', $event)"
|
@start-reply="emit('start-reply', $event)" @cancel-reply="emit('cancel-reply', $event)"
|
||||||
@submit-reply="emit('submit-reply', $event)" @vote="emit('vote', $event)" @delete="emit('delete', $event)" />
|
@submit-reply="emit('submit-reply', $event)"
|
||||||
|
@vote="(a, b, c, d) => emit('vote', a, b, c, d)"
|
||||||
|
@delete="emit('delete', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const popupStyle = ref({})
|
|||||||
async function fetchTrending() {
|
async function fetchTrending() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await $fetch(`${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=20&rating=pg-13`)
|
const data: any = await $fetch(`${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=20&rating=pg-13`)
|
||||||
gifs.value = data.data || []
|
gifs.value = data.data || []
|
||||||
} catch {
|
} catch {
|
||||||
gifs.value = []
|
gifs.value = []
|
||||||
@@ -34,7 +34,7 @@ async function searchGifs(query: string) {
|
|||||||
}
|
}
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await $fetch(`${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=20&rating=pg-13&lang=vi`)
|
const data: any = await $fetch(`${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=20&rating=pg-13&lang=vi`)
|
||||||
gifs.value = data.data || []
|
gifs.value = data.data || []
|
||||||
} catch {
|
} catch {
|
||||||
gifs.value = []
|
gifs.value = []
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ const movieLink = computed(() => ({
|
|||||||
path: `/phim/${props.movie.slug}`,
|
path: `/phim/${props.movie.slug}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function showPreview(event: MouseEvent) {
|
function showPreview(event: MouseEvent | FocusEvent) {
|
||||||
if (window.matchMedia("(max-width: 639px)").matches) return;
|
if (window.matchMedia("(max-width: 639px)").matches) return;
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
|
||||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = ((event as any)?.currentTarget || (event as any)?.target)?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
const width = 408;
|
const width = 408;
|
||||||
const height = 344;
|
const height = 344;
|
||||||
const gap = 14;
|
const gap = 14;
|
||||||
@@ -82,7 +84,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="w-44 shrink-0 snap-start sm:w-52 xl:w-56">
|
<div class="w-44 shrink-0 snap-start sm:w-52 xl:w-56">
|
||||||
<!-- Movie Card -->
|
<!-- Movie Card -->
|
||||||
<NuxtLink :to="movieLink" class="group block" @mouseenter="showPreview" @mouseleave="scheduleHide"
|
<NuxtLink :to="movieLink" class="group block" @mouseenter="showPreview" @mouseleave="scheduleHide"
|
||||||
@focus="showPreview" @blur="scheduleHide">
|
@focus.passive="showPreview" @blur="scheduleHide">
|
||||||
<!-- Poster wrapper -->
|
<!-- Poster wrapper -->
|
||||||
<div class="relative aspect-2/3">
|
<div class="relative aspect-2/3">
|
||||||
<div class="h-full w-full overflow-hidden rounded-t-[7px] bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10">
|
<div class="h-full w-full overflow-hidden rounded-t-[7px] bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10">
|
||||||
|
|||||||
@@ -2,47 +2,53 @@
|
|||||||
<main class="min-h-screen bg-[#0f111a] text-white">
|
<main class="min-h-screen bg-[#0f111a] text-white">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<section class="mx-auto max-w-[800px] px-4 pt-24 pb-16 sm:px-6 lg:px-8 xl:px-10">
|
<section class="mx-auto max-w-200 px-4 pt-24 pb-16 sm:px-6 lg:px-8 xl:px-10">
|
||||||
<h1 class="text-3xl font-black text-white mb-6">Chính sách bảo mật</h1>
|
<h1 class="text-3xl font-black text-white mb-6">Chính sách bảo mật</h1>
|
||||||
|
|
||||||
<div class="space-y-6 text-sm text-slate-300 leading-relaxed">
|
<div class="space-y-6 text-sm text-slate-300 leading-relaxed">
|
||||||
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
||||||
<h2 class="text-lg font-bold text-white mb-3">1. Thu thập thông tin</h2>
|
<h2 class="text-lg font-bold text-white mb-3">1. Thu thập thông tin</h2>
|
||||||
<p>
|
<p>
|
||||||
CineK chỉ thu thập các thông tin cần thiết để cung cấp dịch vụ: email khi đăng ký tài khoản, lịch sử xem để lưu tiến trình, và danh sách phim yêu thích. Chúng tôi không thu thập thông tin cá nhân nhạy cảm.
|
CineK chỉ thu thập các thông tin cần thiết để cung cấp dịch vụ: email khi đăng ký tài khoản, lịch sử xem để
|
||||||
|
lưu tiến trình, và danh sách phim yêu thích. Chúng tôi không thu thập thông tin cá nhân nhạy cảm.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
||||||
<h2 class="text-lg font-bold text-white mb-3">2. Sử dụng thông tin</h2>
|
<h2 class="text-lg font-bold text-white mb-3">2. Sử dụng thông tin</h2>
|
||||||
<p>
|
<p>
|
||||||
Thông tin thu thập được sử dụng để: cung cấp và cải thiện dịch vụ, lưu tiến trình xem, đồng bộ danh sách yêu thích giữa các thiết bị, và gửi thông báo về phim mới (nếu bạn đăng ký).
|
Thông tin thu thập được sử dụng để: cung cấp và cải thiện dịch vụ, lưu tiến trình xem, đồng bộ danh sách yêu
|
||||||
|
thích giữa các thiết bị, và gửi thông báo về phim mới (nếu bạn đăng ký).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
||||||
<h2 class="text-lg font-bold text-white mb-3">3. Chia sẻ thông tin</h2>
|
<h2 class="text-lg font-bold text-white mb-3">3. Chia sẻ thông tin</h2>
|
||||||
<p>
|
<p>
|
||||||
CineK không bán, chia sẻ hoặc tiết lộ thông tin cá nhân của bạn cho bên thứ ba, trừ khi có yêu cầu từ cơ quan pháp luật có thẩm quyền.
|
CineK không bán, chia sẻ hoặc tiết lộ thông tin cá nhân của bạn cho bên thứ ba, trừ khi có yêu cầu từ cơ
|
||||||
|
quan pháp luật có thẩm quyền.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
||||||
<h2 class="text-lg font-bold text-white mb-3">4. Bảo mật dữ liệu</h2>
|
<h2 class="text-lg font-bold text-white mb-3">4. Bảo mật dữ liệu</h2>
|
||||||
<p>
|
<p>
|
||||||
Chúng tôi sử dụng các biện pháp bảo mật tiêu chuẩn để bảo vệ thông tin người dùng, bao gồm mã hóa mật khẩu và kết nối HTTPS.
|
Chúng tôi sử dụng các biện pháp bảo mật tiêu chuẩn để bảo vệ thông tin người dùng, bao gồm mã hóa mật khẩu
|
||||||
|
và kết nối HTTPS.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
<div class="rounded-xl border border-white/10 bg-[#191b24] p-6">
|
||||||
<h2 class="text-lg font-bold text-white mb-3">5. Cookie</h2>
|
<h2 class="text-lg font-bold text-white mb-3">5. Cookie</h2>
|
||||||
<p>
|
<p>
|
||||||
CineK sử dụng cookie để lưu phiên đăng nhập và tùy chọn người dùng. Bạn có thể tắt cookie trong trình duyệt nhưng một số tính năng có thể không hoạt động đúng.
|
CineK sử dụng cookie để lưu phiên đăng nhập và tùy chọn người dùng. Bạn có thể tắt cookie trong trình duyệt
|
||||||
|
nhưng một số tính năng có thể không hoạt động đúng.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-slate-500">
|
<p class="text-slate-500">
|
||||||
Cập nhật lần cuối: Tháng 7, 2026. Nếu có thắc mắc, vui lòng <NuxtLink to="/lien-he" class="text-yellow-300 hover:underline">liên hệ chúng tôi</NuxtLink>.
|
Cập nhật lần cuối: Tháng 7, 2026. Nếu có thắc mắc, vui lòng <NuxtLink to="/lien-he"
|
||||||
|
class="text-yellow-300 hover:underline">liên hệ chúng tôi</NuxtLink>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+5
-6
@@ -313,8 +313,7 @@ useHead({
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<div v-if="hero" class="relative bg-black mt-16 md:mt-0">
|
<div v-if="hero" class="relative bg-black mt-16 md:mt-0">
|
||||||
<section
|
<section class="relative w-full aspect-video md:h-screen overflow-hidden bg-black">
|
||||||
class="relative w-full aspect-video md:h-screen overflow-hidden bg-black after:content-[''] after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1.5 after:bg-black after:z-10">
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<HeroSlider v-if="heroSlides.length" ref="heroSliderRef" :slides="heroSlides"
|
<HeroSlider v-if="heroSlides.length" ref="heroSliderRef" :slides="heroSlides"
|
||||||
@update:modelValue="heroIndex = $event" />
|
@update:modelValue="heroIndex = $event" />
|
||||||
@@ -322,12 +321,12 @@ useHead({
|
|||||||
|
|
||||||
<!-- Mobile gradient overlay (bottom only) -->
|
<!-- Mobile gradient overlay (bottom only) -->
|
||||||
<div class="absolute inset-0 z-10 pointer-events-none md:hidden"
|
<div class="absolute inset-0 z-10 pointer-events-none md:hidden"
|
||||||
style="background:linear-gradient(to top, #000000 0%, rgba(0,0,0,0.92) 8%, rgba(0,0,0,0.75) 22%, rgba(0,0,0,0.35) 50%, transparent 78%)">
|
style="background:linear-gradient(to top, #0E111A 0%, rgba(14,17,26,0.95) 8%, rgba(14,17,26,0.78) 22%, rgba(14,17,26,0.38) 50%, transparent 78%)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop gradient overlay (bottom only) -->
|
<!-- Desktop gradient overlay (bottom only) -->
|
||||||
<div class="absolute inset-0 z-10 pointer-events-none hidden md:block"
|
<div class="absolute inset-0 z-10 pointer-events-none hidden md:block"
|
||||||
style="background:linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.75) 20%, rgba(0,0,0,0.35) 45%, transparent 70%)">
|
style="background:linear-gradient(to top, #0E111A 0%, rgba(14,17,26,0.78) 20%, rgba(14,17,26,0.38) 45%, transparent 70%)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content overlay -->
|
<!-- Content overlay -->
|
||||||
@@ -675,13 +674,13 @@ useHead({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carousel navigation -->
|
<!-- Carousel navigation -->
|
||||||
<button type="button" class="absolute -left-[4.25rem] top-1/2 z-20 hidden size-14 -translate-y-1/2 place-items-center
|
<button type="button" class="absolute -left-17 top-1/2 z-20 hidden size-14 -translate-y-1/2 place-items-center
|
||||||
rounded-full border border-white/15 bg-[#11141d] text-white shadow-lg
|
rounded-full border border-white/15 bg-[#11141d] text-white shadow-lg
|
||||||
transition hover:bg-[#181c27] lg:grid" @click="scrollComments('left')">
|
transition hover:bg-[#181c27] lg:grid" @click="scrollComments('left')">
|
||||||
<AppIcon name="chevron-left" class="size-6" />
|
<AppIcon name="chevron-left" class="size-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="absolute -right-[4.25rem] top-1/2 z-20 hidden size-14 -translate-y-1/2 place-items-center
|
<button type="button" class="absolute -right-17 top-1/2 z-20 hidden size-14 -translate-y-1/2 place-items-center
|
||||||
rounded-full border border-white/15 bg-[#11141d] text-white shadow-lg
|
rounded-full border border-white/15 bg-[#11141d] text-white shadow-lg
|
||||||
transition hover:bg-[#181c27] lg:grid" @click="scrollComments('right')">
|
transition hover:bg-[#181c27] lg:grid" @click="scrollComments('right')">
|
||||||
<AppIcon name="chevron-right" class="size-6" />
|
<AppIcon name="chevron-right" class="size-6" />
|
||||||
|
|||||||
+60
-16
@@ -57,8 +57,51 @@ const libraryItem = computed(() => movie.value ? {
|
|||||||
|
|
||||||
const servers = computed(() => movie.value?.servers ?? [])
|
const servers = computed(() => movie.value?.servers ?? [])
|
||||||
const activeServer = computed(() => servers.value[selectedServer.value] ?? servers.value[0])
|
const activeServer = computed(() => servers.value[selectedServer.value] ?? servers.value[0])
|
||||||
const activeSubtitle = computed(() => activeServer.value?.channels?.[selectedSubtitle.value] ?? activeServer.value)
|
// Server may have channels property at runtime
|
||||||
const activeEpisode = computed(() => activeSubtitle.value?.episodes?.[selectedEpisode.value] ?? activeSubtitle.value?.episodes?.[0])
|
const activeSubtitle = computed(() => {
|
||||||
|
const server = (servers.value[selectedServer.value] as any)
|
||||||
|
if (Array.isArray(server?.channels) && server.channels.length > 0) {
|
||||||
|
return server.channels[selectedSubtitle.value] ?? server
|
||||||
|
}
|
||||||
|
return server
|
||||||
|
})
|
||||||
|
const activeEpisode = computed(() => {
|
||||||
|
const sub = activeSubtitle.value as any
|
||||||
|
if (!sub) return undefined
|
||||||
|
const arr = sub?.episodes || []
|
||||||
|
return arr[selectedEpisode.value] || arr[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Player/computed properties needed for template
|
||||||
|
const durationSeconds = computed(() => videoRef.value ? Math.floor(videoRef.value.duration || 0) : 0)
|
||||||
|
const hlsPlayerUrl = computed(() => {
|
||||||
|
const ep = activeEpisode.value
|
||||||
|
return ep?.linkM3u8 || ep?.link_embed || ''
|
||||||
|
})
|
||||||
|
const hasNextEpisode = computed(() => {
|
||||||
|
const totalEps = activeSubtitle.value?.episodes?.length || 0
|
||||||
|
return selectedEpisode.value < totalEps - 1
|
||||||
|
})
|
||||||
|
const shouldShowHlsControls = computed(() => controlsVisible.value || isSettingsOpen.value)
|
||||||
|
const episodeProgress = computed(() => {
|
||||||
|
if (!movie.value?.episode || !movie.value?.episodeTotal) return null
|
||||||
|
const match = String(movie.value.episode).match(/(\d+)\s*\/\s*(\d+)/)
|
||||||
|
if (match) return { available: Number(match[1]), total: Number(match[2]) }
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
if (!durationSeconds.value) return 0
|
||||||
|
return Math.round((progressSeconds.value / durationSeconds.value) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getProxyUrl(url: string): string {
|
||||||
|
if (!url) return ''
|
||||||
|
return '/api/proxy-m3u8?url=' + encodeURIComponent(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip intro/outro seconds (set from admin config when available)
|
||||||
|
const skipIntroSeconds = 0
|
||||||
|
const skipOutroSeconds = ref(0)
|
||||||
|
|
||||||
|
|
||||||
// Related movies
|
// Related movies
|
||||||
@@ -98,7 +141,8 @@ function episodeLink(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function episodeIndexForServer(serverIndex: number, subIndex = selectedSubtitle.value, episodeIndex = selectedEpisode.value) {
|
function episodeIndexForServer(serverIndex: number, subIndex = selectedSubtitle.value, episodeIndex = selectedEpisode.value) {
|
||||||
const sub = servers.value[serverIndex]?.channels?.[subIndex] ?? servers.value[serverIndex]
|
const server = (servers.value[serverIndex] as any)
|
||||||
|
const sub = server?.channels?.[subIndex] ?? server
|
||||||
const count = sub?.episodes?.length || 0
|
const count = sub?.episodes?.length || 0
|
||||||
return count ? Math.min(Math.max(episodeIndex, 0), count - 1) : 0
|
return count ? Math.min(Math.max(episodeIndex, 0), count - 1) : 0
|
||||||
}
|
}
|
||||||
@@ -698,9 +742,9 @@ useHead(() => ({
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button v-for="(server, index) in servers" :key="server.name" type="button"
|
<button v-for="(server, index) in servers" :key="server.name" type="button"
|
||||||
class="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors"
|
class="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors"
|
||||||
:class="selectedServer === index ? 'border-white/30 bg-white/5 text-white' : 'border-transparent text-white/60 hover:text-white'"
|
:class="selectedServer === Number(index) ? 'border-white/30 bg-white/5 text-white' : 'border-transparent text-white/60 hover:text-white'"
|
||||||
@click="selectServer(index)">
|
@click="selectServer(Number(index))">
|
||||||
{{ serverLabel(server, index) }}
|
{{ serverLabel(server, Number(index)) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,18 +752,18 @@ useHead(() => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subtitle/Source Buttons -->
|
<!-- Subtitle/Source Buttons -->
|
||||||
<div v-if="activeServer?.channels?.length" class="mb-6 flex flex-wrap gap-2">
|
<div v-if="(activeServer as any)?.channels?.length" class="mb-6 flex flex-wrap gap-2">
|
||||||
<button v-for="(sub, index) in activeServer.channels" :key="sub.name || index" type="button"
|
<button v-for="(sub, index) in (activeServer as any)?.channels" :key="sub.name || index" type="button"
|
||||||
class="group flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
|
class="group flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
:class="selectedSubtitle === index ? 'border-white/30 bg-white/5 text-white' : 'border-transparent text-white/60 hover:text-white'"
|
:class="selectedSubtitle === Number(index) ? 'border-white/30 bg-white/5 text-white' : 'border-transparent text-white/60 hover:text-white'"
|
||||||
@click="selectSubtitle(index)">
|
@click="selectSubtitle(Number(index))">
|
||||||
<AppIcon v-if="index === 0" name="captions" class="size-3.5" />
|
<AppIcon v-if="Number(index) === 0" name="captions" class="size-3.5" />
|
||||||
<AppIcon v-else name="languages" class="size-3.5" />
|
<AppIcon v-else name="languages" class="size-3.5" />
|
||||||
|
|
||||||
<span>{{ subtitleLabel(sub, index) }}</span>
|
<span>{{ subtitleLabel(sub, Number(index)) }}</span>
|
||||||
|
|
||||||
<span class="rounded px-1.5 text-[10px] font-bold"
|
<span class="rounded px-1.5 text-[10px] font-bold"
|
||||||
:class="selectedSubtitle === index ? 'bg-white text-black' : 'bg-white/10 text-white/50'">
|
:class="selectedSubtitle === Number(index) ? 'bg-white text-black' : 'bg-white/10 text-white/50'">
|
||||||
{{ sub.episodes?.length || 0 }}
|
{{ sub.episodes?.length || 0 }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -729,11 +773,11 @@ useHead(() => ({
|
|||||||
<div v-if="activeSubtitle?.episodes?.length"
|
<div v-if="activeSubtitle?.episodes?.length"
|
||||||
class="grid grid-cols-3 gap-x-2.5 gap-y-2.5 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
|
class="grid grid-cols-3 gap-x-2.5 gap-y-2.5 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
|
||||||
<NuxtLink v-for="(episode, index) in activeSubtitle.episodes" :key="`${episode.name}-${index}`"
|
<NuxtLink v-for="(episode, index) in activeSubtitle.episodes" :key="`${episode.name}-${index}`"
|
||||||
:to="episodeLink(index)"
|
:to="episodeLink(Number(index))"
|
||||||
class="group flex items-center justify-center gap-1.5 rounded-lg bg-[#191b24] px-2 py-2.5 text-[13px] text-white/90 shadow-sm transition-all hover:bg-[#1f2130] hover:text-[#FFD166]"
|
class="group flex items-center justify-center gap-1.5 rounded-lg bg-[#191b24] px-2 py-2.5 text-[13px] text-white/90 shadow-sm transition-all hover:bg-[#1f2130] hover:text-[#FFD166]"
|
||||||
:class="selectedEpisode === index ? 'border border-[#F5C518]/40 bg-[#F5C518]/20 text-[#FFD166]' : ''">
|
:class="selectedEpisode === Number(index) ? 'border border-[#F5C518]/40 bg-[#F5C518]/20 text-[#FFD166]' : ''">
|
||||||
<AppIcon name="play" class="size-3 fill-current" />
|
<AppIcon name="play" class="size-3 fill-current" />
|
||||||
{{ formatEpisodeName(episode.name, index) }}
|
{{ formatEpisodeName(episode.name, Number(index)) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user