mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 14:27:47 +00:00
feat: change libraly icons
This commit is contained in:
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { CirclePlay, Globe2, MessageCircle, Music2, Send } from 'lucide-vue-next'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const socialLinks = [
|
||||
{ label: 'Telegram', icon: Send },
|
||||
{ label: 'Chat', icon: MessageCircle },
|
||||
{ label: 'Website', icon: Globe2 },
|
||||
{ label: 'Cộng đồng', icon: MessageCircle },
|
||||
{ label: 'TikTok', icon: Music2 },
|
||||
{ label: 'Video', icon: CirclePlay },
|
||||
{ label: 'Telegram', icon: 'send' as const },
|
||||
{ label: 'Chat', icon: 'message-circle' as const },
|
||||
{ label: 'Website', icon: 'globe' as const },
|
||||
{ label: 'Cộng đồng', icon: 'message-circle' as const },
|
||||
{ label: 'TikTok', icon: 'music' as const },
|
||||
{ label: 'Video', icon: 'circle-play' as const },
|
||||
]
|
||||
|
||||
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"
|
||||
:aria-label="item.label"
|
||||
>
|
||||
<component :is="item.icon" class="size-5" />
|
||||
<AppIcon :name="item.icon" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Heart,
|
||||
History,
|
||||
LogOut,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||
@@ -42,12 +31,12 @@ const navItems = [
|
||||
|
||||
const memberMenuItems = computed(() => {
|
||||
const items = [
|
||||
{ label: 'Trang cá nhân', icon: User, to: '/thanh-vien' },
|
||||
{ label: 'Yêu thích', icon: Heart, to: '/yeu-thich' },
|
||||
{ label: 'Lịch sử', icon: History, to: '/lich-su' },
|
||||
{ label: 'Trang cá nhân', icon: 'user' as const, to: '/thanh-vien' },
|
||||
{ label: 'Yêu thích', icon: 'heart' as const, to: '/yeu-thich' },
|
||||
{ label: 'Lịch sử', icon: 'history' as const, to: '/lich-su' },
|
||||
]
|
||||
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
|
||||
})
|
||||
@@ -109,7 +98,7 @@ watch(() => route.path, () => {
|
||||
<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"
|
||||
@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..."
|
||||
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
|
||||
</form>
|
||||
@@ -151,14 +140,14 @@ watch(() => route.path, () => {
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</NuxtLink>
|
||||
<div class="my-1.5 border-t border-white/[0.06]" />
|
||||
<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"
|
||||
@click="handleLogout">
|
||||
<LogOut class="size-4 shrink-0" />
|
||||
<AppIcon name="log-out" class="size-4 shrink-0" />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
@@ -168,7 +157,7 @@ watch(() => route.path, () => {
|
||||
<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"
|
||||
aria-label="Mở menu" @click="mobileMenuOpen = true">
|
||||
<Menu class="size-5" />
|
||||
<AppIcon name="menu" class="size-5" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -182,7 +171,7 @@ watch(() => route.path, () => {
|
||||
<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"
|
||||
aria-label="Đóng menu" @click="closeMobileMenu">
|
||||
<X class="size-5" />
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -210,14 +199,14 @@ watch(() => route.path, () => {
|
||||
<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"
|
||||
@click="closeMobileMenu">
|
||||
<component :is="item.icon" class="size-4 shrink-0" />
|
||||
<AppIcon :name="item.icon" class="size-4 shrink-0" />
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
<div class="my-2 border-t border-white/10" />
|
||||
<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"
|
||||
@click="handleLogout">
|
||||
<LogOut class="size-4 shrink-0" />
|
||||
<AppIcon name="log-out" class="size-4 shrink-0" />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Play } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template>
|
||||
<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"
|
||||
aria-label="CineK">
|
||||
<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">
|
||||
<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 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>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<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 { fetchUser } = useAuth()
|
||||
|
||||
@@ -84,7 +82,7 @@ watch(open, (val) => {
|
||||
<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"
|
||||
@click="closeModal">
|
||||
<X class="size-4" />
|
||||
<AppIcon name="x" class="size-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@@ -132,12 +130,12 @@ watch(open, (val) => {
|
||||
<button type="button"
|
||||
class="mb-3 flex items-center gap-1.5 text-sm text-white/50 transition hover:text-white"
|
||||
@click="mode = 'login'">
|
||||
<ArrowLeft class="size-3.5" />
|
||||
<AppIcon name="arrow-left" class="size-3.5" />
|
||||
Quay lại đăng nhập
|
||||
</button>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<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>
|
||||
<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">
|
||||
<div v-if="mode === 'forgot'" :key="'forgot'" class="space-y-3">
|
||||
<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"
|
||||
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>
|
||||
@@ -159,28 +157,28 @@ watch(open, (val) => {
|
||||
<div v-else :key="mode" class="space-y-3">
|
||||
<Transition name="form-field">
|
||||
<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ị"
|
||||
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>
|
||||
</Transition>
|
||||
|
||||
<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"
|
||||
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 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"
|
||||
: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">
|
||||
<button type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
|
||||
@click="showPassword = !showPassword">
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
<AppIcon v-if="showPassword" name="eye-off" class="size-4" />
|
||||
<AppIcon v-else name="eye" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +239,7 @@ watch(open, (val) => {
|
||||
|
||||
<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">
|
||||
<Check class="size-4 shrink-0" />
|
||||
<AppIcon name="check" class="size-4 shrink-0" />
|
||||
{{ success }}
|
||||
</p>
|
||||
</Transition>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { CornerDownLeft, Crown, Image, Loader2, ThumbsDown, ThumbsUp, Trash2, User, Venus, Mars, Minus } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
replies: any[]
|
||||
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]' : ''">
|
||||
<img v-if="reply.userAvatar" :src="reply.userAvatar"
|
||||
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 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">
|
||||
<Crown class="size-2 text-slate-950" />
|
||||
class="absolute -top-0.5 -left-0.5 size-3 bg-yellow-400 rounded-full flex items-center justify-center ring-1 ring-[#0d0f17]">
|
||||
<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 class="flex-1 min-w-0">
|
||||
@@ -53,9 +51,9 @@ function formatMentions(content: string) {
|
||||
<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>
|
||||
<span class="text-xs font-semibold text-white">{{ reply.userName }}</span>
|
||||
<Venus v-if="reply.userGender === 'female'" class="size-3 text-pink-400" />
|
||||
<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="venus" v-if="reply.userGender === 'female'" class="size-3 text-pink-400" />
|
||||
<AppIcon name="mars" v-if="reply.userGender === 'male'" class="size-3 text-blue-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>
|
||||
</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"
|
||||
@@ -67,22 +65,22 @@ function formatMentions(content: string) {
|
||||
<button type="button" @click="handleVote(reply.id, reply.userVote === 1 ? 0 : 1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
: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 type="button" @click="handleVote(reply.id, reply.userVote === -1 ? 0 : -1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
: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 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">
|
||||
<CornerDownLeft class="size-3" /><span>Trả lời</span>
|
||||
<AppIcon name="corner-down-left" class="size-3" /><span>Trả lời</span>
|
||||
</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"
|
||||
:disabled="props.isDeleting?.has(reply.id)">
|
||||
<Loader2 v-if="props.isDeleting?.has(reply.id)" class="size-3 animate-spin" />
|
||||
<Trash2 v-else class="size-3" />
|
||||
<AppIcon name="loader" v-if="props.isDeleting?.has(reply.id)" class="size-3 animate-spin" />
|
||||
<AppIcon name="trash" v-else class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reply form -->
|
||||
@@ -103,18 +101,20 @@ function formatMentions(content: string) {
|
||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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)"
|
||||
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)"
|
||||
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]">
|
||||
<Loader2 v-if="props.isSubmittingReply?.[reply.id]" class="size-3 animate-spin inline" />
|
||||
<span v-else>Gửi</span>
|
||||
<span>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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<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<{
|
||||
source: string
|
||||
@@ -348,7 +347,7 @@ watch(() => props.slug, () => {
|
||||
<template>
|
||||
<div class="mt-10 border-t border-white/10 pt-8">
|
||||
<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>
|
||||
<span class="text-sm text-slate-400">({{ comments.length }})</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-8 p-4 rounded-xl bg-[#13151f] border border-white/5">
|
||||
<div class="flex gap-3">
|
||||
<div class="size-9 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<img v-if="user?.avatar" :src="user.avatar" class="size-9 rounded-full object-cover" alt="" />
|
||||
<User v-else class="size-5 text-slate-500" />
|
||||
<div v-else class="mb-8 rounded-2xl bg-[#181a24] border border-white/5 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-5 pt-4 pb-2">
|
||||
<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-10 rounded-full object-cover" alt="" />
|
||||
<AppIcon name="user" v-else class="size-5 text-slate-500" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<textarea v-model="commentContent" @input="triggerAutoSaveDraft" placeholder="Viết bình luận..." rows="3"
|
||||
<span class="text-sm font-semibold text-white">{{ user.name || user.email?.split('@')[0] }}</span>
|
||||
</div>
|
||||
<div class="px-5">
|
||||
<textarea v-model="commentContent" @input="triggerAutoSaveDraft" placeholder="Viết bình luận" rows="3"
|
||||
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" />
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||
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>
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button type="button" @click="isContentHidden = !isContentHidden"
|
||||
class="relative w-9 h-5 rounded-full transition-colors"
|
||||
:class="isContentHidden ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="isContentHidden ? 'translate-x-4' : 'translate-x-0'" />
|
||||
class="relative w-10 h-6 rounded-full transition-colors"
|
||||
:class="isContentHidden ? 'bg-[#FFD166]' : 'bg-white/15'">
|
||||
<span class="absolute top-1 size-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="isContentHidden ? 'left-5' : 'left-1'" />
|
||||
</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 class="flex items-center gap-3">
|
||||
<span class="text-xs text-slate-600">{{ commentContent.length }} / 1000</span>
|
||||
<button type="button" :disabled="!commentContent.trim() || isCommentSubmitting"
|
||||
@click="handleSubmitComment"
|
||||
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">
|
||||
<Loader2 v-if="isCommentSubmitting" class="size-3.5 animate-spin" />
|
||||
<Send class="size-3.5" v-else />
|
||||
<button type="button" class="flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 transition">
|
||||
<AppIcon name="image" class="size-4" /><span class="text-white/50">GIF</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs text-white/30">{{ commentContent.length }} / 1000</span>
|
||||
<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>
|
||||
<AppIcon name="loader" v-if="isCommentSubmitting" class="size-4 animate-spin" />
|
||||
<AppIcon name="send" class="size-4" v-else />
|
||||
</button>
|
||||
</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="size-1.5 rounded-full bg-yellow-400/50 inline-block" />
|
||||
Có 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -417,33 +422,33 @@ watch(() => props.slug, () => {
|
||||
<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'">
|
||||
<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 class="flex gap-3">
|
||||
<div class="shrink-0 relative">
|
||||
<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]' : ''">
|
||||
<img v-if="comment.userAvatar" :src="comment.userAvatar"
|
||||
class="size-10 rounded-full object-cover" alt="" />
|
||||
<User v-else class="size-5 text-slate-500" />
|
||||
<img v-if="comment.userAvatar" :src="comment.userAvatar" class="size-10 rounded-full object-cover"
|
||||
alt="" />
|
||||
<AppIcon name="user" v-else class="size-5 text-slate-500" />
|
||||
</div>
|
||||
<div v-if="comment.userRole === 'admin'"
|
||||
class="absolute -top-1 -left-1 size-4 bg-cinek-500 rounded-full flex items-center justify-center">
|
||||
<Crown class="size-2.5 text-slate-950" />
|
||||
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]">
|
||||
<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 class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<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">
|
||||
<Pin class="size-3" />Đã ghim
|
||||
<AppIcon name="pin" class="size-3" />Đã ghim
|
||||
</span>
|
||||
<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>
|
||||
<span class="text-sm font-semibold text-white">{{ comment.userName }}</span>
|
||||
<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" />
|
||||
<Minus v-if="comment.userGender === 'other'" class="size-3.5 text-purple-400" />
|
||||
<AppIcon name="venus" v-if="comment.userGender === 'female'" class="size-3.5 text-pink-400" />
|
||||
<AppIcon name="mars" v-if="comment.userGender === 'male'" class="size-3.5 text-blue-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>
|
||||
</div>
|
||||
<div class="relative">
|
||||
@@ -457,26 +462,26 @@ watch(() => props.slug, () => {
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
: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 type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
: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 type="button" @click="startReply(comment.id)"
|
||||
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 v-if="user && user.role === 'admin'" type="button" @click="handleTogglePin(comment.id)"
|
||||
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'">
|
||||
<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 v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button"
|
||||
@click="handleDeleteComment(comment.id)"
|
||||
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>
|
||||
</div>
|
||||
<div v-if="isReplyingTo === comment.id" class="mt-4">
|
||||
@@ -495,16 +500,21 @@ watch(() => props.slug, () => {
|
||||
</button>
|
||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||
</div>
|
||||
<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>
|
||||
<button type="button"
|
||||
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>
|
||||
</div>
|
||||
<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)"
|
||||
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)"
|
||||
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>
|
||||
@@ -512,8 +522,8 @@ watch(() => props.slug, () => {
|
||||
<div v-if="comment.replies?.length" class="mt-4">
|
||||
<button @click="toggleExpand(comment.id)"
|
||||
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" />
|
||||
<ChevronDown v-else class="size-3" />
|
||||
<AppIcon name="chevron-up" v-if="expandedComments.has(comment.id)" 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ị
|
||||
${countAllReplies(comment.replies)} phản hồi` }}</span>
|
||||
</button>
|
||||
@@ -521,10 +531,9 @@ watch(() => props.slug, () => {
|
||||
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
|
||||
<div class="space-y-3">
|
||||
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
||||
:is-replying-to="isReplyingTo" :reply-content="replyContent"
|
||||
:is-reply-anonymous="isReplyAnonymous" :is-submitting-reply="isSubmittingReply"
|
||||
@start-reply="startReply" @cancel-reply="cancelReply" @submit-reply="submitReply"
|
||||
@vote="handleVote" @delete="handleDeleteComment" />
|
||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||
:is-submitting-reply="isSubmittingReply" @start-reply="startReply" @cancel-reply="cancelReply"
|
||||
@submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,7 +548,7 @@ watch(() => props.slug, () => {
|
||||
<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"
|
||||
: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>
|
||||
</button>
|
||||
@@ -551,7 +560,7 @@ watch(() => props.slug, () => {
|
||||
|
||||
<div v-if="!comments.length && !isCommentsLoading"
|
||||
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 có bình luận nào.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, ChevronUp, Circle, Heart, Play } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
movie: {
|
||||
source: string
|
||||
@@ -139,14 +137,14 @@ onBeforeUnmount(() => {
|
||||
<div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2">
|
||||
<span
|
||||
class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-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
|
||||
</span>
|
||||
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white">
|
||||
<Heart class="size-5 fill-current" />
|
||||
<AppIcon name="heart" class="size-5 fill-current" />
|
||||
</span>
|
||||
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white">
|
||||
<Circle class="size-5 fill-current" />
|
||||
<AppIcon name="circle" class="size-5 fill-current" />
|
||||
</span>
|
||||
</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"
|
||||
@click.stop="isDescriptionExpanded = !isDescriptionExpanded">
|
||||
<template v-if="isDescriptionExpanded">
|
||||
Thu gọn <ChevronUp class="size-3" />
|
||||
Thu gọn <AppIcon name="chevron-up" class="size-3" />
|
||||
</template>
|
||||
<template v-else>
|
||||
Xem thêm <ChevronDown class="size-3" />
|
||||
Xem thêm <AppIcon name="chevron-down" class="size-3" />
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUp } from 'lucide-vue-next'
|
||||
|
||||
const isVisible = ref(false)
|
||||
|
||||
function updateVisibility() {
|
||||
@@ -37,7 +35,7 @@ onBeforeUnmount(() => {
|
||||
aria-label="Lên đầu trang"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ArrowUp class="size-6" />
|
||||
<AppIcon name="arrow-up" class="size-6" />
|
||||
</button>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Home, Search } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
error: {
|
||||
statusCode: number
|
||||
|
||||
+10
-20
@@ -1,14 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Film,
|
||||
Home,
|
||||
LogOut,
|
||||
Menu,
|
||||
Settings,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sidebarOpen = ref(false)
|
||||
@@ -18,10 +8,10 @@ const { data: user } = await useFetch('/api/auth/me', {
|
||||
})
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', icon: Home, to: '/admin' },
|
||||
{ label: 'Phim', icon: Film, to: '/admin/phim' },
|
||||
{ label: 'Thành viên', icon: Users, to: '/admin/thanh-vien' },
|
||||
{ label: 'Cài đặt', icon: Settings, to: '/admin/cai-dat' },
|
||||
{ label: 'Dashboard', icon: 'home' as const, to: '/admin' },
|
||||
{ label: 'Phim', icon: 'film' as const, to: '/admin/phim' },
|
||||
{ label: 'Thành viên', icon: 'users' as const, to: '/admin/thanh-vien' },
|
||||
{ label: 'Cài đặt', icon: 'settings' as const, to: '/admin/cai-dat' },
|
||||
]
|
||||
|
||||
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">
|
||||
<NuxtLink to="/admin" class="flex items-center gap-2">
|
||||
<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>
|
||||
<span class="text-lg font-black text-white">CineK Admin</span>
|
||||
</NuxtLink>
|
||||
<button type="button" class="grid size-8 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden"
|
||||
@click="closeSidebar">
|
||||
<X class="size-5" />
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +56,7 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
:class="isActive(item.to)
|
||||
? 'bg-yellow-400/10 text-yellow-400'
|
||||
: '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 }}
|
||||
</NuxtLink>
|
||||
</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">
|
||||
<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">
|
||||
<Home class="size-5" />
|
||||
<AppIcon name="home" class="size-5" />
|
||||
Về trang chủ
|
||||
</NuxtLink>
|
||||
<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"
|
||||
@click="handleLogout">
|
||||
<LogOut class="size-5" />
|
||||
<AppIcon name="log-out" class="size-5" />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</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">
|
||||
<button type="button" class="grid size-10 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden"
|
||||
@click="sidebarOpen = true">
|
||||
<Menu class="size-5" />
|
||||
<AppIcon name="menu" class="size-5" />
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Settings as SettingsIcon } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
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="mb-5 flex items-center gap-3">
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<Save class="size-4" />
|
||||
<AppIcon name="save" class="size-4" />
|
||||
Lưu thay đổi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { BarChart3, Film, Play, RefreshCw, TrendingUp, Tv, Users, X } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
})
|
||||
@@ -77,7 +75,7 @@ function barWidth(views: number) {
|
||||
<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"
|
||||
@click="syncOpen = true">
|
||||
<RefreshCw class="size-4" />
|
||||
<AppIcon name="refresh" class="size-4" />
|
||||
Đồng bộ phim
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,7 +89,7 @@ function barWidth(views: number) {
|
||||
<p class="mt-2 text-3xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -103,7 +101,7 @@ function barWidth(views: number) {
|
||||
<p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -115,7 +113,7 @@ function barWidth(views: number) {
|
||||
<p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -127,7 +125,7 @@ function barWidth(views: number) {
|
||||
<p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -315,7 +313,7 @@ function barWidth(views: number) {
|
||||
<button type="button"
|
||||
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10"
|
||||
@click="syncOpen = false">
|
||||
<X class="size-5" />
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
|
||||
<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"
|
||||
: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ộ' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Eye, EyeOff, LockKeyhole, LogIn, Mail } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
middleware: [],
|
||||
@@ -65,7 +63,7 @@ async function handleLogin() {
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-white">Email</label>
|
||||
<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"
|
||||
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>
|
||||
@@ -74,15 +72,15 @@ async function handleLogin() {
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-white">Mật khẩu</label>
|
||||
<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"
|
||||
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">
|
||||
<button type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white"
|
||||
@click="showPassword = !showPassword">
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
<AppIcon name="eye-off" v-if="showPassword" class="size-4" />
|
||||
<AppIcon name="eye" v-else class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +89,7 @@ async function handleLogin() {
|
||||
<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"
|
||||
:disabled="loading">
|
||||
<LogIn class="size-4" />
|
||||
<AppIcon name="log-in" class="size-4" />
|
||||
{{ loading ? 'Đang đăng nhập...' : 'Đăng nhập' }}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Check, Film, Image, Plus, Save, Trash2, Type } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
})
|
||||
@@ -74,7 +72,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
|
||||
<div>
|
||||
<NuxtLink to="/admin/phim"
|
||||
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
|
||||
</NuxtLink>
|
||||
|
||||
@@ -93,7 +91,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
|
||||
<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"
|
||||
:disabled="saving" @click="handleSave">
|
||||
<Save class="size-4" />
|
||||
<AppIcon name="save" class="size-4" />
|
||||
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,7 +99,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
|
||||
<Transition name="fade">
|
||||
<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">
|
||||
<Check class="size-4" />
|
||||
<AppIcon name="check" class="size-4" />
|
||||
Đã lưu thành công
|
||||
</div>
|
||||
</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="mb-4 flex items-center gap-3">
|
||||
<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>
|
||||
<h2 class="text-base font-black text-white">Mô 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="mb-4 flex items-center gap-3">
|
||||
<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 class="flex-1">
|
||||
<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"
|
||||
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">
|
||||
<Plus class="size-3.5" />
|
||||
<AppIcon name="plus" class="size-3.5" />
|
||||
Thêm tập
|
||||
</button>
|
||||
</div>
|
||||
@@ -152,7 +150,7 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
|
||||
<button type="button"
|
||||
class="grid size-7 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
|
||||
@click="removeEpisode(index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
<AppIcon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<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="mb-4 flex items-center gap-3">
|
||||
<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>
|
||||
<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="mb-4 flex items-center gap-3">
|
||||
<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>
|
||||
<h2 class="text-base font-black text-white">Ảnh thumbnail</h2>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, RefreshCw, Search, Trash2, X } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
})
|
||||
@@ -104,13 +102,13 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
<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"
|
||||
@click="deleteConfirmOpen = true">
|
||||
<Trash2 class="size-4" />
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
Xoá tất cả
|
||||
</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"
|
||||
@click="syncOpen = true">
|
||||
<RefreshCw class="size-4" />
|
||||
<AppIcon name="refresh" class="size-4" />
|
||||
Đồng bộ từ API
|
||||
</button>
|
||||
</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="flex flex-col gap-3 border-b border-white/10 p-4 sm:flex-row sm:items-center">
|
||||
<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..."
|
||||
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>
|
||||
@@ -208,7 +206,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
<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"
|
||||
title="Chỉnh sửa">
|
||||
<Pencil class="size-4" />
|
||||
<AppIcon name="pencil" class="size-4" />
|
||||
</NuxtLink>
|
||||
<AdminToggle :model-value="movie.active"
|
||||
@update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" />
|
||||
@@ -249,7 +247,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
<button type="button"
|
||||
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10"
|
||||
@click="syncOpen = false">
|
||||
<X class="size-5" />
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
|
||||
<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"
|
||||
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">
|
||||
<RefreshCw class="size-4" :class="syncing ? 'animate-spin' : ''" />
|
||||
<AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
|
||||
{{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -318,7 +316,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
<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"
|
||||
:disabled="deleting" @click="handleDeleteAll">
|
||||
<Trash2 class="size-4" />
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
{{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Edit, Mail, Search, Trash2, ToggleLeft, ToggleRight, UserRound } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
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="flex items-center gap-3 border-b border-white/10 p-4">
|
||||
<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..."
|
||||
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>
|
||||
@@ -123,7 +121,7 @@ const members = computed(() => data.value?.items || [])
|
||||
:disabled="currentUser?.id === member.id"
|
||||
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
|
||||
@click="deleteMember(member)">
|
||||
<Trash2 class="size-4" />
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Film, Globe2, Heart, Play, Shield, Zap } from 'lucide-vue-next'
|
||||
|
||||
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: 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: 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: 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: Globe2, 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: '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' 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' 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' 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: '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' 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>
|
||||
|
||||
@@ -29,7 +27,7 @@ const features = [
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
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"
|
||||
@click="toggle(section.category, index)">
|
||||
<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' : ''" />
|
||||
</button>
|
||||
<Transition name="faq">
|
||||
|
||||
+4
-5
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Heart, Info, Play, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { WatchHistoryItem } from '~/composables/useWatchHistory'
|
||||
|
||||
const { data, pending, error } = useFetch('/api/movies', {
|
||||
@@ -384,7 +383,7 @@ useHead({
|
||||
<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"
|
||||
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>
|
||||
|
||||
<!-- Info + Add to List combo button -->
|
||||
@@ -394,7 +393,7 @@ useHead({
|
||||
<button type="button"
|
||||
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">
|
||||
<Plus class="size-5 md:size-6 text-white" />
|
||||
<AppIcon name="plus" class="size-5 md:size-6 text-white" />
|
||||
</button>
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-white/30"></div>
|
||||
@@ -402,7 +401,7 @@ useHead({
|
||||
<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]"
|
||||
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" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -499,7 +498,7 @@ useHead({
|
||||
<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"
|
||||
aria-label="Xóa lịch sử xem" @click="clearWatchHistory">
|
||||
<Trash2 class="size-4" />
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
const { data, pending, error } = await useFetch('/api/movies', {
|
||||
query: { page: 1 },
|
||||
})
|
||||
@@ -158,7 +156,7 @@ useHead({
|
||||
|
||||
<section class="schedule-shell">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -180,7 +178,7 @@ useHead({
|
||||
<button v-if="selectedDayIndex > 0" type="button"
|
||||
class="schedule-arrow schedule-arrow-left"
|
||||
aria-label="Ngày trước" @click="selectPreviousDay">
|
||||
<ChevronLeft />
|
||||
<AppIcon name="chevron-left" />
|
||||
</button>
|
||||
|
||||
<div class="schedule-tabs">
|
||||
@@ -196,7 +194,7 @@ useHead({
|
||||
<button v-if="selectedDayIndex < scheduleDays.length - 1" type="button"
|
||||
class="schedule-arrow schedule-arrow-right"
|
||||
aria-label="Ngày sau" @click="selectNextDay">
|
||||
<ChevronRight />
|
||||
<AppIcon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +218,7 @@ useHead({
|
||||
</div>
|
||||
|
||||
<div v-else class="schedule-empty">
|
||||
<CalendarDays />
|
||||
<AppIcon name="calendar-days" />
|
||||
<p class="mt-4 text-sm text-slate-400">Chưa có phim nào trong ngày này.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Clock3, Trash2 } from 'lucide-vue-next'
|
||||
import type { WatchHistoryItem } from '~/composables/useWatchHistory'
|
||||
|
||||
const { loadWatchHistory, clearWatchHistory } = useWatchHistory()
|
||||
@@ -86,7 +85,7 @@ useHead({
|
||||
<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"
|
||||
@click="removeHistory">
|
||||
<Trash2 class="size-4" />
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
Xóa lịch sử
|
||||
</button>
|
||||
</div>
|
||||
@@ -103,7 +102,7 @@ useHead({
|
||||
<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">
|
||||
<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 class="absolute inset-x-0 bottom-0 h-1 bg-white/18">
|
||||
<span class="block h-full bg-yellow-300" :style="{ width: `${watchProgressPercent(item)}%` }" />
|
||||
@@ -125,7 +124,7 @@ useHead({
|
||||
|
||||
<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">
|
||||
<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 có lịch sử xem</h2>
|
||||
<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 có thể xem tiếp nhanh hơn.
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Mail, MessageCircle, Send } from 'lucide-vue-next'
|
||||
|
||||
const contactMethods = [
|
||||
{
|
||||
icon: Send,
|
||||
icon: 'send' as const,
|
||||
label: 'Telegram',
|
||||
value: '@cinek_support',
|
||||
link: 'https://telegram.dog/cinek',
|
||||
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',
|
||||
value: 'Group Facebook CineK',
|
||||
link: '#',
|
||||
desc: 'Tham gia nhóm để trao đổi, chia sẻ phim.',
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
icon: 'mail' as const,
|
||||
label: 'Email',
|
||||
value: '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"
|
||||
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">
|
||||
<component :is="method.icon" class="size-6" />
|
||||
<AppIcon :name="method.icon" class="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-white group-hover:text-yellow-300 transition">{{ method.label }}</h3>
|
||||
|
||||
+10
-12
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, ChevronRight, Heart, MessageSquare, Play, Plus, Server, Share2, Star, Video } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const requestedSource = computed(() => String(route.query.source || 'nguonc'))
|
||||
const requestedSources = computed(() => typeof route.query.srcs === 'string' ? route.query.srcs : '')
|
||||
@@ -272,7 +270,7 @@ useHead(() => ({
|
||||
<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"
|
||||
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
|
||||
</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="isFavoriteMovie ? 'text-yellow-300' : 'text-white/90'" :disabled="actionBusy"
|
||||
@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>
|
||||
</button>
|
||||
<div class="w-px h-8 bg-white/10 mx-1" />
|
||||
<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"
|
||||
:disabled="actionBusy" @click="addToWatchLater">
|
||||
<Plus class="size-5" />
|
||||
<AppIcon name="plus" class="size-5" />
|
||||
<span class="text-[11px]">Thêm vào</span>
|
||||
</button>
|
||||
<div class="w-px h-8 bg-white/10 mx-1" />
|
||||
<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"
|
||||
@click="shareMovie">
|
||||
<Share2 class="size-5" />
|
||||
<AppIcon name="share" class="size-5" />
|
||||
<span class="text-[11px]">Chia sẻ</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -308,19 +306,19 @@ useHead(() => ({
|
||||
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"
|
||||
@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>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95 text-white/90"
|
||||
:disabled="actionBusy" @click="addToWatchLater">
|
||||
<Plus class="size-6" />
|
||||
<AppIcon name="plus" class="size-6" />
|
||||
<span class="text-[11px]">Thêm vào</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="flex flex-col items-center justify-center gap-1 transition-all active:scale-95 text-white/90"
|
||||
@click="shareMovie">
|
||||
<Share2 class="size-6" />
|
||||
<AppIcon name="share" class="size-6" />
|
||||
<span class="text-[11px]">Chia sẻ</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -364,7 +362,7 @@ useHead(() => ({
|
||||
class="w-full flex items-center justify-between text-sm font-semibold text-yellow-200 py-2"
|
||||
@click="movieInfoOpen = !movieInfoOpen">
|
||||
<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>
|
||||
<div v-if="movieInfoOpen" class="space-y-2 text-sm text-slate-300">
|
||||
<div class="flex flex-wrap gap-2 text-[10px] mb-3">
|
||||
@@ -439,7 +437,7 @@ useHead(() => ({
|
||||
<!-- Server Selector -->
|
||||
<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">
|
||||
<Server class="size-4" />
|
||||
<AppIcon name="server" class="size-4" />
|
||||
Máy chủ:
|
||||
</div>
|
||||
<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}`"
|
||||
: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">
|
||||
<Play class="size-3 fill-current" />
|
||||
<AppIcon name="play" class="size-3 fill-current" />
|
||||
{{ formatEpisodeName(episode.name, index) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Search } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
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 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..."
|
||||
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
|
||||
</div>
|
||||
@@ -206,14 +204,14 @@ useHead({
|
||||
<button type="button" :disabled="page === 1"
|
||||
class="grid size-11 place-items-center rounded-full border border-white/15 bg-white/8 text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="Trang trước" @click="previousPage">
|
||||
<ChevronLeft class="size-5" />
|
||||
<AppIcon name="chevron-left" class="size-5" />
|
||||
</button>
|
||||
<span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages
|
||||
}}</template></span>
|
||||
<button type="button"
|
||||
class="grid size-11 place-items-center rounded-full bg-yellow-300 text-slate-950 hover:bg-white"
|
||||
aria-label="Trang sau" @click="nextPage">
|
||||
<ChevronRight class="size-5" />
|
||||
<AppIcon name="chevron-right" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+43
-45
@@ -1,6 +1,4 @@
|
||||
<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' })
|
||||
|
||||
const { user, logout: doLogout, fetchUser } = useAuth()
|
||||
@@ -30,9 +28,9 @@ const genderError = ref('')
|
||||
const genderSuccess = ref('')
|
||||
|
||||
const genderOptions = [
|
||||
{ value: 'male', label: 'Nam', icon: Mars },
|
||||
{ value: 'female', label: 'Nữ', icon: Venus },
|
||||
{ value: 'other', label: 'Khác', icon: Minus },
|
||||
{ value: 'male', label: 'Nam', icon: 'mars' as const },
|
||||
{ value: 'female', label: 'Nữ', icon: 'venus' as const },
|
||||
{ value: 'other', label: 'Khác', icon: 'minus' as const },
|
||||
]
|
||||
|
||||
const avatarCategories = [
|
||||
@@ -65,10 +63,10 @@ const avatarGrid = computed(() => {
|
||||
})
|
||||
|
||||
const sidebarItems = [
|
||||
{ id: 'account', label: 'Quản lý tài khoản', icon: Settings },
|
||||
{ id: 'password', label: 'Đổi mật khẩu', icon: KeyRound },
|
||||
{ id: 'history', label: 'Lịch sử xem', icon: Clock3 },
|
||||
{ id: 'favorites', label: 'Phim yêu thích', icon: Heart },
|
||||
{ id: 'account', label: 'Quản lý tài khoản', icon: 'settings' as const },
|
||||
{ id: 'password', label: 'Đổi mật khẩu', icon: 'key' as const },
|
||||
{ id: 'history', label: 'Lịch sử xem', icon: 'clock' as const },
|
||||
{ id: 'favorites', label: 'Phim yêu thích', icon: 'heart' as const },
|
||||
]
|
||||
|
||||
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'">
|
||||
<span class="text-sm font-bold text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
|
||||
</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" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -283,7 +281,7 @@ useHead({
|
||||
:class="activeTab === item.id
|
||||
? 'bg-[#FFD166]/10 text-[#FFD166]'
|
||||
: '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>
|
||||
</button>
|
||||
|
||||
@@ -291,14 +289,14 @@ useHead({
|
||||
|
||||
<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">
|
||||
<ArrowLeft class="size-4" />
|
||||
<AppIcon name="arrow-left" class="size-4" />
|
||||
<span>Về trang chủ</span>
|
||||
</NuxtLink>
|
||||
|
||||
<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"
|
||||
@click="handleLogout">
|
||||
<LogOut class="size-4" />
|
||||
<AppIcon name="log-out" class="size-4" />
|
||||
<span>Đăng xuất</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -310,7 +308,7 @@ useHead({
|
||||
<div v-if="activeTab === 'account'" :key="'account'"
|
||||
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">
|
||||
<Settings class="size-5 text-[#FFD166]" />
|
||||
<AppIcon name="settings" class="size-5 text-[#FFD166]" />
|
||||
<h2 class="text-lg font-bold">Quản lý tài khoản</h2>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +317,7 @@ useHead({
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-white/40">Tên hiển thị</label>
|
||||
<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ị"
|
||||
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">
|
||||
@@ -334,7 +332,7 @@ useHead({
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-white/40">Email</label>
|
||||
<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
|
||||
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
|
||||
@@ -357,29 +355,29 @@ useHead({
|
||||
? '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'"
|
||||
:disabled="genderLoading" @click="saveGender(opt.value)">
|
||||
<component :is="opt.icon" class="size-4" />
|
||||
<AppIcon :name="opt.icon" class="size-4" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
</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">
|
||||
<X class="size-4 shrink-0" />
|
||||
<AppIcon name="x" class="size-4 shrink-0" />
|
||||
{{ nameError }}
|
||||
</p>
|
||||
<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">
|
||||
<Check class="size-4 shrink-0" />
|
||||
<AppIcon name="check" class="size-4 shrink-0" />
|
||||
{{ nameSuccess }}
|
||||
</p>
|
||||
<p v-if="genderError"
|
||||
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 }}
|
||||
</p>
|
||||
<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">
|
||||
<Check class="size-4 shrink-0" />
|
||||
<AppIcon name="check" class="size-4 shrink-0" />
|
||||
{{ genderSuccess }}
|
||||
</p>
|
||||
|
||||
@@ -387,19 +385,19 @@ useHead({
|
||||
<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]"
|
||||
@click="avatarModalOpen = true">
|
||||
<Camera class="size-4" />
|
||||
<AppIcon name="camera" class="size-4" />
|
||||
Đổi avatar
|
||||
</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]"
|
||||
@click="activeTab = 'password'">
|
||||
<KeyRound class="size-4" />
|
||||
<AppIcon name="key" class="size-4" />
|
||||
Đổi mật khẩu
|
||||
</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"
|
||||
@click="handleLogout">
|
||||
<LogOut class="size-4" />
|
||||
<AppIcon name="log-out" class="size-4" />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
@@ -415,12 +413,12 @@ useHead({
|
||||
: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>
|
||||
</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" />
|
||||
<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]"
|
||||
@click="avatarModalOpen = true">
|
||||
<Camera class="size-4" />
|
||||
<AppIcon name="camera" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-center text-sm font-semibold">{{ memberName }}</p>
|
||||
@@ -432,7 +430,7 @@ useHead({
|
||||
<div v-else-if="activeTab === 'password'" :key="'password'"
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -440,15 +438,15 @@ useHead({
|
||||
<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>
|
||||
<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
|
||||
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">
|
||||
<button type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
|
||||
@click="showPassword = !showPassword">
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
<AppIcon name="eye-off" v-if="showPassword" class="size-4" />
|
||||
<AppIcon name="eye" v-else class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,15 +454,15 @@ useHead({
|
||||
<div class="relative">
|
||||
<label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu mới</label>
|
||||
<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"
|
||||
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">
|
||||
<button type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
|
||||
@click="showNewPassword = !showNewPassword">
|
||||
<EyeOff v-if="showNewPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
<AppIcon name="eye-off" v-if="showNewPassword" class="size-4" />
|
||||
<AppIcon name="eye" v-else class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,15 +470,15 @@ useHead({
|
||||
<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>
|
||||
<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
|
||||
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">
|
||||
<button type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
|
||||
@click="showConfirmPassword = !showConfirmPassword">
|
||||
<EyeOff v-if="showConfirmPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
<AppIcon name="eye-off" v-if="showConfirmPassword" class="size-4" />
|
||||
<AppIcon name="eye" v-else class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -493,13 +491,13 @@ useHead({
|
||||
|
||||
<p v-if="passwordError"
|
||||
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 }}
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<Check class="size-4 shrink-0" />
|
||||
<AppIcon name="check" class="size-4 shrink-0" />
|
||||
{{ passwordSuccess }}
|
||||
</p>
|
||||
</form>
|
||||
@@ -508,13 +506,13 @@ useHead({
|
||||
<div v-else-if="activeTab === 'history'" :key="'history'"
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<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]">
|
||||
<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-1 text-xs text-[#FFD166]">Nhấn để xem →</p>
|
||||
</div>
|
||||
@@ -524,13 +522,13 @@ useHead({
|
||||
<div v-else-if="activeTab === 'favorites'" :key="'favorites'"
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<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]">
|
||||
<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-1 text-xs text-[#FFD166]">Nhấn để xem →</p>
|
||||
</div>
|
||||
@@ -553,13 +551,13 @@ useHead({
|
||||
<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"
|
||||
@click="avatarModalOpen = false">
|
||||
<X class="size-4" />
|
||||
<AppIcon name="x" class="size-4" />
|
||||
</button>
|
||||
|
||||
<div class="p-5 pb-3">
|
||||
<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">
|
||||
<Camera class="size-4 text-[#FFD166]" />
|
||||
<AppIcon name="camera" class="size-4 text-[#FFD166]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white">Chọn avatar</h2>
|
||||
@@ -588,7 +586,7 @@ useHead({
|
||||
<div v-if="avatarUrl === img"
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#FFD166]/20">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
+25
-31
@@ -1,11 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
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 selectedServer = ref(Math.max(Number(route.query.server || 0), 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]">
|
||||
<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">
|
||||
<Loader2 class="size-4 animate-spin" /> Đang tải phim
|
||||
<AppIcon name="loader" class="size-4 animate-spin" /> Đang tải phim
|
||||
</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" />
|
||||
<div v-if="isVideoBuffering"
|
||||
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 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">
|
||||
@@ -482,7 +476,7 @@ useHead(() => ({
|
||||
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"
|
||||
@click="playNextEpisode(true)">
|
||||
<SkipForward class="size-4" /> Tập tiếp
|
||||
<AppIcon name="skip-forward" class="size-4" /> Tập tiếp
|
||||
</button>
|
||||
<button type="button"
|
||||
class="absolute inset-0 grid cursor-pointer place-items-center text-white transition"
|
||||
@@ -491,8 +485,8 @@ useHead(() => ({
|
||||
@pointerleave="stopHoldSpeed" @contextmenu.prevent>
|
||||
<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">
|
||||
<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="pause" v-if="isVideoPlaying" class="size-5 fill-current sm:size-7" />
|
||||
<AppIcon name="play" v-else class="size-5 fill-current sm:size-7" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="gestureHint || isHoldSpeedActive"
|
||||
@@ -509,18 +503,18 @@ useHead(() => ({
|
||||
<div class="kr-hls-actions">
|
||||
<div class="kr-hls-actions-left">
|
||||
<button type="button" class="kr-hls-control-button is-primary" @click="toggleHlsPlayback">
|
||||
<Pause v-if="isVideoPlaying" class="fill-current" />
|
||||
<Play v-else class="fill-current" />
|
||||
<AppIcon name="pause" v-if="isVideoPlaying" class="fill-current" />
|
||||
<AppIcon name="play" v-else class="fill-current" />
|
||||
</button>
|
||||
<button type="button" class="kr-hls-control-button" @click="seekBy(-10)">
|
||||
<Rewind />
|
||||
<AppIcon name="rewind" />
|
||||
</button>
|
||||
<button type="button" class="kr-hls-control-button" @click="seekBy(10)">
|
||||
<FastForward />
|
||||
<AppIcon name="fast-forward" />
|
||||
</button>
|
||||
<button type="button" class="kr-hls-control-button" @click="toggleMute">
|
||||
<VolumeX v-if="isVideoMuted" />
|
||||
<Volume2 v-else />
|
||||
<AppIcon name="volume-x" v-if="isVideoMuted" />
|
||||
<AppIcon name="volume" v-else />
|
||||
</button>
|
||||
<input class="kr-volume-range hidden sm:block" type="range" min="0" max="1" step="0.05"
|
||||
:value="videoVolume" @input="changeVolume">
|
||||
@@ -530,7 +524,7 @@ useHead(() => ({
|
||||
<button type="button" class="kr-hls-control-button"
|
||||
:class="isSettingsOpen ? 'bg-yellow-400 text-slate-950' : ''"
|
||||
@click="isSettingsOpen = !isSettingsOpen; controlsVisible = true">
|
||||
<Settings />
|
||||
<AppIcon name="settings" />
|
||||
</button>
|
||||
<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">
|
||||
@@ -556,14 +550,14 @@ useHead(() => ({
|
||||
</div>
|
||||
<button type="button" class="kr-hls-control-button kr-hls-desktop-button"
|
||||
@click="togglePictureInPicture">
|
||||
<PictureInPicture2 />
|
||||
<AppIcon name="picture-in-picture" />
|
||||
</button>
|
||||
<button v-if="hasNextEpisode" type="button" class="kr-hls-control-button kr-hls-desktop-button"
|
||||
@click="playNextEpisode(true)">
|
||||
<SkipForward />
|
||||
<AppIcon name="skip-forward" />
|
||||
</button>
|
||||
<button type="button" class="kr-hls-control-button" @click="toggleFullscreen">
|
||||
<Maximize />
|
||||
<AppIcon name="maximize" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,7 +570,7 @@ useHead(() => ({
|
||||
<div class="absolute inset-0 grid place-items-center">
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
</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 items-center gap-3">
|
||||
<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 class="text-sm">
|
||||
<span class="font-semibold text-white">{{ formatEpisodeName(activeEpisode?.name, selectedEpisode)
|
||||
@@ -599,12 +593,12 @@ useHead(() => ({
|
||||
<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"
|
||||
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 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"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -656,7 +650,7 @@ useHead(() => ({
|
||||
<!-- Episode Progress -->
|
||||
<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">
|
||||
<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
|
||||
}} tập</span>
|
||||
</div>
|
||||
@@ -670,7 +664,7 @@ useHead(() => ({
|
||||
: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">
|
||||
Thông tin phim
|
||||
<ChevronRight class="size-4" />
|
||||
<AppIcon name="chevron-right" class="size-4" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,7 +676,7 @@ useHead(() => ({
|
||||
<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="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>
|
||||
<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">
|
||||
@@ -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="selectedSubtitle === index ? 'border-white/30 text-white bg-white/5' : 'border-transparent text-white/60 hover:text-white'"
|
||||
@click="selectSubtitle(index)">
|
||||
<Captions v-if="index === 0" class="size-3.5" />
|
||||
<Languages v-else class="size-3.5" />
|
||||
<AppIcon name="captions" v-if="index === 0" class="size-3.5" />
|
||||
<AppIcon name="languages" v-else class="size-3.5" />
|
||||
<span>{{ subtitleLabel(sub, index) }}</span>
|
||||
<span class="rounded px-1.5 text-[10px] font-bold"
|
||||
:class="selectedSubtitle === index ? 'bg-white text-black' : 'bg-white/10 text-white/50'">
|
||||
@@ -728,7 +722,7 @@ useHead(() => ({
|
||||
: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="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) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Heart } from 'lucide-vue-next'
|
||||
import type { LibraryMovieItem } from '~/composables/useMovieLibrary'
|
||||
|
||||
const { loadFavorites } = useMovieLibrary()
|
||||
@@ -58,7 +57,7 @@ useHead({
|
||||
<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">
|
||||
<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>
|
||||
<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
|
||||
@@ -71,7 +70,7 @@ useHead({
|
||||
|
||||
<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">
|
||||
<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 có phim yêu thích</h2>
|
||||
<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.
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 5.4 KiB |
Generated
+72
-11
@@ -7,13 +7,17 @@
|
||||
"name": "cinek",
|
||||
"hasInstallScript": true,
|
||||
"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",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hls.js": "^1.6.16",
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"mysql2": "^3.23.1",
|
||||
"nuxt": "^4.4.5",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -1408,6 +1412,73 @@
|
||||
"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": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
|
||||
@@ -8008,16 +8079,6 @@
|
||||
"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": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",
|
||||
|
||||
+5
-1
@@ -13,13 +13,17 @@
|
||||
"db:migrate": "drizzle-kit migrate"
|
||||
},
|
||||
"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",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hls.js": "^1.6.16",
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"mysql2": "^3.23.1",
|
||||
"nuxt": "^4.4.5",
|
||||
"tailwindcss": "^4.3.0",
|
||||
|
||||
Reference in New Issue
Block a user