feat: change libraly icons

This commit is contained in:
ngthanhvu
2026-07-25 10:39:40 -04:00
parent 856c514c15
commit 6c1dd39935
33 changed files with 827 additions and 459 deletions
+7 -9
View File
@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { CirclePlay, Globe2, MessageCircle, Music2, Send } from 'lucide-vue-next'
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const socialLinks = [ const socialLinks = [
{ label: 'Telegram', icon: Send }, { label: 'Telegram', icon: 'send' as const },
{ label: 'Chat', icon: MessageCircle }, { label: 'Chat', icon: 'message-circle' as const },
{ label: 'Website', icon: Globe2 }, { label: 'Website', icon: 'globe' as const },
{ label: 'Cộng đồng', icon: MessageCircle }, { label: 'Cộng đồng', icon: 'message-circle' as const },
{ label: 'TikTok', icon: Music2 }, { label: 'TikTok', icon: 'music' as const },
{ label: 'Video', icon: CirclePlay }, { label: 'Video', icon: 'circle-play' as const },
] ]
const linkGroups = [ const linkGroups = [
@@ -38,7 +36,7 @@ const linkGroups = [
class="grid size-12 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-lg shadow-yellow-950/30 transition hover:-translate-y-0.5 hover:bg-white" class="grid size-12 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-lg shadow-yellow-950/30 transition hover:-translate-y-0.5 hover:bg-white"
:aria-label="item.label" :aria-label="item.label"
> >
<component :is="item.icon" class="size-5" /> <AppIcon :name="item.icon" class="size-5" />
</button> </button>
</div> </div>
</div> </div>
+11 -22
View File
@@ -1,15 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import {
Heart,
History,
LogOut,
Menu,
Search,
Settings,
User,
X,
} from 'lucide-vue-next'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '') const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
@@ -42,12 +31,12 @@ const navItems = [
const memberMenuItems = computed(() => { const memberMenuItems = computed(() => {
const items = [ const items = [
{ label: 'Trang cá nhân', icon: User, to: '/thanh-vien' }, { label: 'Trang cá nhân', icon: 'user' as const, to: '/thanh-vien' },
{ label: 'Yêu thích', icon: Heart, to: '/yeu-thich' }, { label: 'Yêu thích', icon: 'heart' as const, to: '/yeu-thich' },
{ label: 'Lịch sử', icon: History, to: '/lich-su' }, { label: 'Lịch sử', icon: 'history' as const, to: '/lich-su' },
] ]
if (user.value?.role === 'admin') { if (user.value?.role === 'admin') {
items.push({ label: 'Trang quản trị', icon: Settings, to: '/admin' }) items.push({ label: 'Trang quản trị', icon: 'settings' as const, to: '/admin' })
} }
return items return items
}) })
@@ -109,7 +98,7 @@ watch(() => route.path, () => {
<form <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-yellow-950/20 sm:max-w-sm" 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-yellow-950/20 sm:max-w-sm"
@submit.prevent="submitSearch"> @submit.prevent="submitSearch">
<Search class="mr-3 size-4 shrink-0 text-yellow-200" /> <AppIcon name="search" class="mr-3 size-4 shrink-0 text-yellow-200" />
<input v-model="keyword" type="search" placeholder="Tìm phim Hàn Quốc..." <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"> class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
</form> </form>
@@ -151,14 +140,14 @@ 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/[0.04] hover:text-white"> class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/[0.04] hover:text-white">
<component :is="item.icon" class="size-4 shrink-0 text-white/50" /> <AppIcon :name="item.icon" 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/[0.06]" /> <div class="my-1.5 border-t border-white/[0.06]" />
<button type="button" <button type="button"
class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 text-[13px] font-medium text-red-400/70 transition hover:bg-red-400/8 hover:text-red-400" class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 text-[13px] font-medium text-red-400/70 transition hover:bg-red-400/8 hover:text-red-400"
@click="handleLogout"> @click="handleLogout">
<LogOut class="size-4 shrink-0" /> <AppIcon name="log-out" class="size-4 shrink-0" />
Đăng xuất Đăng xuất
</button> </button>
</div> </div>
@@ -168,7 +157,7 @@ watch(() => route.path, () => {
<button type="button" <button type="button"
class="grid size-10 shrink-0 cursor-pointer place-items-center rounded-lg border border-white/10 bg-white/8 text-white transition hover:bg-white/16 lg:hidden" class="grid size-10 shrink-0 cursor-pointer place-items-center rounded-lg border border-white/10 bg-white/8 text-white transition hover:bg-white/16 lg:hidden"
aria-label="Mở menu" @click="mobileMenuOpen = true"> aria-label="Mở menu" @click="mobileMenuOpen = true">
<Menu class="size-5" /> <AppIcon name="menu" class="size-5" />
</button> </button>
</nav> </nav>
@@ -182,7 +171,7 @@ watch(() => route.path, () => {
<button type="button" <button type="button"
class="grid size-10 cursor-pointer place-items-center rounded-lg border border-white/10 bg-white/8 text-white transition hover:bg-white/16" class="grid size-10 cursor-pointer place-items-center rounded-lg border border-white/10 bg-white/8 text-white transition hover:bg-white/16"
aria-label="Đóng menu" @click="closeMobileMenu"> aria-label="Đóng menu" @click="closeMobileMenu">
<X class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
</div> </div>
@@ -210,14 +199,14 @@ 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">
<component :is="item.icon" class="size-4 shrink-0" /> <AppIcon :name="item.icon" 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" />
<button type="button" <button type="button"
class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-black text-red-400 transition hover:bg-white/8 hover:text-red-300" class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-black text-red-400 transition hover:bg-white/8 hover:text-red-300"
@click="handleLogout"> @click="handleLogout">
<LogOut class="size-4 shrink-0" /> <AppIcon name="log-out" class="size-4 shrink-0" />
Đăng xuất Đăng xuất
</button> </button>
</template> </template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { IconName } from '~/lib/icons'
import { getIcon } from '~/lib/icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
const props = defineProps<{
name: IconName
class?: string
}>()
const icon = computed(() => getIcon(props.name))
</script>
<template>
<FontAwesomeIcon :icon="icon" :class="props.class" />
</template>
+2 -6
View File
@@ -1,14 +1,10 @@
<script setup lang="ts"> <template>
import { Play } from 'lucide-vue-next'
</script>
<template>
<NuxtLink to="/" <NuxtLink to="/"
class="group inline-flex h-8 min-w-24 shrink-0 flex-row flex-nowrap items-center gap-2 whitespace-nowrap sm:h-10 sm:min-w-29.5 sm:gap-2.5" class="group inline-flex h-8 min-w-24 shrink-0 flex-row flex-nowrap items-center gap-2 whitespace-nowrap sm:h-10 sm:min-w-29.5 sm:gap-2.5"
aria-label="CineK"> aria-label="CineK">
<span <span
class="grid size-7 shrink-0 place-items-center rounded-lg border border-yellow-200/80 bg-black/30 text-white shadow-lg shadow-black/20 transition group-hover:border-yellow-300 group-hover:text-yellow-200 sm:size-9"> class="grid size-7 shrink-0 place-items-center rounded-lg border border-yellow-200/80 bg-black/30 text-white shadow-lg shadow-black/20 transition group-hover:border-yellow-300 group-hover:text-yellow-200 sm:size-9">
<Play class="ml-0.5 size-4 fill-current sm:size-5" /> <AppIcon name="play" class="ml-0.5 size-4 fill-current sm:size-5" />
</span> </span>
<span class="inline-flex items-baseline text-xl font-black leading-none tracking-normal sm:text-2xl"> <span class="inline-flex items-baseline text-xl font-black leading-none tracking-normal sm:text-2xl">
<span class="text-white">Cine</span><span class="text-yellow-300">K</span> <span class="text-white">Cine</span><span class="text-yellow-300">K</span>
+10 -12
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft, Check, Eye, EyeOff, KeyRound, LockKeyhole, Mail, UserRound, X } from 'lucide-vue-next'
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const { fetchUser } = useAuth() const { fetchUser } = useAuth()
@@ -84,7 +82,7 @@ watch(open, (val) => {
<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">
<X class="size-4" /> <AppIcon name="x" class="size-4" />
</button> </button>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
@@ -132,12 +130,12 @@ watch(open, (val) => {
<button type="button" <button type="button"
class="mb-3 flex items-center gap-1.5 text-sm text-white/50 transition hover:text-white" class="mb-3 flex items-center gap-1.5 text-sm text-white/50 transition hover:text-white"
@click="mode = 'login'"> @click="mode = 'login'">
<ArrowLeft class="size-3.5" /> <AppIcon name="arrow-left" class="size-3.5" />
Quay lại đăng nhập Quay lại đăng nhập
</button> </button>
<div class="flex items-center gap-2.5"> <div class="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">
<KeyRound class="size-5 text-[#FFD166]" /> <AppIcon name="key" class="size-5 text-[#FFD166]" />
</div> </div>
<div> <div>
<h2 class="text-xl font-bold text-white">Quên mật khẩu</h2> <h2 class="text-xl font-bold text-white">Quên mật khẩu</h2>
@@ -151,7 +149,7 @@ watch(open, (val) => {
<Transition name="form-slide" mode="out-in"> <Transition name="form-slide" mode="out-in">
<div v-if="mode === 'forgot'" :key="'forgot'" class="space-y-3"> <div v-if="mode === 'forgot'" :key="'forgot'" class="space-y-3">
<div class="relative"> <div class="relative">
<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/[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">
</div> </div>
@@ -159,28 +157,28 @@ watch(open, (val) => {
<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">
<UserRound 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/[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">
</div> </div>
</Transition> </Transition>
<div class="relative"> <div class="relative">
<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/[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">
</div> </div>
<div class="relative"> <div class="relative">
<LockKeyhole 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/[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">
<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">
<EyeOff v-if="showPassword" class="size-4" /> <AppIcon v-if="showPassword" name="eye-off" class="size-4" />
<Eye v-else class="size-4" /> <AppIcon v-else name="eye" class="size-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -241,7 +239,7 @@ watch(open, (val) => {
<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">
<Check class="size-4 shrink-0" /> <AppIcon name="check" class="size-4 shrink-0" />
{{ success }} {{ success }}
</p> </p>
</Transition> </Transition>
+19 -19
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { CornerDownLeft, Crown, Image, Loader2, ThumbsDown, ThumbsUp, Trash2, User, Venus, Mars, Minus } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
replies: any[] replies: any[]
depth?: number depth?: number
@@ -41,11 +39,11 @@ function formatMentions(content: string) {
:class="reply.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-1 ring-offset-[#0d0f17]' : ''"> :class="reply.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-1 ring-offset-[#0d0f17]' : ''">
<img v-if="reply.userAvatar" :src="reply.userAvatar" <img v-if="reply.userAvatar" :src="reply.userAvatar"
class="size-8 rounded-full object-cover" alt="" /> class="size-8 rounded-full object-cover" alt="" />
<User v-else class="size-3.5 text-slate-500" /> <AppIcon name="user" v-else class="size-3.5 text-slate-500" />
</div> </div>
<div v-if="reply.userRole === 'admin'" <div v-if="reply.userRole === 'admin'"
class="absolute -top-0.5 -left-0.5 size-3.5 bg-cinek-500 rounded-full flex items-center justify-center"> class="absolute -top-0.5 -left-0.5 size-3 bg-yellow-400 rounded-full flex items-center justify-center ring-1 ring-[#0d0f17]">
<Crown class="size-2 text-slate-950" /> <svg class="size-1.5 text-slate-950" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z"/></svg>
</div> </div>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -53,9 +51,9 @@ function formatMentions(content: string) {
<span v-if="reply.userRole === 'admin'" <span v-if="reply.userRole === 'admin'"
class="px-1.5 py-0.5 rounded text-[9px] font-black bg-yellow-400 text-slate-900">ADMIN</span> class="px-1.5 py-0.5 rounded text-[9px] font-black bg-yellow-400 text-slate-900">ADMIN</span>
<span class="text-xs font-semibold text-white">{{ reply.userName }}</span> <span class="text-xs font-semibold text-white">{{ reply.userName }}</span>
<Venus v-if="reply.userGender === 'female'" class="size-3 text-pink-400" /> <AppIcon name="venus" v-if="reply.userGender === 'female'" class="size-3 text-pink-400" />
<Mars v-if="reply.userGender === 'male'" class="size-3 text-blue-400" /> <AppIcon name="mars" v-if="reply.userGender === 'male'" class="size-3 text-blue-400" />
<Minus v-if="reply.userGender === 'other'" class="size-3 text-purple-400" /> <AppIcon name="minus" v-if="reply.userGender === 'other'" class="size-3 text-purple-400" />
<span class="text-xs text-slate-600">{{ reply.timeAgo || '' }}</span> <span class="text-xs text-slate-600">{{ reply.timeAgo || '' }}</span>
</div> </div>
<p class="text-xs text-slate-400 whitespace-pre-wrap wrap-break-word leading-relaxed cursor-pointer select-none py-1 transition-all duration-200" <p class="text-xs text-slate-400 whitespace-pre-wrap wrap-break-word leading-relaxed cursor-pointer select-none py-1 transition-all duration-200"
@@ -67,22 +65,22 @@ function formatMentions(content: string) {
<button type="button" @click="handleVote(reply.id, reply.userVote === 1 ? 0 : 1)" <button type="button" @click="handleVote(reply.id, reply.userVote === 1 ? 0 : 1)"
class="flex items-center gap-1 text-xs" class="flex items-center gap-1 text-xs"
:class="reply.userVote === 1 ? 'text-cinek-400' : 'text-slate-600 hover:text-slate-400'"> :class="reply.userVote === 1 ? 'text-cinek-400' : 'text-slate-600 hover:text-slate-400'">
<ThumbsUp class="size-3" /><span v-if="reply.likeCount">{{ reply.likeCount }}</span> <AppIcon name="thumbs-up" class="size-3" /><span v-if="reply.likeCount">{{ reply.likeCount }}</span>
</button> </button>
<button type="button" @click="handleVote(reply.id, reply.userVote === -1 ? 0 : -1)" <button type="button" @click="handleVote(reply.id, reply.userVote === -1 ? 0 : -1)"
class="flex items-center gap-1 text-xs" class="flex items-center gap-1 text-xs"
:class="reply.userVote === -1 ? 'text-red-400' : 'text-slate-600 hover:text-slate-400'"> :class="reply.userVote === -1 ? 'text-red-400' : 'text-slate-600 hover:text-slate-400'">
<ThumbsDown class="size-3" /><span v-if="reply.dislikeCount">{{ reply.dislikeCount }}</span> <AppIcon name="thumbs-down" class="size-3" /><span v-if="reply.dislikeCount">{{ reply.dislikeCount }}</span>
</button> </button>
<button v-if="depth < maxDepth" type="button" @click="emit('start-reply', reply.id)" <button v-if="depth < maxDepth" type="button" @click="emit('start-reply', reply.id)"
class="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400"> class="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400">
<CornerDownLeft class="size-3" /><span>Trả lời</span> <AppIcon name="corner-down-left" class="size-3" /><span>Trả lời</span>
</button> </button>
<button v-if="user && (user.id === reply.userId || user.role === 'admin')" type="button" <button v-if="user && (user.id === reply.userId || user.role === 'admin')" type="button"
@click="emit('delete', reply.id)" class="text-xs text-slate-600 hover:text-red-400 disabled:opacity-50" @click="emit('delete', reply.id)" class="text-xs text-slate-600 hover:text-red-400 disabled:opacity-50"
:disabled="props.isDeleting?.has(reply.id)"> :disabled="props.isDeleting?.has(reply.id)">
<Loader2 v-if="props.isDeleting?.has(reply.id)" class="size-3 animate-spin" /> <AppIcon name="loader" v-if="props.isDeleting?.has(reply.id)" class="size-3 animate-spin" />
<Trash2 v-else class="size-3" /> <AppIcon name="trash" v-else class="size-3" />
</button> </button>
</div> </div>
<!-- Reply form --> <!-- Reply form -->
@@ -103,18 +101,20 @@ function formatMentions(content: string) {
<span class="text-xs text-slate-500">Tiết lộ</span> <span class="text-xs text-slate-500">Tiết lộ</span>
</div> </div>
<button type="button" class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition"> <button type="button" class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition">
<Image class="size-4" /><span>GIF</span> <AppIcon name="image" class="size-4" /><span>GIF</span>
</button> </button>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs text-slate-600">{{ (replyContent[reply.id] || '').length }} / 1000</span> <span class="text-xs text-white/30">{{ (replyContent[reply.id] || '').length }} / 1000</span>
<button type="button" @click="emit('cancel-reply', reply.id)" <button type="button" @click="emit('cancel-reply', reply.id)"
class="px-3 py-1.5 rounded-lg text-xs text-slate-500 hover:text-white transition">Hủy</button> class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white transition">Hủy</button>
<button type="button" @click="emit('submit-reply', reply.id)" <button type="button" @click="emit('submit-reply', reply.id)"
class="px-4 py-1.5 rounded-lg bg-cinek-500 text-xs font-bold text-slate-950 hover:bg-cinek-400 transition" class="flex items-center gap-2 text-sm font-semibold transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="(replyContent[reply.id] || '').trim() ? 'text-[#FFD166] hover:text-[#FFC845]' : 'text-white/30'"
:disabled="props.isSubmittingReply?.[reply.id]"> :disabled="props.isSubmittingReply?.[reply.id]">
<Loader2 v-if="props.isSubmittingReply?.[reply.id]" class="size-3 animate-spin inline" /> <span>Gửi</span>
<span v-else>Gửi</span> <AppIcon name="loader" v-if="props.isSubmittingReply?.[reply.id]" class="size-4 animate-spin" />
<AppIcon name="send" v-else class="size-4" />
</button> </button>
</div> </div>
</div> </div>
+65 -56
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDown, ChevronUp, CornerDownLeft, Crown, Image, Loader2, MessageSquare, Pin, Send, ThumbsDown, ThumbsUp, Trash2, User, Venus, Mars, Minus } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
source: string source: string
@@ -348,7 +347,7 @@ watch(() => props.slug, () => {
<template> <template>
<div class="mt-10 border-t border-white/10 pt-8"> <div class="mt-10 border-t border-white/10 pt-8">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<MessageSquare class="size-5 text-yellow-300" /> <AppIcon name="message-square" class="size-5 text-yellow-300" />
<h2 class="text-xl font-bold text-white">Bình luận</h2> <h2 class="text-xl font-bold text-white">Bình luận</h2>
<span class="text-sm text-slate-400">({{ comments.length }})</span> <span class="text-sm text-slate-400">({{ comments.length }})</span>
</div> </div>
@@ -357,39 +356,47 @@ watch(() => props.slug, () => {
<p class="text-sm text-slate-400">Đăng nhập để bình luận về phim này.</p> <p class="text-sm text-slate-400">Đăng nhập để bình luận về phim này.</p>
</div> </div>
<div v-else class="mb-8 p-4 rounded-xl bg-[#13151f] border border-white/5"> <div v-else class="mb-8 rounded-2xl bg-[#181a24] border border-white/5 overflow-hidden">
<div class="flex gap-3"> <div class="flex items-center gap-3 px-5 pt-4 pb-2">
<div class="size-9 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5"> <div class="size-10 rounded-full bg-white/10 flex items-center justify-center shrink-0">
<img v-if="user?.avatar" :src="user.avatar" class="size-9 rounded-full object-cover" alt="" /> <img v-if="user?.avatar" :src="user.avatar" class="size-10 rounded-full object-cover" alt="" />
<User v-else class="size-5 text-slate-500" /> <AppIcon name="user" v-else class="size-5 text-slate-500" />
</div> </div>
<div class="flex-1 min-w-0"> <span class="text-sm font-semibold text-white">{{ user.name || user.email?.split('@')[0] }}</span>
<textarea v-model="commentContent" @input="triggerAutoSaveDraft" placeholder="Viết bình luận..." rows="3" </div>
<div class="px-5">
<textarea v-model="commentContent" @input="triggerAutoSaveDraft" placeholder="Viết bình luận" rows="3"
maxlength="1000" maxlength="1000"
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition" /> class="w-full rounded-xl bg-[#0f1118] border border-white/5 px-4 py-3 text-sm text-white placeholder:text-slate-600 focus:border-white/10 focus:outline-none resize-none transition" />
<div class="flex items-center justify-between mt-2"> </div>
<div class="flex items-center gap-2"> <div class="flex items-center justify-between px-5 py-4">
<span class="text-xs text-slate-500">Tiết lộ</span> <div class="flex items-center gap-5">
<div class="flex items-center gap-2.5">
<button type="button" @click="isContentHidden = !isContentHidden" <button type="button" @click="isContentHidden = !isContentHidden"
class="relative w-9 h-5 rounded-full transition-colors" class="relative w-10 h-6 rounded-full transition-colors"
:class="isContentHidden ? 'bg-cinek-500' : 'bg-white/20'"> :class="isContentHidden ? 'bg-[#FFD166]' : 'bg-white/15'">
<span class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform" <span class="absolute top-1 size-4 rounded-full bg-white shadow transition-transform"
:class="isContentHidden ? 'translate-x-4' : 'translate-x-0'" /> :class="isContentHidden ? 'left-5' : 'left-1'" />
</button> </button>
<span class="text-xs" :class="isContentHidden ? 'text-cinek-400' : 'text-slate-500'">Không tiết lộ</span> <span class="text-xs font-medium" :class="isContentHidden ? 'text-[#FFD166]' : 'text-white/60'">Không tiết
lộ</span>
</div> </div>
<div class="flex items-center gap-3"> <button type="button" class="flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 transition">
<span class="text-xs text-slate-600">{{ commentContent.length }} / 1000</span> <AppIcon name="image" class="size-4" /><span class="text-white/50">GIF</span>
<button type="button" :disabled="!commentContent.trim() || isCommentSubmitting" </button>
@click="handleSubmitComment" </div>
class="flex items-center gap-1.5 rounded-lg bg-cinek-500 px-4 py-1.5 text-xs font-bold text-slate-950 hover:bg-cinek-400 disabled:cursor-not-allowed disabled:opacity-40 transition"> <div class="flex items-center gap-4">
<Loader2 v-if="isCommentSubmitting" class="size-3.5 animate-spin" /> <span class="text-xs text-white/30">{{ commentContent.length }} / 1000</span>
<Send class="size-3.5" v-else /> <button type="button" :disabled="!commentContent.trim() || isCommentSubmitting" @click="handleSubmitComment"
class="flex items-center gap-2 text-sm font-semibold transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="commentContent.trim() ? 'text-[#FFD166] hover:text-[#FFC845]' : 'text-white/30'">
<span>Gửi</span> <span>Gửi</span>
<AppIcon name="loader" v-if="isCommentSubmitting" class="size-4 animate-spin" />
<AppIcon name="send" class="size-4" v-else />
</button> </button>
</div> </div>
</div> </div>
<div v-if="hasDraft" class="mt-2 flex items-center gap-2"> <div v-if="hasDraft" class="px-5 pb-3 flex items-center gap-2">
<span class="text-xs text-yellow-400/70 flex items-center gap-1"> <span class="text-xs text-yellow-400/70 flex items-center gap-1">
<span class="size-1.5 rounded-full bg-yellow-400/50 inline-block" /> <span class="size-1.5 rounded-full bg-yellow-400/50 inline-block" />
nháp đã lưu nháp đã lưu
@@ -398,8 +405,6 @@ watch(() => props.slug, () => {
class="text-xs text-slate-500 hover:text-red-400 transition">Xóa nháp</button> class="text-xs text-slate-500 hover:text-red-400 transition">Xóa nháp</button>
</div> </div>
</div> </div>
</div>
</div>
<div v-if="isCommentsLoading" class="space-y-4"> <div v-if="isCommentsLoading" class="space-y-4">
<div v-for="i in 2" :key="i" class="flex items-start gap-3 p-4 bg-[#13151f] rounded-xl animate-pulse"> <div v-for="i in 2" :key="i" class="flex items-start gap-3 p-4 bg-[#13151f] rounded-xl animate-pulse">
@@ -417,33 +422,33 @@ watch(() => props.slug, () => {
<div class="transition" <div class="transition"
:class="comment.pinned ? 'rounded-xl p-4 bg-yellow-500/5 border border-yellow-500/20' : 'py-4 border-b border-white/5'"> :class="comment.pinned ? 'rounded-xl p-4 bg-yellow-500/5 border border-yellow-500/20' : 'py-4 border-b border-white/5'">
<div v-if="comment.pinned" class="flex items-center gap-1.5 mb-3 text-xs text-yellow-400/80 font-medium"> <div v-if="comment.pinned" class="flex items-center gap-1.5 mb-3 text-xs text-yellow-400/80 font-medium">
<Pin class="size-3" /> Ghim bởi Admin <AppIcon name="pin" class="size-3" /> Ghim bởi Admin
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<div class="shrink-0 relative"> <div class="shrink-0 relative">
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center" <div class="size-10 rounded-full bg-white/10 flex items-center justify-center"
:class="comment.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''"> :class="comment.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''">
<img v-if="comment.userAvatar" :src="comment.userAvatar" <img v-if="comment.userAvatar" :src="comment.userAvatar" class="size-10 rounded-full object-cover"
class="size-10 rounded-full object-cover" alt="" /> alt="" />
<User v-else class="size-5 text-slate-500" /> <AppIcon name="user" v-else class="size-5 text-slate-500" />
</div> </div>
<div v-if="comment.userRole === 'admin'" <div v-if="comment.userRole === 'admin'"
class="absolute -top-1 -left-1 size-4 bg-cinek-500 rounded-full flex items-center justify-center"> class="absolute -top-0.5 -left-0.5 size-3.5 bg-yellow-400 rounded-full flex items-center justify-center ring-2 ring-[#0d0f17]">
<Crown class="size-2.5 text-slate-950" /> <svg class="size-2 text-slate-950" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z"/></svg>
</div> </div>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1"> <div class="flex flex-wrap items-center gap-2 mb-1">
<span v-if="comment.pinned" <span v-if="comment.pinned"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-cinek-500/20 text-cinek-400"> class="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-cinek-500/20 text-cinek-400">
<Pin class="size-3" />Đã ghim <AppIcon name="pin" class="size-3" />Đã ghim
</span> </span>
<span v-if="comment.userRole === 'admin'" <span v-if="comment.userRole === 'admin'"
class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span> class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span>
<span class="text-sm font-semibold text-white">{{ comment.userName }}</span> <span class="text-sm font-semibold text-white">{{ comment.userName }}</span>
<Venus v-if="comment.userGender === 'female'" class="size-3.5 text-pink-400" /> <AppIcon name="venus" v-if="comment.userGender === 'female'" class="size-3.5 text-pink-400" />
<Mars v-if="comment.userGender === 'male'" class="size-3.5 text-blue-400" /> <AppIcon name="mars" v-if="comment.userGender === 'male'" class="size-3.5 text-blue-400" />
<Minus v-if="comment.userGender === 'other'" class="size-3.5 text-purple-400" /> <AppIcon name="minus" v-if="comment.userGender === 'other'" class="size-3.5 text-purple-400" />
<span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span> <span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span>
</div> </div>
<div class="relative"> <div class="relative">
@@ -457,26 +462,26 @@ watch(() => props.slug, () => {
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)" <button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)"
class="flex items-center gap-1 text-xs" class="flex items-center gap-1 text-xs"
:class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'"> :class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'">
<ThumbsUp class="size-3.5" /><span v-if="comment.likeCount">{{ comment.likeCount }}</span> <AppIcon name="thumbs-up" class="size-3.5" /><span v-if="comment.likeCount">{{ comment.likeCount }}</span>
</button> </button>
<button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)" <button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)"
class="flex items-center gap-1 text-xs" class="flex items-center gap-1 text-xs"
:class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'"> :class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'">
<ThumbsDown class="size-3.5" /><span v-if="comment.dislikeCount">{{ comment.dislikeCount }}</span> <AppIcon name="thumbs-down" class="size-3.5" /><span v-if="comment.dislikeCount">{{ comment.dislikeCount }}</span>
</button> </button>
<button type="button" @click="startReply(comment.id)" <button type="button" @click="startReply(comment.id)"
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300"> class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
<CornerDownLeft class="size-3.5" /><span>Trả lời</span> <AppIcon name="corner-down-left" class="size-3.5" /><span>Trả lời</span>
</button> </button>
<button v-if="user && user.role === 'admin'" type="button" @click="handleTogglePin(comment.id)" <button v-if="user && user.role === 'admin'" type="button" @click="handleTogglePin(comment.id)"
class="flex items-center gap-1.5 text-xs" class="flex items-center gap-1.5 text-xs"
:class="comment.pinned ? 'text-cinek-400 hover:text-cinek-300' : 'text-slate-600 hover:text-slate-400'"> :class="comment.pinned ? 'text-cinek-400 hover:text-cinek-300' : 'text-slate-600 hover:text-slate-400'">
<Pin class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span> <AppIcon name="pin" class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span>
</button> </button>
<button v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button" <button v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button"
@click="handleDeleteComment(comment.id)" @click="handleDeleteComment(comment.id)"
class="flex items-center gap-1.5 text-xs text-slate-600 hover:text-red-400"> class="flex items-center gap-1.5 text-xs text-slate-600 hover:text-red-400">
<Trash2 class="size-3.5" /> <AppIcon name="trash" class="size-3.5" />
</button> </button>
</div> </div>
<div v-if="isReplyingTo === comment.id" class="mt-4"> <div v-if="isReplyingTo === comment.id" class="mt-4">
@@ -495,16 +500,21 @@ watch(() => props.slug, () => {
</button> </button>
<span class="text-xs text-slate-500">Tiết lộ</span> <span class="text-xs text-slate-500">Tiết lộ</span>
</div> </div>
<button type="button" class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition"> <button type="button"
<Image class="size-4" /><span>GIF</span> class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition">
<AppIcon name="image" class="size-4" /><span>GIF</span>
</button> </button>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs text-slate-600">{{ (replyContent[comment.id] || '').length }} / 1000</span> <span class="text-xs text-white/30">{{ (replyContent[comment.id] || '').length }} / 1000</span>
<button type="button" @click="cancelReply(comment.id)" <button type="button" @click="cancelReply(comment.id)"
class="px-3 py-1.5 rounded-lg text-xs text-slate-500 hover:text-white transition">Hủy</button> class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white transition">Hủy</button>
<button type="button" @click="submitReply(comment.id)" <button type="button" @click="submitReply(comment.id)"
class="px-4 py-1.5 rounded-lg bg-cinek-500 text-xs font-bold text-slate-950 hover:bg-cinek-400 transition">Gửi</button> class="flex items-center gap-2 text-sm font-semibold transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="(replyContent[comment.id] || '').trim() ? 'text-[#FFD166] hover:text-[#FFC845]' : 'text-white/30'">
<span>Gửi</span>
<AppIcon name="send" class="size-4" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -512,8 +522,8 @@ watch(() => props.slug, () => {
<div v-if="comment.replies?.length" class="mt-4"> <div v-if="comment.replies?.length" class="mt-4">
<button @click="toggleExpand(comment.id)" <button @click="toggleExpand(comment.id)"
class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2"> class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
<ChevronUp v-if="expandedComments.has(comment.id)" class="size-3" /> <AppIcon name="chevron-up" v-if="expandedComments.has(comment.id)" class="size-3" />
<ChevronDown v-else class="size-3" /> <AppIcon name="chevron-down" v-else class="size-3" />
<span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị <span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị
${countAllReplies(comment.replies)} phản hồi` }}</span> ${countAllReplies(comment.replies)} phản hồi` }}</span>
</button> </button>
@@ -521,10 +531,9 @@ watch(() => props.slug, () => {
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'"> :class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
<div class="space-y-3"> <div class="space-y-3">
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id" <CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
:is-reply-anonymous="isReplyAnonymous" :is-submitting-reply="isSubmittingReply" :is-submitting-reply="isSubmittingReply" @start-reply="startReply" @cancel-reply="cancelReply"
@start-reply="startReply" @cancel-reply="cancelReply" @submit-reply="submitReply" @submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
@vote="handleVote" @delete="handleDeleteComment" />
</div> </div>
</div> </div>
</div> </div>
@@ -539,7 +548,7 @@ watch(() => props.slug, () => {
<button type="button" @click="loadMoreComments" <button type="button" @click="loadMoreComments"
class="flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-5 py-2.5 text-sm font-medium text-slate-300 hover:bg-white/10 hover:text-white transition disabled:opacity-50 disabled:cursor-not-allowed" class="flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-5 py-2.5 text-sm font-medium text-slate-300 hover:bg-white/10 hover:text-white transition disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isCommentsLoadingMore"> :disabled="isCommentsLoadingMore">
<Loader2 v-if="isCommentsLoadingMore" class="size-4 animate-spin" /> <AppIcon name="loader" v-if="isCommentsLoadingMore" class="size-4 animate-spin" />
<span>{{ isCommentsLoadingMore ? 'Đang tải...' : `Xem thêm bình luận (${comments.length}/${commentTotal})` <span>{{ isCommentsLoadingMore ? 'Đang tải...' : `Xem thêm bình luận (${comments.length}/${commentTotal})`
}}</span> }}</span>
</button> </button>
@@ -551,7 +560,7 @@ watch(() => props.slug, () => {
<div v-if="!comments.length && !isCommentsLoading" <div v-if="!comments.length && !isCommentsLoading"
class="flex flex-col items-center justify-center py-12 text-center"> class="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare class="size-10 text-white/10 mb-3" /> <AppIcon name="message-square" class="size-10 text-white/10 mb-3" />
<p class="text-slate-400 text-sm">Chưa bình luận nào.</p> <p class="text-slate-400 text-sm">Chưa bình luận nào.</p>
</div> </div>
</div> </div>
+5 -7
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDown, ChevronUp, Circle, Heart, Play } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
movie: { movie: {
source: string source: string
@@ -139,14 +137,14 @@ onBeforeUnmount(() => {
<div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2"> <div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2">
<span <span
class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-white"> class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-white">
<Play class="size-4 fill-current" /> <AppIcon name="play" class="size-4 fill-current" />
Xem ngay Xem ngay
</span> </span>
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white"> <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" /> <AppIcon name="heart" class="size-5 fill-current" />
</span> </span>
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white"> <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" /> <AppIcon name="circle" class="size-5 fill-current" />
</span> </span>
</div> </div>
@@ -171,10 +169,10 @@ onBeforeUnmount(() => {
class="mt-1 inline-flex items-center gap-0.5 text-[10px] font-semibold text-yellow-300 transition hover:text-yellow-200" class="mt-1 inline-flex items-center gap-0.5 text-[10px] font-semibold text-yellow-300 transition hover:text-yellow-200"
@click.stop="isDescriptionExpanded = !isDescriptionExpanded"> @click.stop="isDescriptionExpanded = !isDescriptionExpanded">
<template v-if="isDescriptionExpanded"> <template v-if="isDescriptionExpanded">
Thu gọn <ChevronUp class="size-3" /> Thu gọn <AppIcon name="chevron-up" class="size-3" />
</template> </template>
<template v-else> <template v-else>
Xem thêm <ChevronDown class="size-3" /> Xem thêm <AppIcon name="chevron-down" class="size-3" />
</template> </template>
</button> </button>
</div> </div>
+1 -3
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowUp } from 'lucide-vue-next'
const isVisible = ref(false) const isVisible = ref(false)
function updateVisibility() { function updateVisibility() {
@@ -37,7 +35,7 @@ onBeforeUnmount(() => {
aria-label="Lên đầu trang" aria-label="Lên đầu trang"
@click="scrollToTop" @click="scrollToTop"
> >
<ArrowUp class="size-6" /> <AppIcon name="arrow-up" class="size-6" />
</button> </button>
</Transition> </Transition>
</template> </template>
-2
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft, Home, Search } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
error: { error: {
statusCode: number statusCode: number
+10 -20
View File
@@ -1,14 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import {
Film,
Home,
LogOut,
Menu,
Settings,
Users,
X,
} from 'lucide-vue-next'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
@@ -18,10 +8,10 @@ const { data: user } = await useFetch('/api/auth/me', {
}) })
const navItems = [ const navItems = [
{ label: 'Dashboard', icon: Home, to: '/admin' }, { label: 'Dashboard', icon: 'home' as const, to: '/admin' },
{ label: 'Phim', icon: Film, to: '/admin/phim' }, { label: 'Phim', icon: 'film' as const, to: '/admin/phim' },
{ label: 'Thành viên', icon: Users, to: '/admin/thanh-vien' }, { label: 'Thành viên', icon: 'users' as const, to: '/admin/thanh-vien' },
{ label: 'Cài đặt', icon: Settings, to: '/admin/cai-dat' }, { label: 'Cài đặt', icon: 'settings' as const, to: '/admin/cai-dat' },
] ]
function isActive(to: string) { function isActive(to: string) {
@@ -50,13 +40,13 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
<div class="flex h-16 items-center justify-between border-b border-white/10 px-4"> <div class="flex h-16 items-center justify-between border-b border-white/10 px-4">
<NuxtLink to="/admin" class="flex items-center gap-2"> <NuxtLink to="/admin" class="flex items-center gap-2">
<div class="grid size-8 place-items-center rounded-lg bg-yellow-400"> <div class="grid size-8 place-items-center rounded-lg bg-yellow-400">
<Film class="size-4 text-slate-950" /> <AppIcon name="film" class="size-4 text-slate-950" />
</div> </div>
<span class="text-lg font-black text-white">CineK Admin</span> <span class="text-lg font-black text-white">CineK Admin</span>
</NuxtLink> </NuxtLink>
<button type="button" class="grid size-8 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden" <button type="button" class="grid size-8 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden"
@click="closeSidebar"> @click="closeSidebar">
<X class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
</div> </div>
@@ -66,7 +56,7 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
:class="isActive(item.to) :class="isActive(item.to)
? 'bg-yellow-400/10 text-yellow-400' ? 'bg-yellow-400/10 text-yellow-400'
: 'text-slate-300 hover:bg-white/5 hover:text-white'"> : 'text-slate-300 hover:bg-white/5 hover:text-white'">
<component :is="item.icon" class="size-5" /> <AppIcon :name="item.icon" class="size-5" />
{{ item.label }} {{ item.label }}
</NuxtLink> </NuxtLink>
</nav> </nav>
@@ -74,13 +64,13 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
<div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-white/10 p-4"> <div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-white/10 p-4">
<NuxtLink to="/" <NuxtLink to="/"
class="flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white"> class="flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white">
<Home class="size-5" /> <AppIcon name="home" class="size-5" />
Về trang chủ Về trang chủ
</NuxtLink> </NuxtLink>
<button type="button" <button type="button"
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-400/10" class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-400/10"
@click="handleLogout"> @click="handleLogout">
<LogOut class="size-5" /> <AppIcon name="log-out" class="size-5" />
Đăng xuất Đăng xuất
</button> </button>
</div> </div>
@@ -91,7 +81,7 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-white/10 bg-slate-950/80 px-4 backdrop-blur-xl lg:px-6"> class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-white/10 bg-slate-950/80 px-4 backdrop-blur-xl lg:px-6">
<button type="button" class="grid size-10 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden" <button type="button" class="grid size-10 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden"
@click="sidebarOpen = true"> @click="sidebarOpen = true">
<Menu class="size-5" /> <AppIcon name="menu" class="size-5" />
</button> </button>
<div class="ml-auto flex items-center gap-3"> <div class="ml-auto flex items-center gap-3">
+348
View File
@@ -0,0 +1,348 @@
import {
faArrowLeft,
faArrowUp,
faArrowTurnDown,
faArrowTrendUp,
faArrowsRotate,
faBackward,
faBackwardFast,
faBars,
faBolt,
faBug,
faCalendarDays,
faCamera,
faChartColumn,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCirclePlay,
faCircleQuestion,
faClock,
faClosedCaptioning,
faComment,
faCommentDots,
faEnvelope,
faExpand,
faEye,
faEyeSlash,
faFilm,
faFloppyDisk,
faFastForward,
faForward,
faForwardStep,
faGear,
faGlobe,
faHeart,
faHome,
faImage,
faKey,
faLanguage,
faLayerGroup,
faLock,
faMagnifyingGlass,
faMars,
faMinus,
faMusic,
faPause,
faPen,
faPlay,
faPlus,
faRectangleList,
faRightFromBracket,
faRightToBracket,
faServer,
faShare,
faShareNodes,
faShield,
faSpinner,
faStar,
faThumbtack,
faThumbsDown,
faThumbsUp,
faToggleOff,
faToggleOn,
faTrashCan,
faTriangleExclamation,
faTv,
faUser,
faUserCircle,
faUsers,
faVenus,
faVideo,
faVolumeHigh,
faVolumeXmark,
faXmark,
faPaperPlane,
faCrown,
faListOl,
faFont,
faTrash,
faCommentSlash,
faRotate,
faPenToSquare,
} from '@fortawesome/free-solid-svg-icons'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
faArrowLeft,
faArrowUp,
faArrowTurnDown,
faArrowTrendUp,
faArrowsRotate,
faBackward,
faBackwardFast,
faBars,
faBolt,
faBug,
faCalendarDays,
faCamera,
faChartColumn,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCirclePlay,
faCircleQuestion,
faClock,
faClosedCaptioning,
faComment,
faCommentDots,
faEnvelope,
faExpand,
faEye,
faEyeSlash,
faFilm,
faFloppyDisk,
faFastForward,
faForward,
faForwardStep,
faGear,
faGlobe,
faHeart,
faHome,
faImage,
faKey,
faLanguage,
faLayerGroup,
faLock,
faMagnifyingGlass,
faMars,
faMinus,
faMusic,
faPause,
faPen,
faPlay,
faPlus,
faRectangleList,
faRightFromBracket,
faRightToBracket,
faServer,
faShare,
faShareNodes,
faShield,
faSpinner,
faStar,
faThumbtack,
faThumbsDown,
faThumbsUp,
faToggleOff,
faToggleOn,
faTrashCan,
faTriangleExclamation,
faTv,
faUser,
faUserCircle,
faUsers,
faVenus,
faVideo,
faVolumeHigh,
faVolumeXmark,
faXmark,
faPaperPlane,
faCrown,
faListOl,
faFont,
faTrash,
faCommentSlash,
faRotate,
faPenToSquare,
)
export type IconName =
| 'alert-triangle'
| 'arrow-left'
| 'arrow-up'
| 'badge-check'
| 'bar-chart'
| 'bug'
| 'calendar-days'
| 'camera'
| 'captions'
| 'check'
| 'chevron-down'
| 'chevron-left'
| 'chevron-right'
| 'chevron-up'
| 'circle'
| 'circle-help'
| 'circle-play'
| 'clock'
| 'corner-down-left'
| 'crown'
| 'edit'
| 'eye'
| 'eye-off'
| 'fast-forward'
| 'film'
| 'globe'
| 'heart'
| 'history'
| 'home'
| 'image'
| 'info'
| 'key'
| 'languages'
| 'layers'
| 'loader'
| 'lock'
| 'log-in'
| 'log-out'
| 'mail'
| 'mars'
| 'maximize'
| 'menu'
| 'message-circle'
| 'message-square'
| 'minus'
| 'music'
| 'pause'
| 'pencil'
| 'picture-in-picture'
| 'pin'
| 'play'
| 'plus'
| 'refresh'
| 'rewind'
| 'save'
| 'search'
| 'send'
| 'server'
| 'settings'
| 'share'
| 'shield'
| 'skip-forward'
| 'star'
| 'thumbs-down'
| 'thumbs-up'
| 'toggle-left'
| 'toggle-right'
| 'trash'
| 'trending-up'
| 'tv'
| 'type'
| 'user'
| 'user-round'
| 'users'
| 'venus'
| 'video'
| 'volume'
| 'volume-x'
| 'x'
| 'zap'
const iconMap: Record<IconName, [string, string]> = {
'alert-triangle': ['fas', 'triangle-exclamation'],
'arrow-left': ['fas', 'arrow-left'],
'arrow-up': ['fas', 'arrow-up'],
'badge-check': ['fas', 'circle-check'],
'bar-chart': ['fas', 'chart-column'],
'bug': ['fas', 'bug'],
'calendar-days': ['fas', 'calendar-days'],
'camera': ['fas', 'camera'],
'captions': ['fas', 'closed-captioning'],
'check': ['fas', 'check'],
'chevron-down': ['fas', 'chevron-down'],
'chevron-left': ['fas', 'chevron-left'],
'chevron-right': ['fas', 'chevron-right'],
'chevron-up': ['fas', 'chevron-up'],
'circle': ['fas', 'circle'],
'circle-help': ['fas', 'circle-question'],
'circle-play': ['fas', 'circle-play'],
'clock': ['fas', 'clock'],
'corner-down-left': ['fas', 'arrow-turn-down'],
'crown': ['fas', 'crown'],
'edit': ['fas', 'pen-to-square'],
'eye': ['fas', 'eye'],
'eye-off': ['fas', 'eye-slash'],
'fast-forward': ['fas', 'forward-fast'],
'film': ['fas', 'film'],
'globe': ['fas', 'globe'],
'heart': ['fas', 'heart'],
'history': ['fas', 'clock-rotate-left'],
'home': ['fas', 'home'],
'image': ['fas', 'image'],
'info': ['fas', 'circle-info'],
'key': ['fas', 'key'],
'languages': ['fas', 'language'],
'layers': ['fas', 'layer-group'],
'loader': ['fas', 'spinner'],
'lock': ['fas', 'lock'],
'log-in': ['fas', 'right-to-bracket'],
'log-out': ['fas', 'right-from-bracket'],
'mail': ['fas', 'envelope'],
'mars': ['fas', 'mars'],
'maximize': ['fas', 'expand'],
'menu': ['fas', 'bars'],
'message-circle': ['fas', 'comment-dots'],
'message-square': ['fas', 'comment'],
'minus': ['fas', 'minus'],
'music': ['fas', 'music'],
'pause': ['fas', 'pause'],
'pencil': ['fas', 'pen'],
'picture-in-picture': ['fas', 'rectangle-list'],
'pin': ['fas', 'thumbtack'],
'play': ['fas', 'play'],
'plus': ['fas', 'plus'],
'refresh': ['fas', 'arrows-rotate'],
'rewind': ['fas', 'backward-fast'],
'save': ['fas', 'floppy-disk'],
'search': ['fas', 'magnifying-glass'],
'send': ['fas', 'paper-plane'],
'server': ['fas', 'server'],
'settings': ['fas', 'gear'],
'share': ['fas', 'share-nodes'],
'shield': ['fas', 'shield'],
'skip-forward': ['fas', 'forward-step'],
'star': ['fas', 'star'],
'thumbs-down': ['fas', 'thumbs-down'],
'thumbs-up': ['fas', 'thumbs-up'],
'toggle-left': ['fas', 'toggle-off'],
'toggle-right': ['fas', 'toggle-on'],
'trash': ['fas', 'trash-can'],
'trending-up': ['fas', 'arrow-trend-up'],
'tv': ['fas', 'tv'],
'type': ['fas', 'font'],
'user': ['fas', 'user'],
'user-round': ['fas', 'user-circle'],
'users': ['fas', 'users'],
'venus': ['fas', 'venus'],
'video': ['fas', 'video'],
'volume': ['fas', 'volume-high'],
'volume-x': ['fas', 'volume-xmark'],
'x': ['fas', 'xmark'],
'zap': ['fas', 'bolt'],
}
export function getIcon(name: IconName) {
return iconMap[name]
}
+2 -4
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Save, Settings as SettingsIcon } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
}) })
@@ -26,7 +24,7 @@ const allowRegistration = ref(true)
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-5 flex items-center gap-3"> <div class="mb-5 flex items-center gap-3">
<div class="grid size-10 place-items-center rounded-lg bg-yellow-400/10"> <div class="grid size-10 place-items-center rounded-lg bg-yellow-400/10">
<SettingsIcon class="size-4 text-yellow-400" /> <AppIcon name="settings" class="size-4 text-yellow-400" />
</div> </div>
<div> <div>
<h2 class="text-lg font-black text-white">Cài đặt chung</h2> <h2 class="text-lg font-black text-white">Cài đặt chung</h2>
@@ -74,7 +72,7 @@ const allowRegistration = ref(true)
<div class="flex justify-end"> <div class="flex justify-end">
<button type="button" <button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300"> class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300">
<Save class="size-4" /> <AppIcon name="save" class="size-4" />
Lưu thay đổi Lưu thay đổi
</button> </button>
</div> </div>
+7 -9
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { BarChart3, Film, Play, RefreshCw, TrendingUp, Tv, Users, X } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
}) })
@@ -77,7 +75,7 @@ function barWidth(views: number) {
<button type="button" <button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300" class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300"
@click="syncOpen = true"> @click="syncOpen = true">
<RefreshCw class="size-4" /> <AppIcon name="refresh" class="size-4" />
Đồng bộ phim Đồng bộ phim
</button> </button>
</div> </div>
@@ -91,7 +89,7 @@ function barWidth(views: number) {
<p class="mt-2 text-3xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p> <p class="mt-2 text-3xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
</div> </div>
<div class="grid size-11 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400"> <div class="grid size-11 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400">
<Film class="size-5" /> <AppIcon name="film" class="size-5" />
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +101,7 @@ function barWidth(views: number) {
<p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p> <p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p>
</div> </div>
<div class="grid size-11 place-items-center rounded-xl bg-green-400/10 text-green-400"> <div class="grid size-11 place-items-center rounded-xl bg-green-400/10 text-green-400">
<TrendingUp class="size-5" /> <AppIcon name="trending-up" class="size-5" />
</div> </div>
</div> </div>
</div> </div>
@@ -115,7 +113,7 @@ function barWidth(views: number) {
<p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p> <p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p>
</div> </div>
<div class="grid size-11 place-items-center rounded-xl bg-blue-400/10 text-blue-400"> <div class="grid size-11 place-items-center rounded-xl bg-blue-400/10 text-blue-400">
<Tv class="size-5" /> <AppIcon name="tv" class="size-5" />
</div> </div>
</div> </div>
</div> </div>
@@ -127,7 +125,7 @@ function barWidth(views: number) {
<p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p> <p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p>
</div> </div>
<div class="grid size-11 place-items-center rounded-xl bg-purple-400/10 text-purple-400"> <div class="grid size-11 place-items-center rounded-xl bg-purple-400/10 text-purple-400">
<Play class="size-5" /> <AppIcon name="play" class="size-5" />
</div> </div>
</div> </div>
</div> </div>
@@ -315,7 +313,7 @@ function barWidth(views: number) {
<button type="button" <button type="button"
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10" class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10"
@click="syncOpen = false"> @click="syncOpen = false">
<X class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2> <h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
@@ -357,7 +355,7 @@ function barWidth(views: number) {
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50" class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="syncing || !Object.values(syncSources).some(Boolean)" :disabled="syncing || !Object.values(syncSources).some(Boolean)"
@click="handleSync"> @click="handleSync">
<RefreshCw class="size-4" :class="syncing ? 'animate-spin' : ''" /> <AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
{{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }} {{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }}
</button> </button>
</div> </div>
+5 -7
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Eye, EyeOff, LockKeyhole, LogIn, Mail } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
layout: false, layout: false,
middleware: [], middleware: [],
@@ -65,7 +63,7 @@ async function handleLogin() {
<div> <div>
<label class="mb-2 block text-sm font-semibold text-white">Email</label> <label class="mb-2 block text-sm font-semibold text-white">Email</label>
<div class="relative"> <div class="relative">
<Mail class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <AppIcon name="mail" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="email" type="email" required placeholder="admin@cinek.vn" <input v-model="email" type="email" required placeholder="admin@cinek.vn"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50"> class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
</div> </div>
@@ -74,15 +72,15 @@ async function handleLogin() {
<div> <div>
<label class="mb-2 block text-sm font-semibold text-white">Mật khẩu</label> <label class="mb-2 block text-sm font-semibold text-white">Mật khẩu</label>
<div class="relative"> <div class="relative">
<LockKeyhole class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <AppIcon name="lock" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6" <input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6"
placeholder="Nhập mật khẩu" placeholder="Nhập mật khẩu"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-10 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50"> class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-10 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<button type="button" <button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white" class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white"
@click="showPassword = !showPassword"> @click="showPassword = !showPassword">
<EyeOff v-if="showPassword" class="size-4" /> <AppIcon name="eye-off" v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" /> <AppIcon name="eye" v-else class="size-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -91,7 +89,7 @@ async function handleLogin() {
<button type="submit" <button type="submit"
class="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-yellow-400 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50" class="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-yellow-400 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="loading"> :disabled="loading">
<LogIn class="size-4" /> <AppIcon name="log-in" class="size-4" />
{{ loading ? 'Đang đăng nhập...' : 'Đăng nhập' }} {{ loading ? 'Đang đăng nhập...' : 'Đăng nhập' }}
</button> </button>
+9 -11
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft, Check, Film, Image, Plus, Save, Trash2, Type } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
}) })
@@ -74,7 +72,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<div> <div>
<NuxtLink to="/admin/phim" <NuxtLink to="/admin/phim"
class="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-400 transition hover:text-white"> class="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-400 transition hover:text-white">
<ArrowLeft class="size-4" /> <AppIcon name="arrow-left" class="size-4" />
Quay lại danh sách phim Quay lại danh sách phim
</NuxtLink> </NuxtLink>
@@ -93,7 +91,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<button type="button" <button type="button"
class="inline-flex h-10 shrink-0 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50" class="inline-flex h-10 shrink-0 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="saving" @click="handleSave"> :disabled="saving" @click="handleSave">
<Save class="size-4" /> <AppIcon name="save" class="size-4" />
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }} {{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }}
</button> </button>
</div> </div>
@@ -101,7 +99,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<Transition name="fade"> <Transition name="fade">
<div v-if="saved" <div v-if="saved"
class="mb-4 flex items-center gap-2 rounded-lg border border-green-400/20 bg-green-400/10 px-4 py-3 text-sm font-semibold text-green-400"> class="mb-4 flex items-center gap-2 rounded-lg border border-green-400/20 bg-green-400/10 px-4 py-3 text-sm font-semibold text-green-400">
<Check class="size-4" /> <AppIcon name="check" class="size-4" />
Đã lưu thành công Đã lưu thành công
</div> </div>
</Transition> </Transition>
@@ -111,7 +109,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center gap-3"> <div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-blue-400/10"> <div class="grid size-9 place-items-center rounded-lg bg-blue-400/10">
<Type class="size-4 text-blue-400" /> <AppIcon name="type" class="size-4 text-blue-400" />
</div> </div>
<div> <div>
<h2 class="text-base font-black text-white"> tả phim</h2> <h2 class="text-base font-black text-white"> tả phim</h2>
@@ -130,7 +128,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center gap-3"> <div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-amber-400/10"> <div class="grid size-9 place-items-center rounded-lg bg-amber-400/10">
<Film class="size-4 text-amber-400" /> <AppIcon name="film" class="size-4 text-amber-400" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h2 class="text-base font-black text-white">Tập phim tuỳ chỉnh</h2> <h2 class="text-base font-black text-white">Tập phim tuỳ chỉnh</h2>
@@ -139,7 +137,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<button type="button" <button type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/10 px-3 text-xs font-semibold text-white transition hover:bg-white/20" class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/10 px-3 text-xs font-semibold text-white transition hover:bg-white/20"
@click="addEpisode"> @click="addEpisode">
<Plus class="size-3.5" /> <AppIcon name="plus" class="size-3.5" />
Thêm tập Thêm tập
</button> </button>
</div> </div>
@@ -152,7 +150,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<button type="button" <button type="button"
class="grid size-7 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10" class="grid size-7 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeEpisode(index)"> @click="removeEpisode(index)">
<Trash2 class="size-3.5" /> <AppIcon name="trash" class="size-3.5" />
</button> </button>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -173,7 +171,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center gap-3"> <div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-purple-400/10"> <div class="grid size-9 place-items-center rounded-lg bg-purple-400/10">
<Image class="size-4 text-purple-400" /> <AppIcon name="image" class="size-4 text-purple-400" />
</div> </div>
<div> <div>
<h2 class="text-base font-black text-white">Ảnh poster</h2> <h2 class="text-base font-black text-white">Ảnh poster</h2>
@@ -196,7 +194,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center gap-3"> <div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-pink-400/10"> <div class="grid size-9 place-items-center rounded-lg bg-pink-400/10">
<Image class="size-4 text-pink-400" /> <AppIcon name="image" class="size-4 text-pink-400" />
</div> </div>
<div> <div>
<h2 class="text-base font-black text-white">Ảnh thumbnail</h2> <h2 class="text-base font-black text-white">Ảnh thumbnail</h2>
+7 -9
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Pencil, RefreshCw, Search, Trash2, X } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
}) })
@@ -104,13 +102,13 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<button type="button" <button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-black text-red-400 transition hover:bg-red-400/20" class="inline-flex h-10 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-black text-red-400 transition hover:bg-red-400/20"
@click="deleteConfirmOpen = true"> @click="deleteConfirmOpen = true">
<Trash2 class="size-4" /> <AppIcon name="trash" class="size-4" />
Xoá tất cả Xoá tất cả
</button> </button>
<button type="button" <button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50" class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
@click="syncOpen = true"> @click="syncOpen = true">
<RefreshCw class="size-4" /> <AppIcon name="refresh" class="size-4" />
Đồng bộ từ API Đồng bộ từ API
</button> </button>
</div> </div>
@@ -119,7 +117,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<div class="rounded-xl border border-white/10 bg-slate-900/50"> <div class="rounded-xl border border-white/10 bg-slate-900/50">
<div class="flex flex-col gap-3 border-b border-white/10 p-4 sm:flex-row sm:items-center"> <div class="flex flex-col gap-3 border-b border-white/10 p-4 sm:flex-row sm:items-center">
<div class="relative flex-1"> <div class="relative flex-1">
<Search class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <AppIcon name="search" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..." <input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..."
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50"> class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50">
</div> </div>
@@ -208,7 +206,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<NuxtLink :to="`/admin/phim/${movie.id}`" <NuxtLink :to="`/admin/phim/${movie.id}`"
class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/10 hover:text-white" class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/10 hover:text-white"
title="Chỉnh sửa"> title="Chỉnh sửa">
<Pencil class="size-4" /> <AppIcon name="pencil" class="size-4" />
</NuxtLink> </NuxtLink>
<AdminToggle :model-value="movie.active" <AdminToggle :model-value="movie.active"
@update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" /> @update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" />
@@ -249,7 +247,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<button type="button" <button type="button"
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10" class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10"
@click="syncOpen = false"> @click="syncOpen = false">
<X class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2> <h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
@@ -293,7 +291,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<button type="button" <button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50" class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="syncing || !Object.values(syncSources).some(Boolean)" @click="handleSync"> :disabled="syncing || !Object.values(syncSources).some(Boolean)" @click="handleSync">
<RefreshCw class="size-4" :class="syncing ? 'animate-spin' : ''" /> <AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
{{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }} {{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }}
</button> </button>
</div> </div>
@@ -318,7 +316,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<button type="button" <button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-red-500 px-5 text-sm font-black text-white transition hover:bg-red-400 disabled:opacity-50" class="inline-flex h-10 items-center gap-2 rounded-lg bg-red-500 px-5 text-sm font-black text-white transition hover:bg-red-400 disabled:opacity-50"
:disabled="deleting" @click="handleDeleteAll"> :disabled="deleting" @click="handleDeleteAll">
<Trash2 class="size-4" /> <AppIcon name="trash" class="size-4" />
{{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }} {{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }}
</button> </button>
</div> </div>
+2 -4
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Edit, Mail, Search, Trash2, ToggleLeft, ToggleRight, UserRound } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
}) })
@@ -68,7 +66,7 @@ const members = computed(() => data.value?.items || [])
<div class="rounded-xl border border-white/10 bg-slate-900/50"> <div class="rounded-xl border border-white/10 bg-slate-900/50">
<div class="flex items-center gap-3 border-b border-white/10 p-4"> <div class="flex items-center gap-3 border-b border-white/10 p-4">
<div class="relative flex-1"> <div class="relative flex-1">
<Search class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <AppIcon name="search" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="searchInput" type="search" placeholder="Tìm kiếm thành viên..." <input v-model="searchInput" type="search" placeholder="Tìm kiếm thành viên..."
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50"> class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50">
</div> </div>
@@ -123,7 +121,7 @@ const members = computed(() => data.value?.items || [])
:disabled="currentUser?.id === member.id" :disabled="currentUser?.id === member.id"
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'" :title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
@click="deleteMember(member)"> @click="deleteMember(member)">
<Trash2 class="size-4" /> <AppIcon name="trash" class="size-4" />
</button> </button>
</div> </div>
</td> </td>
+7 -9
View File
@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Film, Globe2, Heart, Play, Shield, Zap } from 'lucide-vue-next'
const features = [ const features = [
{ icon: Play, title: 'Kho phim khổng lồ', desc: 'Tổng hợp phim Hàn Quốc từ nhiều nguồn, cập nhật liên tục mỗi ngày.' }, { icon: 'play' as const, title: 'Kho phim khổng lồ', desc: 'Tổng hợp phim Hàn Quốc từ nhiều nguồn, cập nhật liên tục mỗi ngày.' },
{ icon: Shield, title: 'Miễn phí 100%', desc: 'Xem phim hoàn toàn miễn phí, không cần trả bất kỳ khoản phí nào.' }, { icon: 'shield' as const, title: 'Miễn phí 100%', desc: 'Xem phim hoàn toàn miễn phí, không cần trả bất kỳ khoản phí nào.' },
{ icon: Zap, title: 'Tốc độ nhanh', desc: 'Trình phát video mượt mà, hỗ trợ đa server, không quảng cáo xâm phạm.' }, { icon: 'zap' as const, title: 'Tốc độ nhanh', desc: 'Trình phát video mượt mà, hỗ trợ đa server, không quảng cáo xâm phạm.' },
{ icon: Heart, title: 'Lưu tiến trình', desc: 'Tự động lưu tiến trình xem, danh sách yêu thích đồng bộ giữa các thiết bị.' }, { icon: 'heart' as const, title: 'Lưu tiến trình', desc: 'Tự động lưu tiến trình xem, danh sách yêu thích đồng bộ giữa các thiết bị.' },
{ icon: Globe2, title: 'Đa ngôn ngữ', desc: 'Hỗ trợ Vietsub, thuyết minh, lồng tiếng cho đa số phim.' }, { icon: 'globe' as const, title: 'Đa ngôn ngữ', desc: 'Hỗ trợ Vietsub, thuyết minh, lồng tiếng cho đa số phim.' },
{ icon: Film, title: 'Chất lượng cao', desc: 'Phim HD, Full HD, chất lượng hình ảnh và âm thanh tốt nhất.' }, { icon: 'film' as const, title: 'Chất lượng cao', desc: 'Phim HD, Full HD, chất lượng hình ảnh và âm thanh tốt nhất.' },
] ]
</script> </script>
@@ -29,7 +27,7 @@ const features = [
<div v-for="f in features" :key="f.title" <div v-for="f in features" :key="f.title"
class="rounded-xl border border-white/10 bg-[#191b24] p-6 transition hover:border-white/20"> class="rounded-xl border border-white/10 bg-[#191b24] p-6 transition hover:border-white/20">
<div class="grid size-12 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400 mb-4"> <div class="grid size-12 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400 mb-4">
<component :is="f.icon" class="size-6" /> <AppIcon :name="f.icon" class="size-6" />
</div> </div>
<h3 class="text-lg font-bold text-white mb-2">{{ f.title }}</h3> <h3 class="text-lg font-bold text-white mb-2">{{ f.title }}</h3>
<p class="text-sm text-slate-400 leading-relaxed">{{ f.desc }}</p> <p class="text-sm text-slate-400 leading-relaxed">{{ f.desc }}</p>
+1 -3
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDown } from 'lucide-vue-next'
const faqs = [ const faqs = [
{ {
category: 'Tổng quan', category: 'Tổng quan',
@@ -67,7 +65,7 @@ function toggle(category: string, index: number) {
class="w-full flex items-center justify-between px-5 py-4 text-left text-sm font-semibold text-white hover:bg-white/5 transition" class="w-full flex items-center justify-between px-5 py-4 text-left text-sm font-semibold text-white hover:bg-white/5 transition"
@click="toggle(section.category, index)"> @click="toggle(section.category, index)">
<span>{{ item.q }}</span> <span>{{ item.q }}</span>
<ChevronDown class="size-4 shrink-0 text-slate-400 transition-transform" <AppIcon name="chevron-down" class="size-4 shrink-0 text-slate-400 transition-transform"
:class="openItems[`${section.category}-${index}`] ? 'rotate-180' : ''" /> :class="openItems[`${section.category}-${index}`] ? 'rotate-180' : ''" />
</button> </button>
<Transition name="faq"> <Transition name="faq">
+4 -5
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronLeft, ChevronRight, Heart, Info, Play, Plus, Trash2 } from 'lucide-vue-next'
import type { WatchHistoryItem } from '~/composables/useWatchHistory' import type { WatchHistoryItem } from '~/composables/useWatchHistory'
const { data, pending, error } = useFetch('/api/movies', { const { data, pending, error } = useFetch('/api/movies', {
@@ -384,7 +383,7 @@ useHead({
<NuxtLink :to="movieLink(hero)" <NuxtLink :to="movieLink(hero)"
class="w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 text-[#0f1115] rounded-full flex items-center justify-center transition-transform hover:scale-105 shadow-[0_0_15px_rgba(254,207,89,0.5)] shrink-0 pointer-events-auto" class="w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 text-[#0f1115] rounded-full flex items-center justify-center transition-transform hover:scale-105 shadow-[0_0_15px_rgba(254,207,89,0.5)] shrink-0 pointer-events-auto"
style="background:linear-gradient(39deg, rgb(254, 207, 89), rgb(255, 241, 204))" aria-label="Xem ngay"> style="background:linear-gradient(39deg, rgb(254, 207, 89), rgb(255, 241, 204))" aria-label="Xem ngay">
<Play class="size-6 md:size-7 lg:size-8 fill-current" /> <AppIcon name="play" class="size-6 md:size-7 lg:size-8 fill-current" />
</NuxtLink> </NuxtLink>
<!-- Info + Add to List combo button --> <!-- Info + Add to List combo button -->
@@ -394,7 +393,7 @@ useHead({
<button type="button" <button type="button"
class="group/btn w-16 md:w-20 h-full flex items-center justify-center transition-all hover:bg-white/10" class="group/btn w-16 md:w-20 h-full flex items-center justify-center transition-all hover:bg-white/10"
aria-label="Thêm vào danh sách"> aria-label="Thêm vào danh sách">
<Plus class="size-5 md:size-6 text-white" /> <AppIcon name="plus" class="size-5 md:size-6 text-white" />
</button> </button>
<!-- Divider --> <!-- Divider -->
<div class="w-px h-6 bg-white/30"></div> <div class="w-px h-6 bg-white/30"></div>
@@ -402,7 +401,7 @@ useHead({
<NuxtLink :to="movieLink(hero)" <NuxtLink :to="movieLink(hero)"
class="group/link w-16 md:w-20 h-full flex items-center justify-center transition-colors text-white hover:text-[#FECF59]" class="group/link w-16 md:w-20 h-full flex items-center justify-center transition-colors text-white hover:text-[#FECF59]"
aria-label="Thông tin phim"> aria-label="Thông tin phim">
<Info <AppIcon name="info"
class="size-6 md:size-7 text-white fill-none stroke-current group-hover/link:text-[#FECF59] transition-all" /> class="size-6 md:size-7 text-white fill-none stroke-current group-hover/link:text-[#FECF59] transition-all" />
</NuxtLink> </NuxtLink>
</div> </div>
@@ -499,7 +498,7 @@ useHead({
<button type="button" <button type="button"
class="grid size-10 shrink-0 place-items-center rounded-full border border-white/20 bg-white/8 text-white transition hover:border-yellow-400/60 hover:bg-white/14" class="grid size-10 shrink-0 place-items-center rounded-full border border-white/20 bg-white/8 text-white transition hover:border-yellow-400/60 hover:bg-white/14"
aria-label="Xóa lịch sử xem" @click="clearWatchHistory"> aria-label="Xóa lịch sử xem" @click="clearWatchHistory">
<Trash2 class="size-4" /> <AppIcon name="trash" class="size-4" />
</button> </button>
</div> </div>
+4 -6
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const { data, pending, error } = await useFetch('/api/movies', { const { data, pending, error } = await useFetch('/api/movies', {
query: { page: 1 }, query: { page: 1 },
}) })
@@ -158,7 +156,7 @@ useHead({
<section class="schedule-shell"> <section class="schedule-shell">
<div class="schedule-heading"> <div class="schedule-heading">
<CalendarDays class="schedule-heading-icon" /> <AppIcon name="calendar-days" class="schedule-heading-icon" />
<h1 class="text-2xl font-black sm:text-3xl">Lịch chiếu</h1> <h1 class="text-2xl font-black sm:text-3xl">Lịch chiếu</h1>
</div> </div>
@@ -180,7 +178,7 @@ useHead({
<button v-if="selectedDayIndex > 0" type="button" <button v-if="selectedDayIndex > 0" type="button"
class="schedule-arrow schedule-arrow-left" class="schedule-arrow schedule-arrow-left"
aria-label="Ngày trước" @click="selectPreviousDay"> aria-label="Ngày trước" @click="selectPreviousDay">
<ChevronLeft /> <AppIcon name="chevron-left" />
</button> </button>
<div class="schedule-tabs"> <div class="schedule-tabs">
@@ -196,7 +194,7 @@ useHead({
<button v-if="selectedDayIndex < scheduleDays.length - 1" type="button" <button v-if="selectedDayIndex < scheduleDays.length - 1" type="button"
class="schedule-arrow schedule-arrow-right" class="schedule-arrow schedule-arrow-right"
aria-label="Ngày sau" @click="selectNextDay"> aria-label="Ngày sau" @click="selectNextDay">
<ChevronRight /> <AppIcon name="chevron-right" />
</button> </button>
</div> </div>
@@ -220,7 +218,7 @@ useHead({
</div> </div>
<div v-else class="schedule-empty"> <div v-else class="schedule-empty">
<CalendarDays /> <AppIcon name="calendar-days" />
<p class="mt-4 text-sm text-slate-400">Chưa phim nào trong ngày này.</p> <p class="mt-4 text-sm text-slate-400">Chưa phim nào trong ngày này.</p>
</div> </div>
</template> </template>
+3 -4
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Clock3, Trash2 } from 'lucide-vue-next'
import type { WatchHistoryItem } from '~/composables/useWatchHistory' import type { WatchHistoryItem } from '~/composables/useWatchHistory'
const { loadWatchHistory, clearWatchHistory } = useWatchHistory() const { loadWatchHistory, clearWatchHistory } = useWatchHistory()
@@ -86,7 +85,7 @@ useHead({
<button v-if="historyItems.length" type="button" <button v-if="historyItems.length" type="button"
class="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-md border border-white/10 bg-white/8 px-4 text-sm font-black text-white transition hover:bg-white/14" class="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-md border border-white/10 bg-white/8 px-4 text-sm font-black text-white transition hover:bg-white/14"
@click="removeHistory"> @click="removeHistory">
<Trash2 class="size-4" /> <AppIcon name="trash" class="size-4" />
Xóa lịch sử Xóa lịch sử
</button> </button>
</div> </div>
@@ -103,7 +102,7 @@ useHead({
<img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name" <img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div v-else class="grid h-full w-full place-items-center bg-white/8"> <div v-else class="grid h-full w-full place-items-center bg-white/8">
<Clock3 class="size-10 text-yellow-300" /> <AppIcon name="clock" class="size-10 text-yellow-300" />
</div> </div>
<div class="absolute inset-x-0 bottom-0 h-1 bg-white/18"> <div class="absolute inset-x-0 bottom-0 h-1 bg-white/18">
<span class="block h-full bg-yellow-300" :style="{ width: `${watchProgressPercent(item)}%` }" /> <span class="block h-full bg-yellow-300" :style="{ width: `${watchProgressPercent(item)}%` }" />
@@ -125,7 +124,7 @@ useHead({
<div v-else <div v-else
class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center"> class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center">
<Clock3 class="size-12 text-yellow-300" /> <AppIcon name="clock" class="size-12 text-yellow-300" />
<h2 class="mt-4 text-xl font-black">Chưa lịch sử xem</h2> <h2 class="mt-4 text-xl font-black">Chưa lịch sử xem</h2>
<p class="mt-2 max-w-md text-sm leading-6 text-slate-300"> <p class="mt-2 max-w-md text-sm leading-6 text-slate-300">
Phim bạn đã xem sẽ xuất hiện đây để bạn thể xem tiếp nhanh hơn. Phim bạn đã xem sẽ xuất hiện đây để bạn thể xem tiếp nhanh hơn.
+4 -6
View File
@@ -1,23 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { Mail, MessageCircle, Send } from 'lucide-vue-next'
const contactMethods = [ const contactMethods = [
{ {
icon: Send, icon: 'send' as const,
label: 'Telegram', label: 'Telegram',
value: '@cinek_support', value: '@cinek_support',
link: 'https://telegram.dog/cinek', link: 'https://telegram.dog/cinek',
desc: 'Nhắn tin trực tiếp, phản hồi nhanh nhất.', desc: 'Nhắn tin trực tiếp, phản hồi nhanh nhất.',
}, },
{ {
icon: MessageCircle, icon: 'message-circle' as const,
label: 'Cộng đồng', label: 'Cộng đồng',
value: 'Group Facebook CineK', value: 'Group Facebook CineK',
link: '#', link: '#',
desc: 'Tham gia nhóm để trao đổi, chia sẻ phim.', desc: 'Tham gia nhóm để trao đổi, chia sẻ phim.',
}, },
{ {
icon: Mail, icon: 'mail' as const,
label: 'Email', label: 'Email',
value: 'support@cinek.app', value: 'support@cinek.app',
link: 'mailto:support@cinek.app', link: 'mailto:support@cinek.app',
@@ -42,7 +40,7 @@ const contactMethods = [
<NuxtLink v-for="method in contactMethods" :key="method.label" :to="method.link" <NuxtLink v-for="method in contactMethods" :key="method.label" :to="method.link"
class="flex items-start gap-4 rounded-xl border border-white/10 bg-[#191b24] p-5 transition hover:border-white/20 hover:bg-white/5 group"> class="flex items-start gap-4 rounded-xl border border-white/10 bg-[#191b24] p-5 transition hover:border-white/20 hover:bg-white/5 group">
<div class="grid size-12 shrink-0 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400"> <div class="grid size-12 shrink-0 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400">
<component :is="method.icon" class="size-6" /> <AppIcon :name="method.icon" class="size-6" />
</div> </div>
<div> <div>
<h3 class="font-bold text-white group-hover:text-yellow-300 transition">{{ method.label }}</h3> <h3 class="font-bold text-white group-hover:text-yellow-300 transition">{{ method.label }}</h3>
+10 -12
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDown, ChevronRight, Heart, MessageSquare, Play, Plus, Server, Share2, Star, Video } from 'lucide-vue-next'
const route = useRoute() const route = useRoute()
const requestedSource = computed(() => String(route.query.source || 'nguonc')) const requestedSource = computed(() => String(route.query.source || 'nguonc'))
const requestedSources = computed(() => typeof route.query.srcs === 'string' ? route.query.srcs : '') const requestedSources = computed(() => typeof route.query.srcs === 'string' ? route.query.srcs : '')
@@ -272,7 +270,7 @@ useHead(() => ({
<NuxtLink :to="firstWatchLink" <NuxtLink :to="firstWatchLink"
class="inline-flex justify-center items-center gap-2 md:gap-2.5 px-6 md:px-8 py-3.5 md:py-3 text-[#0f1115] text-[15px] md:text-base font-bold rounded-full transition-all hover:scale-105 shadow-lg shadow-[#F5C518]/20 hover:shadow-[#F5C518]/40" class="inline-flex justify-center items-center gap-2 md:gap-2.5 px-6 md:px-8 py-3.5 md:py-3 text-[#0f1115] text-[15px] md:text-base font-bold rounded-full transition-all hover:scale-105 shadow-lg shadow-[#F5C518]/20 hover:shadow-[#F5C518]/40"
style="background:linear-gradient(135deg, #F5C518 0%, #FFD700 50%, #FFC107 100%)"> style="background:linear-gradient(135deg, #F5C518 0%, #FFD700 50%, #FFC107 100%)">
<Play class="w-5 h-5 md:w-6 md:h-6 shrink-0 fill-current" /> <AppIcon name="play" class="w-5 h-5 md:w-6 md:h-6 shrink-0 fill-current" />
Xem ngay Xem ngay
</NuxtLink> </NuxtLink>
@@ -282,21 +280,21 @@ useHead(() => ({
class="flex flex-col items-center justify-center gap-1 transition-all hover:-translate-y-0.5 active:scale-95 px-3" class="flex flex-col items-center justify-center gap-1 transition-all hover:-translate-y-0.5 active:scale-95 px-3"
:class="isFavoriteMovie ? 'text-yellow-300' : 'text-white/90'" :disabled="actionBusy" :class="isFavoriteMovie ? 'text-yellow-300' : 'text-white/90'" :disabled="actionBusy"
@click="toggleFavorite"> @click="toggleFavorite">
<Heart class="size-5" :class="isFavoriteMovie ? 'fill-current' : ''" /> <AppIcon name="heart" class="size-5" :class="isFavoriteMovie ? 'fill-current' : ''" />
<span class="text-[11px]">{{ isFavoriteMovie ? 'Đã thích' : 'Yêu thích' }}</span> <span class="text-[11px]">{{ isFavoriteMovie ? 'Đã thích' : 'Yêu thích' }}</span>
</button> </button>
<div class="w-px h-8 bg-white/10 mx-1" /> <div class="w-px h-8 bg-white/10 mx-1" />
<button type="button" <button type="button"
class="flex flex-col items-center justify-center gap-1 transition-all hover:-translate-y-0.5 active:scale-95 px-3 text-white/90" class="flex flex-col items-center justify-center gap-1 transition-all hover:-translate-y-0.5 active:scale-95 px-3 text-white/90"
:disabled="actionBusy" @click="addToWatchLater"> :disabled="actionBusy" @click="addToWatchLater">
<Plus class="size-5" /> <AppIcon name="plus" class="size-5" />
<span class="text-[11px]">Thêm vào</span> <span class="text-[11px]">Thêm vào</span>
</button> </button>
<div class="w-px h-8 bg-white/10 mx-1" /> <div class="w-px h-8 bg-white/10 mx-1" />
<button type="button" <button type="button"
class="flex flex-col items-center justify-center gap-1 transition-all hover:-translate-y-0.5 active:scale-95 px-3 text-white/90" class="flex flex-col items-center justify-center gap-1 transition-all hover:-translate-y-0.5 active:scale-95 px-3 text-white/90"
@click="shareMovie"> @click="shareMovie">
<Share2 class="size-5" /> <AppIcon name="share" class="size-5" />
<span class="text-[11px]">Chia sẻ</span> <span class="text-[11px]">Chia sẻ</span>
</button> </button>
</div> </div>
@@ -308,19 +306,19 @@ useHead(() => ({
class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95" class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95"
:class="isFavoriteMovie ? 'text-yellow-300' : 'text-white/90'" :disabled="actionBusy" :class="isFavoriteMovie ? 'text-yellow-300' : 'text-white/90'" :disabled="actionBusy"
@click="toggleFavorite"> @click="toggleFavorite">
<Heart class="size-6" :class="isFavoriteMovie ? 'fill-current' : ''" /> <AppIcon name="heart" class="size-6" :class="isFavoriteMovie ? 'fill-current' : ''" />
<span class="text-[11px]">Yêu thích</span> <span class="text-[11px]">Yêu thích</span>
</button> </button>
<button type="button" <button type="button"
class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95 text-white/90" class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95 text-white/90"
:disabled="actionBusy" @click="addToWatchLater"> :disabled="actionBusy" @click="addToWatchLater">
<Plus class="size-6" /> <AppIcon name="plus" class="size-6" />
<span class="text-[11px]">Thêm vào</span> <span class="text-[11px]">Thêm vào</span>
</button> </button>
<button type="button" <button type="button"
class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95 text-white/90" class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95 text-white/90"
@click="shareMovie"> @click="shareMovie">
<Share2 class="size-6" /> <AppIcon name="share" class="size-6" />
<span class="text-[11px]">Chia sẻ</span> <span class="text-[11px]">Chia sẻ</span>
</button> </button>
</div> </div>
@@ -364,7 +362,7 @@ useHead(() => ({
class="w-full flex items-center justify-between text-sm font-semibold text-yellow-200 py-2" class="w-full flex items-center justify-between text-sm font-semibold text-yellow-200 py-2"
@click="movieInfoOpen = !movieInfoOpen"> @click="movieInfoOpen = !movieInfoOpen">
<span>Thông tin phim</span> <span>Thông tin phim</span>
<ChevronDown class="size-4 transition" :class="movieInfoOpen ? 'rotate-180' : ''" /> <AppIcon name="chevron-down" class="size-4 transition" :class="movieInfoOpen ? 'rotate-180' : ''" />
</button> </button>
<div v-if="movieInfoOpen" class="space-y-2 text-sm text-slate-300"> <div v-if="movieInfoOpen" class="space-y-2 text-sm text-slate-300">
<div class="flex flex-wrap gap-2 text-[10px] mb-3"> <div class="flex flex-wrap gap-2 text-[10px] mb-3">
@@ -439,7 +437,7 @@ useHead(() => ({
<!-- Server Selector --> <!-- Server Selector -->
<div v-if="servers.length > 1" class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-4"> <div v-if="servers.length > 1" class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-4">
<div class="flex items-center gap-1.5 text-[13px] font-semibold text-white/50 mr-1"> <div class="flex items-center gap-1.5 text-[13px] font-semibold text-white/50 mr-1">
<Server class="size-4" /> <AppIcon name="server" class="size-4" />
Máy chủ: Máy chủ:
</div> </div>
<button v-for="(server, index) in servers" :key="server.name" type="button" <button v-for="(server, index) in servers" :key="server.name" type="button"
@@ -461,7 +459,7 @@ useHead(() => ({
<NuxtLink v-for="(episode, index) in activeServer.episodes" :key="`${episode.name}-${index}`" <NuxtLink v-for="(episode, index) in activeServer.episodes" :key="`${episode.name}-${index}`"
:to="episodeLink(index)" :to="episodeLink(index)"
class="group flex items-center justify-center gap-1.5 rounded-lg transition-all py-2.5 px-2 text-[13px] bg-[#191b24] text-white/90 hover:text-[#FFD166] hover:bg-[#1f2130] shadow-sm"> class="group flex items-center justify-center gap-1.5 rounded-lg transition-all py-2.5 px-2 text-[13px] bg-[#191b24] text-white/90 hover:text-[#FFD166] hover:bg-[#1f2130] shadow-sm">
<Play class="size-3 fill-current" /> <AppIcon name="play" class="size-3 fill-current" />
{{ formatEpisodeName(episode.name, index) }} {{ formatEpisodeName(episode.name, index) }}
</NuxtLink> </NuxtLink>
</div> </div>
+3 -5
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronLeft, ChevronRight, Search } from 'lucide-vue-next'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -136,7 +134,7 @@ useHead({
<div class="flex w-full flex-col gap-3 sm:flex-row lg:max-w-3xl"> <div class="flex w-full flex-col gap-3 sm:flex-row lg:max-w-3xl">
<div <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-yellow-950/20"> 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-yellow-950/20">
<Search class="mr-3 size-4 shrink-0 text-yellow-200" /> <AppIcon name="search" class="mr-3 size-4 shrink-0 text-yellow-200" />
<input v-model="keyword" type="search" placeholder="Tìm phim Hàn Quốc..." <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"> class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
</div> </div>
@@ -206,14 +204,14 @@ useHead({
<button type="button" :disabled="page === 1" <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" 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"> aria-label="Trang trước" @click="previousPage">
<ChevronLeft class="size-5" /> <AppIcon name="chevron-left" class="size-5" />
</button> </button>
<span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages <span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages
}}</template></span> }}</template></span>
<button type="button" <button type="button"
class="grid size-11 place-items-center rounded-full bg-yellow-300 text-slate-950 hover:bg-white" class="grid size-11 place-items-center rounded-full bg-yellow-300 text-slate-950 hover:bg-white"
aria-label="Trang sau" @click="nextPage"> aria-label="Trang sau" @click="nextPage">
<ChevronRight class="size-5" /> <AppIcon name="chevron-right" class="size-5" />
</button> </button>
</div> </div>
</section> </section>
+43 -45
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft, Camera, Check, Clock3, Crown, Eye, EyeOff, Heart, KeyRound, LogOut, LockKeyhole, Mail, Pencil, Settings, UserRound, X, Venus, Mars, Minus } from 'lucide-vue-next'
definePageMeta({ middleware: 'auth' }) definePageMeta({ middleware: 'auth' })
const { user, logout: doLogout, fetchUser } = useAuth() const { user, logout: doLogout, fetchUser } = useAuth()
@@ -30,9 +28,9 @@ const genderError = ref('')
const genderSuccess = ref('') const genderSuccess = ref('')
const genderOptions = [ const genderOptions = [
{ value: 'male', label: 'Nam', icon: Mars }, { value: 'male', label: 'Nam', icon: 'mars' as const },
{ value: 'female', label: 'Nữ', icon: Venus }, { value: 'female', label: 'Nữ', icon: 'venus' as const },
{ value: 'other', label: 'Khác', icon: Minus }, { value: 'other', label: 'Khác', icon: 'minus' as const },
] ]
const avatarCategories = [ const avatarCategories = [
@@ -65,10 +63,10 @@ const avatarGrid = computed(() => {
}) })
const sidebarItems = [ const sidebarItems = [
{ id: 'account', label: 'Quản lý tài khoản', icon: Settings }, { id: 'account', label: 'Quản lý tài khoản', icon: 'settings' as const },
{ id: 'password', label: 'Đổi mật khẩu', icon: KeyRound }, { id: 'password', label: 'Đổi mật khẩu', icon: 'key' as const },
{ id: 'history', label: 'Lịch sử xem', icon: Clock3 }, { id: 'history', label: 'Lịch sử xem', icon: 'clock' as const },
{ id: 'favorites', label: 'Phim yêu thích', icon: Heart }, { id: 'favorites', label: 'Phim yêu thích', icon: 'heart' as const },
] ]
async function handleLogout() { async function handleLogout() {
@@ -267,7 +265,7 @@ useHead({
:class="isAdmin ? 'bg-[#FFD166]/20 ring-2 ring-[#FFD166]' : 'bg-[#FFD166]/10 ring-2 ring-[#FFD166]/20'"> :class="isAdmin ? 'bg-[#FFD166]/20 ring-2 ring-[#FFD166]' : 'bg-[#FFD166]/10 ring-2 ring-[#FFD166]/20'">
<span class="text-sm font-bold text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span> <span class="text-sm font-bold text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
</div> </div>
<Crown v-if="isAdmin" <AppIcon name="crown" v-if="isAdmin"
class="absolute -right-1 -top-1 grid size-5 place-items-center rounded-full bg-[#0a0a0f] p-0.5 text-[#FFD166] ring-1 ring-[#FFD166]/40" /> class="absolute -right-1 -top-1 grid size-5 place-items-center rounded-full bg-[#0a0a0f] p-0.5 text-[#FFD166] ring-1 ring-[#FFD166]/40" />
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
@@ -283,7 +281,7 @@ useHead({
:class="activeTab === item.id :class="activeTab === item.id
? 'bg-[#FFD166]/10 text-[#FFD166]' ? 'bg-[#FFD166]/10 text-[#FFD166]'
: 'text-white/50 hover:bg-white/[0.04] hover:text-white'" @click="activeTab = item.id"> : 'text-white/50 hover:bg-white/[0.04] hover:text-white'" @click="activeTab = item.id">
<component :is="item.icon" class="size-4" /> <AppIcon :name="item.icon" class="size-4" />
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</button> </button>
@@ -291,14 +289,14 @@ useHead({
<NuxtLink to="/" <NuxtLink to="/"
class="hidden shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-white/40 transition hover:text-white lg:flex"> class="hidden shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-white/40 transition hover:text-white lg:flex">
<ArrowLeft class="size-4" /> <AppIcon name="arrow-left" class="size-4" />
<span>Về trang chủ</span> <span>Về trang chủ</span>
</NuxtLink> </NuxtLink>
<button type="button" <button type="button"
class="hidden shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-red-400/60 transition hover:bg-red-400/8 hover:text-red-400 lg:flex" class="hidden shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-red-400/60 transition hover:bg-red-400/8 hover:text-red-400 lg:flex"
@click="handleLogout"> @click="handleLogout">
<LogOut class="size-4" /> <AppIcon name="log-out" class="size-4" />
<span>Đăng xuất</span> <span>Đăng xuất</span>
</button> </button>
</nav> </nav>
@@ -310,7 +308,7 @@ useHead({
<div v-if="activeTab === 'account'" :key="'account'" <div v-if="activeTab === 'account'" :key="'account'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6"> class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5"> <div class="mb-5 flex items-center gap-2.5">
<Settings class="size-5 text-[#FFD166]" /> <AppIcon name="settings" class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Quản tài khoản</h2> <h2 class="text-lg font-bold">Quản tài khoản</h2>
</div> </div>
@@ -319,7 +317,7 @@ useHead({
<div> <div>
<label class="mb-1.5 block text-xs font-medium text-white/40">Tên hiển thị</label> <label class="mb-1.5 block text-xs font-medium text-white/40">Tên hiển thị</label>
<div class="relative"> <div class="relative">
<UserRound 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="displayNameInput" type="text" placeholder="Nhập tên hiển thị" <input v-model="displayNameInput" type="text" placeholder="Nhập tên hiển thị"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-24 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/[0.08] bg-white/[0.04] pl-11 pr-24 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"
@keyup.enter="saveName"> @keyup.enter="saveName">
@@ -334,7 +332,7 @@ useHead({
<div> <div>
<label class="mb-1.5 block text-xs font-medium text-white/40">Email</label> <label class="mb-1.5 block text-xs font-medium text-white/40">Email</label>
<div class="relative"> <div class="relative">
<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 type="text" :value="user.email" readonly <input type="text" :value="user.email" readonly
class="h-12 w-full cursor-not-allowed rounded-xl border border-white/[0.06] bg-white/[0.02] pl-11 pr-16 text-sm text-white/50 outline-none"> class="h-12 w-full cursor-not-allowed rounded-xl border border-white/[0.06] bg-white/[0.02] pl-11 pr-16 text-sm text-white/50 outline-none">
<span <span
@@ -357,29 +355,29 @@ useHead({
? 'border-[#FFD166]/40 bg-[#FFD166]/10 text-[#FFD166]' ? 'border-[#FFD166]/40 bg-[#FFD166]/10 text-[#FFD166]'
: 'border-white/[0.08] bg-white/[0.04] text-white/50 hover:border-white/[0.15] hover:text-white/70'" : 'border-white/[0.08] bg-white/[0.04] text-white/50 hover:border-white/[0.15] hover:text-white/70'"
:disabled="genderLoading" @click="saveGender(opt.value)"> :disabled="genderLoading" @click="saveGender(opt.value)">
<component :is="opt.icon" class="size-4" /> <AppIcon :name="opt.icon" class="size-4" />
<span>{{ opt.label }}</span> <span>{{ opt.label }}</span>
</button> </button>
</div> </div>
</div> </div>
<p v-if="nameError" class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400"> <p v-if="nameError" class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
<X class="size-4 shrink-0" /> <AppIcon name="x" class="size-4 shrink-0" />
{{ nameError }} {{ nameError }}
</p> </p>
<p v-if="nameSuccess" <p v-if="nameSuccess"
class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400"> class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
<Check class="size-4 shrink-0" /> <AppIcon name="check" class="size-4 shrink-0" />
{{ nameSuccess }} {{ nameSuccess }}
</p> </p>
<p v-if="genderError" <p v-if="genderError"
class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400"> class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
<X class="size-4 shrink-0" /> <AppIcon name="x" class="size-4 shrink-0" />
{{ genderError }} {{ genderError }}
</p> </p>
<p v-if="genderSuccess" <p v-if="genderSuccess"
class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400"> class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
<Check class="size-4 shrink-0" /> <AppIcon name="check" class="size-4 shrink-0" />
{{ genderSuccess }} {{ genderSuccess }}
</p> </p>
@@ -387,19 +385,19 @@ useHead({
<button type="button" <button type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg bg-[#FFD166] px-4 text-sm font-bold text-[#0f111a] transition hover:bg-[#FFC845]" class="inline-flex h-9 items-center gap-2 rounded-lg bg-[#FFD166] px-4 text-sm font-bold text-[#0f111a] transition hover:bg-[#FFC845]"
@click="avatarModalOpen = true"> @click="avatarModalOpen = true">
<Camera class="size-4" /> <AppIcon name="camera" class="size-4" />
Đổi avatar Đổi avatar
</button> </button>
<button type="button" <button type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg bg-[#FFD166] px-4 text-sm font-bold text-[#0f111a] transition hover:bg-[#FFC845]" class="inline-flex h-9 items-center gap-2 rounded-lg bg-[#FFD166] px-4 text-sm font-bold text-[#0f111a] transition hover:bg-[#FFC845]"
@click="activeTab = 'password'"> @click="activeTab = 'password'">
<KeyRound class="size-4" /> <AppIcon name="key" class="size-4" />
Đổi mật khẩu Đổi mật khẩu
</button> </button>
<button type="button" <button type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-semibold text-red-400 transition hover:bg-red-400/20 lg:hidden" class="inline-flex h-9 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-semibold text-red-400 transition hover:bg-red-400/20 lg:hidden"
@click="handleLogout"> @click="handleLogout">
<LogOut class="size-4" /> <AppIcon name="log-out" class="size-4" />
Đăng xuất Đăng xuất
</button> </button>
</div> </div>
@@ -415,12 +413,12 @@ useHead({
:class="isAdmin ? 'bg-[#FFD166]/20 ring-4 ring-[#FFD166]' : 'bg-[#FFD166]/10 ring-4 ring-[#FFD166]/20'"> :class="isAdmin ? 'bg-[#FFD166]/20 ring-4 ring-[#FFD166]' : 'bg-[#FFD166]/10 ring-4 ring-[#FFD166]/20'">
<span class="text-4xl font-black text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span> <span class="text-4xl font-black text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
</div> </div>
<Crown v-if="isAdmin" <AppIcon name="crown" v-if="isAdmin"
class="absolute -right-2 -top-2 grid size-9 place-items-center rounded-full bg-[#FFD166] p-1.5 text-[#0f111a] ring-2 ring-[#FFD166]/40" /> class="absolute -right-2 -top-2 grid size-9 place-items-center rounded-full bg-[#FFD166] p-1.5 text-[#0f111a] ring-2 ring-[#FFD166]/40" />
<button type="button" <button type="button"
class="absolute -right-1 -bottom-1 grid size-9 place-items-center rounded-full bg-[#FFD166] text-[#0f111a] shadow-lg transition hover:bg-[#FFC845]" class="absolute -right-1 -bottom-1 grid size-9 place-items-center rounded-full bg-[#FFD166] text-[#0f111a] shadow-lg transition hover:bg-[#FFC845]"
@click="avatarModalOpen = true"> @click="avatarModalOpen = true">
<Camera class="size-4" /> <AppIcon name="camera" class="size-4" />
</button> </button>
</div> </div>
<p class="text-center text-sm font-semibold">{{ memberName }}</p> <p class="text-center text-sm font-semibold">{{ memberName }}</p>
@@ -432,7 +430,7 @@ useHead({
<div v-else-if="activeTab === 'password'" :key="'password'" <div v-else-if="activeTab === 'password'" :key="'password'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6"> class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5"> <div class="mb-5 flex items-center gap-2.5">
<KeyRound class="size-5 text-[#FFD166]" /> <AppIcon name="key" class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Đổi mật khẩu</h2> <h2 class="text-lg font-bold">Đổi mật khẩu</h2>
</div> </div>
@@ -440,15 +438,15 @@ useHead({
<div class="relative"> <div class="relative">
<label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu hiện tại</label> <label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu hiện tại</label>
<div class="relative"> <div class="relative">
<LockKeyhole 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="currentPassword" :type="showPassword ? 'text' : 'password'" required <input v-model="currentPassword" :type="showPassword ? 'text' : 'password'" required
placeholder="Nhập mật khẩu hiện tại" placeholder="Nhập mật khẩu hiện tại"
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/[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">
<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">
<EyeOff v-if="showPassword" class="size-4" /> <AppIcon name="eye-off" v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" /> <AppIcon name="eye" v-else class="size-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -456,15 +454,15 @@ useHead({
<div class="relative"> <div class="relative">
<label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu mới</label> <label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu mới</label>
<div class="relative"> <div class="relative">
<LockKeyhole 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="newPassword" :type="showNewPassword ? 'text' : 'password'" required minlength="6" <input v-model="newPassword" :type="showNewPassword ? 'text' : 'password'" required minlength="6"
placeholder="Ít nhất 6 ký tự" placeholder="Ít nhất 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/[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">
<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="showNewPassword = !showNewPassword"> @click="showNewPassword = !showNewPassword">
<EyeOff v-if="showNewPassword" class="size-4" /> <AppIcon name="eye-off" v-if="showNewPassword" class="size-4" />
<Eye v-else class="size-4" /> <AppIcon name="eye" v-else class="size-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -472,15 +470,15 @@ useHead({
<div class="relative"> <div class="relative">
<label class="mb-1.5 block text-xs font-medium text-white/40">Xác nhận mật khẩu mới</label> <label class="mb-1.5 block text-xs font-medium text-white/40">Xác nhận mật khẩu mới</label>
<div class="relative"> <div class="relative">
<LockKeyhole 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="confirmPassword" :type="showConfirmPassword ? 'text' : 'password'" required <input v-model="confirmPassword" :type="showConfirmPassword ? 'text' : 'password'" required
minlength="6" placeholder="Nhập lại mật khẩu mới" minlength="6" placeholder="Nhập lại mật khẩu mới"
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/[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">
<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="showConfirmPassword = !showConfirmPassword"> @click="showConfirmPassword = !showConfirmPassword">
<EyeOff v-if="showConfirmPassword" class="size-4" /> <AppIcon name="eye-off" v-if="showConfirmPassword" class="size-4" />
<Eye v-else class="size-4" /> <AppIcon name="eye" v-else class="size-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -493,13 +491,13 @@ useHead({
<p v-if="passwordError" <p v-if="passwordError"
class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400"> class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
<X class="size-4 shrink-0" /> <AppIcon name="x" class="size-4 shrink-0" />
{{ passwordError }} {{ passwordError }}
</p> </p>
<p v-if="passwordSuccess" <p v-if="passwordSuccess"
class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400"> class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
<Check class="size-4 shrink-0" /> <AppIcon name="check" class="size-4 shrink-0" />
{{ passwordSuccess }} {{ passwordSuccess }}
</p> </p>
</form> </form>
@@ -508,13 +506,13 @@ useHead({
<div v-else-if="activeTab === 'history'" :key="'history'" <div v-else-if="activeTab === 'history'" :key="'history'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6"> class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5"> <div class="mb-5 flex items-center gap-2.5">
<Clock3 class="size-5 text-[#FFD166]" /> <AppIcon name="clock" class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Lịch sử xem</h2> <h2 class="text-lg font-bold">Lịch sử xem</h2>
</div> </div>
<NuxtLink to="/lich-su" <NuxtLink to="/lich-su"
class="flex items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.02] p-8 text-center transition hover:border-[#FFD166]/30 hover:bg-white/[0.04]"> class="flex items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.02] p-8 text-center transition hover:border-[#FFD166]/30 hover:bg-white/[0.04]">
<div> <div>
<Clock3 class="mx-auto size-8 text-white/20" /> <AppIcon name="clock" class="mx-auto size-8 text-white/20" />
<p class="mt-2 text-sm text-white/40">Xem lịch sử xem chi tiết</p> <p class="mt-2 text-sm text-white/40">Xem lịch sử xem chi tiết</p>
<p class="mt-1 text-xs text-[#FFD166]">Nhấn để xem </p> <p class="mt-1 text-xs text-[#FFD166]">Nhấn để xem </p>
</div> </div>
@@ -524,13 +522,13 @@ useHead({
<div v-else-if="activeTab === 'favorites'" :key="'favorites'" <div v-else-if="activeTab === 'favorites'" :key="'favorites'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6"> class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5"> <div class="mb-5 flex items-center gap-2.5">
<Heart class="size-5 text-[#FFD166]" /> <AppIcon name="heart" class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Phim yêu thích</h2> <h2 class="text-lg font-bold">Phim yêu thích</h2>
</div> </div>
<NuxtLink to="/yeu-thich" <NuxtLink to="/yeu-thich"
class="flex items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.02] p-8 text-center transition hover:border-[#FFD166]/30 hover:bg-white/[0.04]"> class="flex items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.02] p-8 text-center transition hover:border-[#FFD166]/30 hover:bg-white/[0.04]">
<div> <div>
<Heart class="mx-auto size-8 text-white/20" /> <AppIcon name="heart" class="mx-auto size-8 text-white/20" />
<p class="mt-2 text-sm text-white/40">Xem danh sách phim yêu thích</p> <p class="mt-2 text-sm text-white/40">Xem danh sách phim yêu thích</p>
<p class="mt-1 text-xs text-[#FFD166]">Nhấn để xem </p> <p class="mt-1 text-xs text-[#FFD166]">Nhấn để xem </p>
</div> </div>
@@ -553,13 +551,13 @@ useHead({
<button type="button" <button type="button"
class="absolute right-4 top-4 z-10 grid h-8 w-8 place-items-center rounded-full bg-white/10 text-white/70 transition hover:bg-white/20 hover:text-white" class="absolute right-4 top-4 z-10 grid h-8 w-8 place-items-center rounded-full bg-white/10 text-white/70 transition hover:bg-white/20 hover:text-white"
@click="avatarModalOpen = false"> @click="avatarModalOpen = false">
<X class="size-4" /> <AppIcon name="x" class="size-4" />
</button> </button>
<div class="p-5 pb-3"> <div class="p-5 pb-3">
<div class="mb-4 flex items-center gap-2.5"> <div class="mb-4 flex items-center gap-2.5">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-[#FFD166]/10"> <div class="flex h-9 w-9 items-center justify-center rounded-xl bg-[#FFD166]/10">
<Camera class="size-4 text-[#FFD166]" /> <AppIcon name="camera" class="size-4 text-[#FFD166]" />
</div> </div>
<div> <div>
<h2 class="text-lg font-bold text-white">Chọn avatar</h2> <h2 class="text-lg font-bold text-white">Chọn avatar</h2>
@@ -588,7 +586,7 @@ useHead({
<div v-if="avatarUrl === img" <div v-if="avatarUrl === img"
class="absolute inset-0 flex items-center justify-center bg-[#FFD166]/20"> class="absolute inset-0 flex items-center justify-center bg-[#FFD166]/20">
<div class="grid size-6 place-items-center rounded-full bg-[#FFD166]"> <div class="grid size-6 place-items-center rounded-full bg-[#FFD166]">
<Check class="size-3.5 text-[#0f111a]" /> <AppIcon name="check" class="size-3.5 text-[#0f111a]" />
</div> </div>
</div> </div>
</button> </button>
+25 -31
View File
@@ -1,11 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type Hls from 'hls.js' import type Hls from 'hls.js'
import {
AlertTriangle, BadgeCheck, Bug, ChevronRight, CircleHelp, CornerDownLeft, Crown, Eye, EyeOff, FastForward, Heart, Image, Layers,
Loader2, LogIn, Maximize, MessageCircle, MessageSquare, Pause, PictureInPicture2, Pin, Play, Plus, Rewind,
Send, Settings, Share2, SkipForward, Star, ThumbsDown, ThumbsUp, Trash2, User, Volume2, VolumeX, Captions, Languages,
} from 'lucide-vue-next'
const route = useRoute() const route = useRoute()
const selectedServer = ref(Math.max(Number(route.query.server || 0), 0)) const selectedServer = ref(Math.max(Number(route.query.server || 0), 0))
const selectedEpisode = ref(Math.max(Number(route.query.ep || 1) - 1, 0)) const selectedEpisode = ref(Math.max(Number(route.query.ep || 1) - 1, 0))
@@ -453,7 +447,7 @@ useHead(() => ({
class="pointer-events-none absolute inset-0 z-30 grid place-items-center bg-black/45 text-yellow-200 backdrop-blur-[1px]"> class="pointer-events-none absolute inset-0 z-30 grid place-items-center bg-black/45 text-yellow-200 backdrop-blur-[1px]">
<div <div
class="inline-flex items-center gap-3 rounded-full border border-yellow-300/25 bg-black/70 px-4 py-2 text-xs font-black shadow-xl shadow-black/30"> class="inline-flex items-center gap-3 rounded-full border border-yellow-300/25 bg-black/70 px-4 py-2 text-xs font-black shadow-xl shadow-black/30">
<Loader2 class="size-4 animate-spin" /> Đang tải phim <AppIcon name="loader" class="size-4 animate-spin" /> Đang tải phim
</div> </div>
</div> </div>
@@ -470,7 +464,7 @@ useHead(() => ({
class="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-b from-black/35 to-transparent" /> class="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-b from-black/35 to-transparent" />
<div v-if="isVideoBuffering" <div v-if="isVideoBuffering"
class="pointer-events-none absolute inset-0 z-10 grid place-items-center bg-black/20 text-yellow-200"> class="pointer-events-none absolute inset-0 z-10 grid place-items-center bg-black/20 text-yellow-200">
<Loader2 class="size-10 animate-spin" /> <AppIcon name="loader" class="size-10 animate-spin" />
</div> </div>
<div v-if="hlsErrorMessage" <div v-if="hlsErrorMessage"
class="absolute left-1/2 top-1/2 z-20 w-[min(90%,28rem)] -translate-x-1/2 -translate-y-1/2 rounded-md border border-red-300/30 bg-red-950/85 p-4 text-center text-sm font-bold text-red-100"> class="absolute left-1/2 top-1/2 z-20 w-[min(90%,28rem)] -translate-x-1/2 -translate-y-1/2 rounded-md border border-red-300/30 bg-red-950/85 p-4 text-center text-sm font-bold text-red-100">
@@ -482,7 +476,7 @@ useHead(() => ({
type="button" type="button"
class="absolute bottom-24 right-4 z-20 inline-flex items-center gap-2 rounded-md border border-yellow-300/80 bg-black/55 px-4 py-2 text-xs font-black text-yellow-200 shadow-lg shadow-black/30 backdrop-blur transition hover:border-yellow-200 hover:bg-yellow-400 hover:text-slate-950" class="absolute bottom-24 right-4 z-20 inline-flex items-center gap-2 rounded-md border border-yellow-300/80 bg-black/55 px-4 py-2 text-xs font-black text-yellow-200 shadow-lg shadow-black/30 backdrop-blur transition hover:border-yellow-200 hover:bg-yellow-400 hover:text-slate-950"
@click="playNextEpisode(true)"> @click="playNextEpisode(true)">
<SkipForward class="size-4" /> Tập tiếp <AppIcon name="skip-forward" class="size-4" /> Tập tiếp
</button> </button>
<button type="button" <button type="button"
class="absolute inset-0 grid cursor-pointer place-items-center text-white transition" class="absolute inset-0 grid cursor-pointer place-items-center text-white transition"
@@ -491,8 +485,8 @@ useHead(() => ({
@pointerleave="stopHoldSpeed" @contextmenu.prevent> @pointerleave="stopHoldSpeed" @contextmenu.prevent>
<span <span
class="grid size-12 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-xl shadow-yellow-950/30 sm:size-16"> class="grid size-12 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-xl shadow-yellow-950/30 sm:size-16">
<Pause v-if="isVideoPlaying" class="size-5 fill-current sm:size-7" /> <AppIcon name="pause" v-if="isVideoPlaying" class="size-5 fill-current sm:size-7" />
<Play v-else class="size-5 fill-current sm:size-7" /> <AppIcon name="play" v-else class="size-5 fill-current sm:size-7" />
</span> </span>
</button> </button>
<div v-if="gestureHint || isHoldSpeedActive" <div v-if="gestureHint || isHoldSpeedActive"
@@ -509,18 +503,18 @@ useHead(() => ({
<div class="kr-hls-actions"> <div class="kr-hls-actions">
<div class="kr-hls-actions-left"> <div class="kr-hls-actions-left">
<button type="button" class="kr-hls-control-button is-primary" @click="toggleHlsPlayback"> <button type="button" class="kr-hls-control-button is-primary" @click="toggleHlsPlayback">
<Pause v-if="isVideoPlaying" class="fill-current" /> <AppIcon name="pause" v-if="isVideoPlaying" class="fill-current" />
<Play v-else class="fill-current" /> <AppIcon name="play" v-else class="fill-current" />
</button> </button>
<button type="button" class="kr-hls-control-button" @click="seekBy(-10)"> <button type="button" class="kr-hls-control-button" @click="seekBy(-10)">
<Rewind /> <AppIcon name="rewind" />
</button> </button>
<button type="button" class="kr-hls-control-button" @click="seekBy(10)"> <button type="button" class="kr-hls-control-button" @click="seekBy(10)">
<FastForward /> <AppIcon name="fast-forward" />
</button> </button>
<button type="button" class="kr-hls-control-button" @click="toggleMute"> <button type="button" class="kr-hls-control-button" @click="toggleMute">
<VolumeX v-if="isVideoMuted" /> <AppIcon name="volume-x" v-if="isVideoMuted" />
<Volume2 v-else /> <AppIcon name="volume" v-else />
</button> </button>
<input class="kr-volume-range hidden sm:block" type="range" min="0" max="1" step="0.05" <input class="kr-volume-range hidden sm:block" type="range" min="0" max="1" step="0.05"
:value="videoVolume" @input="changeVolume"> :value="videoVolume" @input="changeVolume">
@@ -530,7 +524,7 @@ useHead(() => ({
<button type="button" class="kr-hls-control-button" <button type="button" class="kr-hls-control-button"
:class="isSettingsOpen ? 'bg-yellow-400 text-slate-950' : ''" :class="isSettingsOpen ? 'bg-yellow-400 text-slate-950' : ''"
@click="isSettingsOpen = !isSettingsOpen; controlsVisible = true"> @click="isSettingsOpen = !isSettingsOpen; controlsVisible = true">
<Settings /> <AppIcon name="settings" />
</button> </button>
<div v-if="isSettingsOpen" <div v-if="isSettingsOpen"
class="absolute bottom-10 right-0 z-30 w-48 rounded-md border border-white/10 bg-slate-950/95 p-3 text-white shadow-2xl shadow-black/40 backdrop-blur sm:bottom-12 sm:w-56"> class="absolute bottom-10 right-0 z-30 w-48 rounded-md border border-white/10 bg-slate-950/95 p-3 text-white shadow-2xl shadow-black/40 backdrop-blur sm:bottom-12 sm:w-56">
@@ -556,14 +550,14 @@ useHead(() => ({
</div> </div>
<button type="button" class="kr-hls-control-button kr-hls-desktop-button" <button type="button" class="kr-hls-control-button kr-hls-desktop-button"
@click="togglePictureInPicture"> @click="togglePictureInPicture">
<PictureInPicture2 /> <AppIcon name="picture-in-picture" />
</button> </button>
<button v-if="hasNextEpisode" type="button" class="kr-hls-control-button kr-hls-desktop-button" <button v-if="hasNextEpisode" type="button" class="kr-hls-control-button kr-hls-desktop-button"
@click="playNextEpisode(true)"> @click="playNextEpisode(true)">
<SkipForward /> <AppIcon name="skip-forward" />
</button> </button>
<button type="button" class="kr-hls-control-button" @click="toggleFullscreen"> <button type="button" class="kr-hls-control-button" @click="toggleFullscreen">
<Maximize /> <AppIcon name="maximize" />
</button> </button>
</div> </div>
</div> </div>
@@ -576,7 +570,7 @@ useHead(() => ({
<div class="absolute inset-0 grid place-items-center"> <div class="absolute inset-0 grid place-items-center">
<span <span
class="grid size-16 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-xl shadow-yellow-950/30"> class="grid size-16 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-xl shadow-yellow-950/30">
<Play class="size-7 fill-current" /> <AppIcon name="play" class="size-7 fill-current" />
</span> </span>
</div> </div>
</button> </button>
@@ -586,7 +580,7 @@ useHead(() => ({
<div class="flex h-14 items-center justify-between border-t border-white/5 bg-black px-4 sm:px-6"> <div class="flex h-14 items-center justify-between border-t border-white/5 bg-black px-4 sm:px-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="grid size-8 place-items-center rounded-full bg-yellow-400/20 text-yellow-300"> <div class="grid size-8 place-items-center rounded-full bg-yellow-400/20 text-yellow-300">
<Play class="size-4 fill-current" /> <AppIcon name="play" class="size-4 fill-current" />
</div> </div>
<div class="text-sm"> <div class="text-sm">
<span class="font-semibold text-white">{{ formatEpisodeName(activeEpisode?.name, selectedEpisode) <span class="font-semibold text-white">{{ formatEpisodeName(activeEpisode?.name, selectedEpisode)
@@ -599,12 +593,12 @@ useHead(() => ({
<button type="button" <button type="button"
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-white/70 hover:bg-white/10 hover:text-white transition" class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-white/70 hover:bg-white/10 hover:text-white transition"
title="Báo lỗi"> title="Báo lỗi">
<Bug class="size-4" /><span class="hidden sm:inline">Báo lỗi</span> <AppIcon name="bug" class="size-4" /><span class="hidden sm:inline">Báo lỗi</span>
</button> </button>
<button type="button" <button type="button"
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-white/70 hover:bg-white/10 hover:text-white transition" class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-white/70 hover:bg-white/10 hover:text-white transition"
title="Hướng dẫn"> title="Hướng dẫn">
<CircleHelp class="size-4" /><span class="hidden sm:inline">Hướng dẫn</span> <AppIcon name="circle-help" class="size-4" /><span class="hidden sm:inline">Hướng dẫn</span>
</button> </button>
</div> </div>
</div> </div>
@@ -656,7 +650,7 @@ useHead(() => ({
<!-- Episode Progress --> <!-- Episode Progress -->
<div v-if="episodeProgress?.total" <div v-if="episodeProgress?.total"
class="inline-flex self-center sm:self-start items-center gap-1.5 px-3 py-1.5 rounded-full mt-2 border bg-cinek-500/10 border-cinek-500/20 text-cinek-500"> class="inline-flex self-center sm:self-start items-center gap-1.5 px-3 py-1.5 rounded-full mt-2 border bg-cinek-500/10 border-cinek-500/20 text-cinek-500">
<Loader2 class="size-3.5 animate-spin" /> <AppIcon name="loader" class="size-3.5 animate-spin" />
<span class="text-xs font-medium">Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total <span class="text-xs font-medium">Đã chiếu: {{ episodeProgress.available }} / {{ episodeProgress.total
}} tập</span> }} tập</span>
</div> </div>
@@ -670,7 +664,7 @@ useHead(() => ({
:to="{ path: `/phim/${route.params.slug}`, query: { source: route.query.source, srcs: route.query.srcs } }" :to="{ path: `/phim/${route.params.slug}`, query: { source: route.query.source, srcs: route.query.srcs } }"
class="inline-flex items-center gap-1 text-[13px] text-cinek-500 hover:underline mt-1"> class="inline-flex items-center gap-1 text-[13px] text-cinek-500 hover:underline mt-1">
Thông tin phim Thông tin phim
<ChevronRight class="size-4" /> <AppIcon name="chevron-right" class="size-4" />
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
@@ -682,7 +676,7 @@ useHead(() => ({
<div class="flex flex-wrap items-center justify-between gap-4 mb-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="relative flex min-w-0 items-center gap-1.5 sm:gap-2"> <div class="relative flex min-w-0 items-center gap-1.5 sm:gap-2">
<div class="flex shrink-0 items-center gap-1 text-[12px] font-semibold text-white/50"> <div class="flex shrink-0 items-center gap-1 text-[12px] font-semibold text-white/50">
<Layers class="size-4" /> Phần: <AppIcon name="layers" class="size-4" /> Phần:
</div> </div>
<button <button
class="flex items-center gap-1.5 border border-white/30 text-white bg-white/5 rounded-md px-3 py-1.5 text-sm font-medium"> class="flex items-center gap-1.5 border border-white/30 text-white bg-white/5 rounded-md px-3 py-1.5 text-sm font-medium">
@@ -711,8 +705,8 @@ useHead(() => ({
class="group flex items-center gap-1.5 text-xs transition-colors rounded-md px-3 py-1.5 border font-medium" class="group flex items-center gap-1.5 text-xs transition-colors rounded-md px-3 py-1.5 border font-medium"
:class="selectedSubtitle === index ? 'border-white/30 text-white bg-white/5' : 'border-transparent text-white/60 hover:text-white'" :class="selectedSubtitle === index ? 'border-white/30 text-white bg-white/5' : 'border-transparent text-white/60 hover:text-white'"
@click="selectSubtitle(index)"> @click="selectSubtitle(index)">
<Captions v-if="index === 0" class="size-3.5" /> <AppIcon name="captions" v-if="index === 0" class="size-3.5" />
<Languages v-else class="size-3.5" /> <AppIcon name="languages" v-else class="size-3.5" />
<span>{{ subtitleLabel(sub, index) }}</span> <span>{{ subtitleLabel(sub, 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 === index ? 'bg-white text-black' : 'bg-white/10 text-white/50'">
@@ -728,7 +722,7 @@ useHead(() => ({
:to="episodeLink(index)" :to="episodeLink(index)"
class="group flex items-center justify-center gap-1.5 rounded-lg transition-all py-2.5 px-2 text-[13px] bg-[#191b24] text-white/90 hover:text-[#FFD166] hover:bg-[#1f2130] shadow-sm" class="group flex items-center justify-center gap-1.5 rounded-lg transition-all py-2.5 px-2 text-[13px] bg-[#191b24] text-white/90 hover:text-[#FFD166] hover:bg-[#1f2130] shadow-sm"
:class="selectedEpisode === index ? 'bg-[#F5C518]/20 text-[#FFD166] border border-[#F5C518]/40' : ''"> :class="selectedEpisode === index ? 'bg-[#F5C518]/20 text-[#FFD166] border border-[#F5C518]/40' : ''">
<Play class="size-3 fill-current" /> <AppIcon name="play" class="size-3 fill-current" />
{{ formatEpisodeName(episode.name, index) }} {{ formatEpisodeName(episode.name, index) }}
</NuxtLink> </NuxtLink>
</div> </div>
+2 -3
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Heart } from 'lucide-vue-next'
import type { LibraryMovieItem } from '~/composables/useMovieLibrary' import type { LibraryMovieItem } from '~/composables/useMovieLibrary'
const { loadFavorites } = useMovieLibrary() const { loadFavorites } = useMovieLibrary()
@@ -58,7 +57,7 @@ useHead({
<img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name" <img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div v-else class="grid h-full w-full place-items-center bg-white/8"> <div v-else class="grid h-full w-full place-items-center bg-white/8">
<Heart class="size-10 text-yellow-300" /> <AppIcon name="heart" class="size-10 text-yellow-300" />
</div> </div>
<span class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950"> <span class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
Yêu thích Yêu thích
@@ -71,7 +70,7 @@ useHead({
<div v-else <div v-else
class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center"> class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center">
<Heart class="size-12 text-yellow-300" /> <AppIcon name="heart" class="size-12 text-yellow-300" />
<h2 class="mt-4 text-xl font-black">Chưa phim yêu thích</h2> <h2 class="mt-4 text-xl font-black">Chưa phim yêu thích</h2>
<p class="mt-2 max-w-md text-sm leading-6 text-slate-300"> <p class="mt-2 max-w-md text-sm leading-6 text-slate-300">
Bấm nút Yêu thích trang phim để lưu phim vào đây. Bấm nút Yêu thích trang phim để lưu phim vào đây.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

+72 -11
View File
@@ -7,13 +7,17 @@
"name": "cinek", "name": "cinek",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"hls.js": "^1.6.16", "hls.js": "^1.6.16",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"mysql2": "^3.23.1", "mysql2": "^3.23.1",
"nuxt": "^4.4.5", "nuxt": "^4.4.5",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
@@ -1408,6 +1412,73 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.1.tgz",
"integrity": "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.3.1.tgz",
"integrity": "sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.3.1.tgz",
"integrity": "sha512-q1EsmL7Q8DDnkRBUjSvrxbq7c9oVwwVjCn/xa5apKmdp65YSgzUg2y0Ltnd5aDbT6GdAQQdXql5Ha90ArqIReQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.3.3.tgz",
"integrity": "sha512-Jbjze98gGcVBSdkscLbnDlHECumMAFVWDarMtycctn9qL3p92ApnV36VW4PwVrcm9IWrHnnE6ZL9x7MXStSCfA==",
"license": "MIT",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
"vue": ">= 3.0.0 < 4"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
@@ -8008,16 +8079,6 @@
"url": "https://github.com/sponsors/wellwelwel" "url": "https://github.com/sponsors/wellwelwel"
} }
}, },
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-regexp": { "node_modules/magic-regexp": {
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz", "resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",
+5 -1
View File
@@ -13,13 +13,17 @@
"db:migrate": "drizzle-kit migrate" "db:migrate": "drizzle-kit migrate"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"hls.js": "^1.6.16", "hls.js": "^1.6.16",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"mysql2": "^3.23.1", "mysql2": "^3.23.1",
"nuxt": "^4.4.5", "nuxt": "^4.4.5",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",