feat: implement cache busting mechanism and UI improvements

- Added `useCacheBust` composable to manage cache invalidation after movie edits in the admin panel.
- Integrated cache busting in movie sync and delete actions to ensure data consistency across pages.
- Enhanced UI components in the admin movie management and member management pages for better readability and usability.
- Introduced a suggestions tab in the movie detail page to recommend similar movies based on categories.
- Updated styles for various elements to improve the overall aesthetic and user experience.
This commit is contained in:
ngthanhvu
2026-08-05 05:19:34 -04:00
parent 6619cae6cc
commit a36e6c77f8
13 changed files with 1117 additions and 873 deletions
View File
+1 -1
View File
@@ -7,7 +7,7 @@ const isAdmin = computed(() => route.path.startsWith('/admin'))
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<NuxtLoadingIndicator color="#facc15" :height="2" /> <NuxtLoadingIndicator color="#facc15" :height="2" />
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage :key="$route.fullPath" />
</NuxtLayout> </NuxtLayout>
<AppFooter v-if="!isAdmin" /> <AppFooter v-if="!isAdmin" />
<ScrollToTop v-if="!isAdmin" /> <ScrollToTop v-if="!isAdmin" />
+98
View File
@@ -76,3 +76,101 @@ select:disabled {
.no-scrollbar::-webkit-scrollbar { .no-scrollbar::-webkit-scrollbar {
display: none; display: none;
} }
/* Admin design system — Modern Minimal Enterprise */
@layer utilities {
.admin-page {
@apply min-h-screen bg-[#0A0B0E] text-zinc-100 antialiased;
}
/* Flat, neutral enterprise card */
.admin-card {
@apply rounded-2xl border border-white/[0.07] bg-[#131418] transition-colors duration-200 hover:border-white/[0.14];
}
/* Card with subtle top highlight for hierarchy */
.admin-card-gradient {
@apply rounded-2xl border border-white/[0.07] bg-[#131418] transition-colors duration-200 hover:border-white/[0.14];
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent 40%);
}
.admin-input {
@apply h-11 w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 text-sm text-white placeholder:text-zinc-500 outline-none transition focus:border-yellow-400/40 focus:bg-white/[0.05] focus:ring-2 focus:ring-yellow-400/15;
}
.admin-btn {
@apply inline-flex h-10 items-center justify-center gap-2 rounded-xl px-5 text-sm font-semibold transition-all duration-150 active:scale-[0.98];
}
.admin-btn-primary {
@apply inline-flex h-10 items-center justify-center gap-2 rounded-xl px-5 text-sm font-semibold transition-all duration-150 active:scale-[0.98] bg-yellow-400 text-zinc-950 hover:bg-yellow-300 hover:shadow-lg hover:shadow-yellow-400/20;
}
.admin-btn-secondary {
@apply inline-flex h-10 items-center justify-center gap-2 rounded-xl px-5 text-sm font-semibold transition-all duration-150 active:scale-[0.98] border border-white/[0.08] bg-white/[0.04] text-zinc-100 hover:bg-white/[0.08];
}
.admin-btn-danger {
@apply inline-flex h-10 items-center justify-center gap-2 rounded-xl px-5 text-sm font-semibold transition-all duration-150 active:scale-[0.98] bg-red-500/10 text-red-400 hover:bg-red-500/20;
}
.admin-badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
}
.admin-label {
@apply text-[11px] font-semibold uppercase tracking-[0.14em] text-zinc-500;
}
.admin-section-title {
@apply text-[1.6rem] font-bold tracking-tight text-white;
}
.admin-section-subtitle {
@apply text-sm text-zinc-500;
}
.admin-num {
@apply font-bold tracking-tight text-white tabular-nums;
}
/* Compact controls for dense edit rows */
.admin-input-sm {
@apply h-8 rounded-lg border border-white/[0.08] bg-white/[0.03] px-2.5 text-xs text-white placeholder:text-zinc-500 outline-none transition focus:border-yellow-400/40 focus:bg-white/[0.05];
}
.admin-btn-sm {
@apply inline-flex h-7 items-center justify-center gap-1 rounded-lg px-2.5 text-xs font-semibold transition active:scale-[0.97];
}
.admin-icon-square {
@apply grid size-9 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-zinc-300;
}
}
/* Custom scrollbar for admin tables */
.admin-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.admin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(250, 204, 21, 0.5);
border-radius: 999px;
}
.admin-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03);
}
/* Smooth modal fade */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
transform: scale(0.98);
}
+3 -3
View File
@@ -4,9 +4,9 @@ const model = defineModel<boolean>({ required: true })
<template> <template>
<button type="button" role="switch" :aria-checked="model" <button type="button" role="switch" :aria-checked="model"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-yellow-400/50 focus:ring-offset-2 focus:ring-offset-slate-900" class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-yellow-400/50 focus:ring-offset-2 focus:ring-offset-slate-900"
:class="model ? 'bg-yellow-400' : 'bg-white/15'" @click="model = !model"> :class="model ? 'bg-yellow-400' : 'bg-slate-600'" @click="model = !model">
<span class="pointer-events-none inline-block size-5 rounded-full bg-white shadow-lg transition duration-200 ease-in-out" <span class="pointer-events-none inline-block size-5 rounded-full bg-white shadow-lg transition duration-300 ease-in-out"
:class="model ? 'translate-x-5' : 'translate-x-0'" /> :class="model ? 'translate-x-5' : 'translate-x-0'" />
</button> </button>
</template> </template>
+37
View File
@@ -0,0 +1,37 @@
// Composable để invalidate cached data sau khi edit phim ở admin
const BUST_KEY = 'cinek-cache-version'
function getCacheVersion(): number {
if (!import.meta.client) return 0
return Number(localStorage.getItem(BUST_KEY)) || 0
}
let currentVersion = getCacheVersion()
// Tăng version mỗi lần gọi — dùng để invalidate useFetch cache
export function useCacheBust() {
const version = ref(currentVersion)
function bust() {
if (!import.meta.client) return
currentVersion++
localStorage.setItem(BUST_KEY, String(currentVersion))
version.value = currentVersion
// Dispatch event để các component khác biết cần refresh
window.dispatchEvent(new CustomEvent('cinek:cache-bust', { detail: { version: currentVersion } }))
}
return { version, bust }
}
// Auto-refresh khi có cache bust từ phía admin
export function useAutoRefresh(onDataUpdated?: () => void) {
const { version } = useCacheBust()
watch(version, () => {
onDataUpdated?.()
if (import.meta.client) {
window.dispatchEvent(new CustomEvent('cinek:data-updated'))
}
})
}
+29 -22
View File
@@ -33,42 +33,43 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
</script> </script>
<template> <template>
<div class="min-h-screen bg-slate-950"> <div class="min-h-screen bg-[#0A0B0E]">
<aside <aside
class="fixed inset-y-0 left-0 z-40 w-64 transform border-r border-white/10 bg-slate-900 transition-transform lg:translate-x-0" class="fixed inset-y-0 left-0 z-40 w-64 transform border-r border-white/[0.06] bg-[#0E0F13] transition-transform lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"> :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<div class="flex h-16 items-center justify-between border-b border-white/10 px-4"> <div class="flex h-16 items-center justify-between border-b border-white/[0.06] px-5">
<NuxtLink to="/admin" class="flex items-center gap-2"> <NuxtLink to="/admin" class="flex items-center gap-3">
<div class="grid size-8 place-items-center rounded-lg bg-yellow-400"> <div class="grid size-9 place-items-center rounded-xl bg-gradient-to-br from-yellow-400 to-amber-500 shadow-lg shadow-yellow-400/20">
<AppIcon name="film" class="size-4 text-slate-950" /> <AppIcon name="film" class="size-4 text-slate-950" />
</div> </div>
<span class="text-lg font-black text-white">CineK Admin</span> <span class="text-lg font-black text-white">CineK Admin</span>
</NuxtLink> </NuxtLink>
<button type="button" class="grid size-8 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden" <button type="button" class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/[0.05] hover:text-white lg:hidden"
@click="closeSidebar"> @click="closeSidebar">
<AppIcon name="x" class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
</div> </div>
<nav class="flex flex-col gap-1 p-4"> <nav class="flex flex-col gap-1 p-3">
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to" <NuxtLink v-for="item in navItems" :key="item.to" :to="item.to"
class="flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold transition" class="group relative flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold transition"
:class="isActive(item.to) :class="isActive(item.to)
? 'bg-yellow-400/10 text-yellow-400' ? 'bg-yellow-400/10 text-yellow-400'
: 'text-slate-300 hover:bg-white/5 hover:text-white'"> : 'text-slate-400 hover:bg-white/[0.03] hover:text-white'">
<AppIcon :name="item.icon" class="size-5" /> <span v-if="isActive(item.to)" class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-yellow-400" />
<AppIcon :name="item.icon" class="size-5 transition group-hover:scale-110" />
{{ item.label }} {{ item.label }}
</NuxtLink> </NuxtLink>
</nav> </nav>
<div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-white/10 p-4"> <div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-white/[0.06] p-3">
<NuxtLink to="/" <NuxtLink to="/"
class="flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white"> class="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold text-slate-400 transition hover:bg-white/[0.03] hover:text-white">
<AppIcon name="home" class="size-5" /> <AppIcon name="home" class="size-5" />
Về trang chủ Về trang chủ
</NuxtLink> </NuxtLink>
<button type="button" <button type="button"
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-400/10" class="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-500/10"
@click="handleLogout"> @click="handleLogout">
<AppIcon name="log-out" class="size-5" /> <AppIcon name="log-out" class="size-5" />
Đăng xuất Đăng xuất
@@ -78,19 +79,25 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
<div class="lg:pl-64"> <div class="lg:pl-64">
<header <header
class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-white/10 bg-slate-950/80 px-4 backdrop-blur-xl lg:px-6"> class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-white/[0.06] bg-[#0A0B0E]/80 px-4 backdrop-blur-xl lg:px-6">
<button type="button" class="grid size-10 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden" <button type="button" class="grid size-10 place-items-center rounded-xl text-slate-400 transition hover:bg-white/[0.05] hover:text-white lg:hidden"
@click="sidebarOpen = true"> @click="sidebarOpen = true">
<AppIcon name="menu" class="size-5" /> <AppIcon name="menu" class="size-5" />
</button> </button>
<div class="ml-auto flex items-center gap-3"> <div class="ml-auto flex items-center gap-4">
<div class="grid size-9 place-items-center rounded-full bg-yellow-400/10"> <div class="hidden items-center gap-2 text-sm text-slate-400 md:flex">
<span class="text-sm font-black text-yellow-400">{{ displayInitial }}</span> <span class="size-2 rounded-full bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/50" />
Hệ thống hoạt động
</div> </div>
<div class="hidden sm:block"> <div class="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-[#131418] px-3 py-1.5">
<p class="text-sm font-semibold text-white">{{ displayName }}</p> <div class="grid size-8 place-items-center rounded-full bg-gradient-to-br from-yellow-400/20 to-yellow-400/5 text-sm font-black text-yellow-400 ring-1 ring-yellow-400/20">
<p class="text-xs capitalize text-slate-400">{{ user?.role || 'admin' }}</p> {{ displayInitial }}
</div>
<div class="hidden sm:block">
<p class="text-sm font-bold text-white">{{ displayName }}</p>
<p class="text-xs capitalize text-slate-400">{{ user?.role || 'admin' }}</p>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -101,7 +108,7 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
</div> </div>
<Transition name="sidebar-fade"> <Transition name="sidebar-fade">
<div v-if="sidebarOpen" class="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden" <div v-if="sidebarOpen" class="fixed inset-0 z-30 bg-black/70 backdrop-blur-sm lg:hidden"
@click="closeSidebar" /> @click="closeSidebar" />
</Transition> </Transition>
</div> </div>
+56 -47
View File
@@ -16,66 +16,75 @@ const allowRegistration = ref(true)
<template> <template>
<div> <div>
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-black text-white">Cài đặt</h1> <h1 class="admin-section-title">Cài đặt</h1>
<p class="mt-1 text-sm text-slate-400">Quản cài đặt hệ thống CineK</p> <p class="admin-section-subtitle mt-1">Quản cài đặt hệ thống CineK</p>
</div> </div>
<div class="space-y-6"> <div class="grid gap-6 lg:grid-cols-3">
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="space-y-6 lg:col-span-2">
<div class="mb-5 flex items-center gap-3"> <div class="admin-card p-5">
<div class="grid size-10 place-items-center rounded-lg bg-yellow-400/10"> <div class="mb-5 flex items-center gap-3">
<AppIcon name="settings" class="size-4 text-yellow-400" /> <div class="grid size-11 place-items-center rounded-xl bg-yellow-400/10">
<AppIcon name="settings" class="size-5 text-yellow-400" />
</div>
<div>
<h2 class="text-lg font-black text-white">Cài đặt chung</h2>
<p class="text-sm text-slate-400">Thông tin bản về website</p>
</div>
</div> </div>
<div>
<h2 class="text-lg font-black text-white">Cài đặt chung</h2> <div class="space-y-4">
<p class="text-sm text-slate-400">Thông tin bản về website</p> <div>
<label class="mb-2 block text-sm font-semibold text-white">Tên website</label>
<input v-model="siteName" type="text" class="admin-input">
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-white"> tả website</label>
<textarea v-model="siteDescription" rows="3"
class="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-2.5 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-yellow-400/50 focus:bg-white/[0.05] focus:ring-2 focus:ring-yellow-400/20"></textarea>
</div>
</div> </div>
</div> </div>
<div class="space-y-4"> <div class="admin-card p-5">
<div> <h2 class="mb-5 text-lg font-black text-white">Tùy chọn hệ thống</h2>
<label class="mb-2 block text-sm font-semibold text-white">Tên website</label>
<input v-model="siteName" type="text"
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
</div>
<div> <div class="space-y-3">
<label class="mb-2 block text-sm font-semibold text-white"> tả website</label> <label class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.1] hover:bg-white/[0.05]">
<textarea v-model="siteDescription" rows="3" <div>
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white outline-none focus:border-yellow-400/50"></textarea> <p class="text-sm font-bold text-white">Chế độ bảo trì</p>
<p class="mt-1 text-xs text-slate-400">Tạm thời tắt website để bảo trì</p>
</div>
<AdminToggle v-model="maintenanceMode" />
</label>
<label class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.1] hover:bg-white/[0.05]">
<div>
<p class="text-sm font-bold text-white">Cho phép đăng </p>
<p class="mt-1 text-xs text-slate-400">Cho phép người dùng mới đăng tài khoản</p>
</div>
<AdminToggle v-model="allowRegistration" />
</label>
</div> </div>
</div> </div>
<div class="flex justify-end">
<button type="button" class="admin-btn-primary">
<AppIcon name="save" class="size-4" />
Lưu thay đổi
</button>
</div>
</div> </div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="space-y-6">
<h2 class="mb-5 text-lg font-black text-white">Tùy chọn hệ thống</h2> <div class="admin-card-gradient p-5">
<h3 class="mb-2 text-lg font-black text-white">Cần giúp đỡ?</h3>
<div class="space-y-4"> <p class="text-sm leading-relaxed text-slate-400">
<label class="flex cursor-pointer items-center justify-between rounded-lg border border-white/5 bg-white/5 p-4 transition hover:border-white/10"> Các cài đặt này ảnh hưởng đến toàn bộ hệ thống. Hãy cẩn thận khi thay đổi chế độ bảo trì.
<div> </p>
<p class="text-sm font-semibold text-white">Chế độ bảo trì</p>
<p class="mt-1 text-xs text-slate-400">Tạm thời tắt website để bảo trì</p>
</div>
<AdminToggle v-model="maintenanceMode" />
</label>
<label class="flex cursor-pointer items-center justify-between rounded-lg border border-white/5 bg-white/5 p-4 transition hover:border-white/10">
<div>
<p class="text-sm font-semibold text-white">Cho phép đăng </p>
<p class="mt-1 text-xs text-slate-400">Cho phép người dùng mới đăng tài khoản</p>
</div>
<AdminToggle v-model="allowRegistration" />
</label>
</div> </div>
</div> </div>
<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">
<AppIcon name="save" class="size-4" />
Lưu thay đổi
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
+206 -186
View File
@@ -40,7 +40,7 @@ function percentOf(value: number, total: number) {
} }
// Donut chart helpers // Donut chart helpers
function donutSegment(value: number, total: number, startAngle: number, radius = 40) { function donutSegment(value: number, total: number, startAngle: number, radius = 38) {
if (!total || !value) return '' if (!total || !value) return ''
const angle = (value / total) * 360 const angle = (value / total) * 360
const startRad = ((startAngle - 90) * Math.PI) / 180 const startRad = ((startAngle - 90) * Math.PI) / 180
@@ -53,6 +53,20 @@ function donutSegment(value: number, total: number, startAngle: number, radius =
return `M 50 50 L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z` return `M 50 50 L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`
} }
// Stacked source bar segments
const sourceSegments = computed(() => {
const total = stats.value?.total || 0
const ophim = stats.value?.ophim || 0
const nguonc = stats.value?.nguonc || 0
const kkphim = stats.value?.kkphim || 0
if (!total) return []
return [
{ key: 'ophim', label: 'OPhim', value: ophim, pct: (ophim / total) * 100, color: '#facc15' },
{ key: 'nguonc', label: 'NguonC', value: nguonc, pct: (nguonc / total) * 100, color: '#34d399' },
{ key: 'kkphim', label: 'KKPhim', value: kkphim, pct: (kkphim / total) * 100, color: '#60a5fa' },
].filter(s => s.value > 0)
})
// Bar chart max value // Bar chart max value
const maxViews = computed(() => { const maxViews = computed(() => {
const movies = stats.value?.topMovies || [] const movies = stats.value?.topMovies || []
@@ -61,245 +75,281 @@ const maxViews = computed(() => {
function barWidth(views: number) { function barWidth(views: number) {
if (!maxViews.value) return 0 if (!maxViews.value) return 0
return Math.max((views / maxViews.value) * 100, 4) return Math.max((views / maxViews.value) * 100, 3)
} }
const syncSourceOptions = [
{ key: 'ophim', label: 'OPhim', domain: 'ophim1.com' },
{ key: 'nguonc', label: 'NguonC', domain: 'phim.nguonc.com' },
{ key: 'kkphim', label: 'KKPhim', domain: 'phimapi.com' },
] as const
</script> </script>
<template> <template>
<div> <div class="space-y-5">
<div class="mb-6 flex items-center justify-between"> <!-- Header -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 class="text-2xl font-black text-white">Dashboard</h1> <h1 class="admin-section-title">Dashboard</h1>
<p class="mt-1 text-sm text-slate-400">Tổng quan hệ thống CineK</p> <p class="admin-section-subtitle mt-1">Tổng quan hệ thống CineK</p>
</div> </div>
<button type="button" <button type="button" class="admin-btn-primary" @click="syncOpen = true">
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">
<AppIcon name="refresh" class="size-4" /> <AppIcon name="refresh" class="size-4" />
Đồng bộ phim Đồng bộ phim
</button> </button>
</div> </div>
<!-- Stats Cards --> <!-- Bento: KPI grid -->
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:grid-rows-2">
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20"> <!-- Hero: Total movies (col-span-2, row-span-2) -->
<div class="flex items-start justify-between"> <div class="admin-card-gradient relative overflow-hidden p-6 sm:col-span-2 lg:row-span-2">
<div> <!-- subtle accent glow -->
<p class="text-sm font-semibold text-slate-400">Tổng phim</p> <div class="pointer-events-none absolute -right-16 -top-16 size-48 rounded-full bg-yellow-400/10 blur-3xl" />
<p class="mt-2 text-3xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
<div class="relative flex h-full flex-col">
<div class="flex items-start justify-between">
<div>
<p class="admin-label">Tổng số phim</p>
<p class="admin-num mt-3 text-5xl leading-none sm:text-6xl">{{ stats?.total?.toLocaleString() || 0 }}</p>
</div>
<div class="grid size-10 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-yellow-400">
<AppIcon name="film" class="size-5" />
</div>
</div> </div>
<div class="grid size-11 place-items-center rounded-xl bg-yellow-400/10 text-yellow-400">
<AppIcon name="film" class="size-5" /> <!-- Stacked source distribution bar -->
<div class="mt-8">
<div class="mb-2 flex items-center justify-between">
<p class="admin-label">Phân bổ theo nguồn</p>
<span class="text-xs text-zinc-500">{{ sourceSegments.length }} nguồn</span>
</div>
<div v-if="sourceSegments.length" class="flex h-3 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div v-for="seg in sourceSegments" :key="seg.key"
class="h-full transition-all duration-700"
:style="{ width: seg.pct + '%', backgroundColor: seg.color }"
:title="`${seg.label}: ${seg.value} (${Math.round(seg.pct)}%)`" />
</div>
<div v-else class="h-3 w-full rounded-full bg-white/[0.04]" />
<div class="mt-3 flex flex-wrap gap-x-5 gap-y-1.5">
<div v-for="seg in sourceSegments" :key="seg.key" class="flex items-center gap-2">
<span class="size-2 rounded-full" :style="{ backgroundColor: seg.color }" />
<span class="text-xs text-zinc-400">{{ seg.label }}</span>
<span class="text-xs font-semibold text-zinc-200">{{ seg.value.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- Footer mini stats -->
<div class="mt-auto grid grid-cols-2 gap-4 border-t border-white/[0.06] pt-5">
<div>
<p class="admin-label">Đang hiển thị</p>
<p class="mt-1.5 flex items-baseline gap-2">
<span class="admin-num text-xl">{{ stats?.active?.toLocaleString() || 0 }}</span>
<span class="text-xs font-medium text-emerald-400">{{ percentOf(stats?.active || 0, stats?.total || 0) }}%</span>
</p>
</div>
<div>
<p class="admin-label">Chưa hiển thị</p>
<p class="mt-1.5 flex items-baseline gap-2">
<span class="admin-num text-xl text-zinc-300">{{ stats?.inactive?.toLocaleString() || 0 }}</span>
<span class="text-xs font-medium text-zinc-500">{{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</span>
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20"> <!-- KPI: Đang hiển thị -->
<div class="admin-card p-5">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <p class="admin-label">Đang hiển thị</p>
<p class="text-sm font-semibold text-slate-400">Đang hiển thị</p> <div class="grid size-8 place-items-center rounded-lg bg-emerald-400/10 text-emerald-400">
<p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p> <AppIcon name="trending-up" class="size-4" />
</div>
<div class="grid size-11 place-items-center rounded-xl bg-green-400/10 text-green-400">
<AppIcon name="trending-up" class="size-5" />
</div> </div>
</div> </div>
<p class="admin-num mt-4 text-3xl">{{ stats?.active?.toLocaleString() || 0 }}</p>
<p class="mt-1 text-xs text-zinc-500">{{ percentOf(stats?.active || 0, stats?.total || 0) }}% tổng phim</p>
</div> </div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20"> <!-- KPI: Phim bộ -->
<div class="admin-card p-5">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <p class="admin-label">Phim bộ</p>
<p class="text-sm font-semibold text-slate-400">Phim bộ</p> <div class="grid size-8 place-items-center rounded-lg bg-blue-400/10 text-blue-400">
<p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p> <AppIcon name="tv" class="size-4" />
</div>
<div class="grid size-11 place-items-center rounded-xl bg-blue-400/10 text-blue-400">
<AppIcon name="tv" class="size-5" />
</div> </div>
</div> </div>
<p class="admin-num mt-4 text-3xl">{{ stats?.series?.toLocaleString() || 0 }}</p>
<p class="mt-1 text-xs text-zinc-500">{{ percentOf(stats?.series || 0, stats?.total || 0) }}% tổng phim</p>
</div> </div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20"> <!-- KPI: Phim lẻ -->
<div class="admin-card p-5">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <p class="admin-label">Phim lẻ</p>
<p class="text-sm font-semibold text-slate-400">Phim lẻ</p> <div class="grid size-8 place-items-center rounded-lg bg-purple-400/10 text-purple-400">
<p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p> <AppIcon name="play" class="size-4" />
</div>
<div class="grid size-11 place-items-center rounded-xl bg-purple-400/10 text-purple-400">
<AppIcon name="play" class="size-5" />
</div> </div>
</div> </div>
<p class="admin-num mt-4 text-3xl">{{ stats?.single?.toLocaleString() || 0 }}</p>
<p class="mt-1 text-xs text-zinc-500">{{ percentOf(stats?.single || 0, stats?.total || 0) }}% tổng phim</p>
</div>
<!-- KPI: Tổng lượt xem -->
<div class="admin-card p-5">
<div class="flex items-start justify-between">
<p class="admin-label">Tổng lượt xem</p>
<div class="grid size-8 place-items-center rounded-lg bg-yellow-400/10 text-yellow-400">
<AppIcon name="bar-chart" class="size-4" />
</div>
</div>
<p class="admin-num mt-4 text-3xl">{{ stats?.totalViews?.toLocaleString() || 0 }}</p>
<p class="mt-1 text-xs text-zinc-500">trên {{ stats?.total?.toLocaleString() || 0 }} phim</p>
</div> </div>
</div> </div>
<!-- Charts Row --> <!-- Bento: Charts row -->
<div class="mb-6 grid gap-4 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-4">
<!-- Donut Chart: Source Distribution --> <!-- Donut: Source distribution -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="admin-card p-5">
<p class="text-sm font-semibold text-slate-400">Phân bổ nguồn phim</p> <div class="flex items-center justify-between">
<p class="admin-label">Phân bổ nguồn</p>
<span class="admin-badge bg-white/[0.05] text-zinc-400">Nguồn</span>
</div>
<div class="mt-4 flex items-center justify-center"> <div class="mt-4 flex items-center justify-center">
<div class="relative"> <div class="relative">
<svg viewBox="0 0 100 100" class="size-44"> <svg viewBox="0 0 100 100" class="size-40">
<!-- OPhim segment -->
<template v-if="stats?.ophim"> <template v-if="stats?.ophim">
<path :d="donutSegment(stats.ophim, stats.total, 0)" fill="#facc15" /> <path :d="donutSegment(stats.ophim, stats.total, 0)" fill="#facc15" />
</template> </template>
<!-- NguonC segment -->
<template v-if="stats?.nguonc"> <template v-if="stats?.nguonc">
<path :d="donutSegment(stats.nguonc, stats.total, (stats.ophim || 0) / (stats.total || 1) * 360)" fill="#4ade80" /> <path :d="donutSegment(stats.nguonc, stats.total, (stats.ophim || 0) / (stats.total || 1) * 360)" fill="#34d399" />
</template> </template>
<!-- KKPhim segment -->
<template v-if="stats?.kkphim"> <template v-if="stats?.kkphim">
<path :d="donutSegment(stats.kkphim, stats.total, ((stats.ophim || 0) + (stats.nguonc || 0)) / (stats.total || 1) * 360)" fill="#60a5fa" /> <path :d="donutSegment(stats.kkphim, stats.total, ((stats.ophim || 0) + (stats.nguonc || 0)) / (stats.total || 1) * 360)" fill="#60a5fa" />
</template> </template>
<!-- Inner circle for donut effect --> <circle cx="50" cy="50" r="22" fill="#131418" />
<circle cx="50" cy="50" r="24" fill="#0f172a" />
</svg> </svg>
<div class="absolute inset-0 grid place-items-center"> <div class="absolute inset-0 grid place-items-center">
<div class="text-center"> <div class="text-center">
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p> <p class="admin-num text-xl">{{ stats?.total?.toLocaleString() || 0 }}</p>
<p class="text-[10px] text-slate-400">total</p> <p class="text-[10px] uppercase tracking-widest text-zinc-500">phim</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-1.5">
<div class="flex items-center justify-between text-sm"> <div v-for="s in [
{ key: 'ophim', label: 'OPhim', color: 'bg-yellow-400' },
{ key: 'nguonc', label: 'NguonC', color: 'bg-emerald-400' },
{ key: 'kkphim', label: 'KKPhim', color: 'bg-blue-400' },
]" :key="s.key" class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-yellow-400" /> <span class="size-2 rounded-full" :class="s.color" />
<span class="text-slate-300">OPhim</span> <span class="text-zinc-400">{{ s.label }}</span>
</div> </div>
<span class="font-semibold text-white">{{ stats?.ophim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.ophim || 0, stats?.total || 0) }}%)</span></span> <span class="font-semibold text-zinc-200">{{ stats?.[s.key] || 0 }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-green-400" />
<span class="text-slate-300">NguonC</span>
</div>
<span class="font-semibold text-white">{{ stats?.nguonc || 0 }} <span class="text-slate-500">({{ percentOf(stats?.nguonc || 0, stats?.total || 0) }}%)</span></span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-blue-400" />
<span class="text-slate-300">KKPhim</span>
</div>
<span class="font-semibold text-white">{{ stats?.kkphim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.kkphim || 0, stats?.total || 0) }}%)</span></span>
</div> </div>
</div> </div>
</div> </div>
<!-- Donut Chart: Series vs Single --> <!-- Donut: Series vs Single -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="admin-card p-5">
<p class="text-sm font-semibold text-slate-400">Phim bộ vs Phim lẻ</p> <div class="flex items-center justify-between">
<p class="admin-label">Bộ vs Lẻ</p>
<span class="admin-badge bg-white/[0.05] text-zinc-400">Loại</span>
</div>
<div class="mt-4 flex items-center justify-center"> <div class="mt-4 flex items-center justify-center">
<div class="relative"> <div class="relative">
<svg viewBox="0 0 100 100" class="size-44"> <svg viewBox="0 0 100 100" class="size-40">
<template v-if="stats?.series"> <template v-if="stats?.series">
<path :d="donutSegment(stats.series, stats.total, 0)" fill="#3b82f6" /> <path :d="donutSegment(stats.series, stats.total, 0)" fill="#3b82f6" />
</template> </template>
<template v-if="stats?.single"> <template v-if="stats?.single">
<path :d="donutSegment(stats.single, stats.total, (stats.series || 0) / (stats.total || 1) * 360)" fill="#a855f7" /> <path :d="donutSegment(stats.single, stats.total, (stats.series || 0) / (stats.total || 1) * 360)" fill="#a855f7" />
</template> </template>
<circle cx="50" cy="50" r="24" fill="#0f172a" /> <circle cx="50" cy="50" r="22" fill="#131418" />
</svg> </svg>
<div class="absolute inset-0 grid place-items-center"> <div class="absolute inset-0 grid place-items-center">
<div class="text-center"> <div class="text-center">
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p> <p class="admin-num text-xl">{{ stats?.total?.toLocaleString() || 0 }}</p>
<p class="text-[10px] text-slate-400">total</p> <p class="text-[10px] uppercase tracking-widest text-zinc-500">phim</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-1.5">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-blue-500" /> <span class="size-2 rounded-full bg-blue-500" />
<span class="text-slate-300">Phim bộ</span> <span class="text-zinc-400">Phim bộ</span>
</div> </div>
<span class="font-semibold text-white">{{ stats?.series || 0 }} <span class="text-slate-500">({{ percentOf(stats?.series || 0, stats?.total || 0) }}%)</span></span> <span class="font-semibold text-zinc-200">{{ stats?.series || 0 }}</span>
</div> </div>
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-purple-500" /> <span class="size-2 rounded-full bg-purple-500" />
<span class="text-slate-300">Phim lẻ</span> <span class="text-zinc-400">Phim lẻ</span>
</div> </div>
<span class="font-semibold text-white">{{ stats?.single || 0 }} <span class="text-slate-500">({{ percentOf(stats?.single || 0, stats?.total || 0) }}%)</span></span> <span class="font-semibold text-zinc-200">{{ stats?.single || 0 }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-green-400" />
<span class="text-slate-300">Đang hiển thị</span>
</div>
<span class="font-semibold text-green-400">{{ stats?.active || 0 }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-slate-500" />
<span class="text-slate-300">Chưa hiển thị</span>
</div>
<span class="font-semibold text-slate-400">{{ stats?.inactive || 0 }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Bar Chart: Top Movies by Views --> <!-- Top movies (col-span-2) -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="admin-card p-5 sm:col-span-2 lg:col-span-2">
<p class="text-sm font-semibold text-slate-400">Top 5 phim nhiều lượt xem</p> <div class="flex items-center justify-between">
<div class="mt-4 space-y-3"> <p class="admin-label">Top phim xem nhiều</p>
<span class="admin-badge bg-yellow-400/10 text-yellow-400">Top 5</span>
</div>
<div class="mt-4 space-y-3.5">
<div v-for="(movie, i) in stats?.topMovies || []" :key="movie.slug"> <div v-for="(movie, i) in stats?.topMovies || []" :key="movie.slug">
<div class="mb-1 flex items-center justify-between"> <div class="mb-1.5 flex items-center justify-between gap-3">
<div class="flex items-center gap-2"> <div class="flex min-w-0 items-center gap-2.5">
<span class="w-5 text-center text-xs font-black" :class="i === 0 ? 'text-yellow-400' : i === 1 ? 'text-slate-300' : i === 2 ? 'text-amber-600' : 'text-slate-500'">{{ i + 1 }}</span> <span class="flex size-5 flex-shrink-0 items-center justify-center rounded-md text-[10px] font-bold"
<span class="truncate text-xs text-white">{{ movie.name }}</span> :class="i === 0 ? 'bg-yellow-400 text-zinc-950' : 'bg-white/[0.06] text-zinc-400'">
{{ i + 1 }}
</span>
<span class="truncate text-sm text-zinc-100">{{ movie.name }}</span>
</div> </div>
<span class="text-xs font-semibold text-slate-400">{{ movie.views?.toLocaleString() }}</span> <span class="flex-shrink-0 text-xs font-semibold text-zinc-400 tabular-nums">{{ movie.views?.toLocaleString() }}</span>
</div> </div>
<div class="h-2 overflow-hidden rounded-full bg-white/10"> <div class="h-1.5 overflow-hidden rounded-full bg-white/[0.04]">
<div class="h-full rounded-full transition-all duration-500" <div class="h-full rounded-full transition-all duration-700"
:class="i === 0 ? 'bg-yellow-400' : i === 1 ? 'bg-slate-300' : i === 2 ? 'bg-amber-500' : 'bg-slate-500'" :class="i === 0 ? 'bg-yellow-400' : 'bg-zinc-500'"
:style="{ width: barWidth(movie.views || 0) + '%' }" /> :style="{ width: barWidth(movie.views || 0) + '%' }" />
</div> </div>
</div> </div>
<p v-if="!stats?.topMovies?.length" class="text-center text-xs text-slate-500">Chưa dữ liệu</p> <p v-if="!stats?.topMovies?.length" class="py-6 text-center text-sm text-zinc-500">Chưa có dữ liệu</p>
</div>
<div class="mt-5 border-t border-white/10 pt-4">
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">Tổng lượt xem</span>
<span class="text-lg font-black text-white">{{ stats?.totalViews?.toLocaleString() || 0 }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Active vs Inactive Comparison Bar --> <!-- Status comparison (full width) -->
<div class="mb-6 rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div class="admin-card p-6">
<p class="text-sm font-semibold text-slate-400">Trạng thái hiển thị</p> <div class="mb-5 flex items-center justify-between">
<div class="mt-4 flex gap-6"> <p class="admin-label">Trạng thái hiển thị</p>
<div class="flex-1"> <span class="admin-badge bg-white/[0.05] text-zinc-400">{{ stats?.total?.toLocaleString() || 0 }} phim</span>
</div>
<div class="grid gap-6 md:grid-cols-2">
<div>
<div class="mb-2 flex items-center justify-between text-sm"> <div class="mb-2 flex items-center justify-between text-sm">
<div class="flex items-center gap-2"> <span class="text-zinc-400">Đang hiển thị</span>
<span class="size-3 rounded-full bg-green-400" /> <span class="font-semibold text-emerald-400 tabular-nums">{{ stats?.active?.toLocaleString() || 0 }} · {{ percentOf(stats?.active || 0, stats?.total || 0) }}%</span>
<span class="text-white">Đang hiển thị</span>
</div>
<span class="font-semibold text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</span>
</div> </div>
<div class="h-4 overflow-hidden rounded-full bg-white/10"> <div class="h-2.5 overflow-hidden rounded-full bg-white/[0.04]">
<div class="h-full rounded-full bg-green-400 transition-all duration-500" :style="{ width: percentOf(stats?.active || 0, stats?.total || 0) + '%' }" /> <div class="h-full rounded-full bg-gradient-to-r from-emerald-500 to-emerald-400 transition-all duration-700" :style="{ width: percentOf(stats?.active || 0, stats?.total || 0) + '%' }" />
</div> </div>
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.active || 0, stats?.total || 0) }}%</p>
</div> </div>
<div class="flex-1"> <div>
<div class="mb-2 flex items-center justify-between text-sm"> <div class="mb-2 flex items-center justify-between text-sm">
<div class="flex items-center gap-2"> <span class="text-zinc-400">Chưa hiển thị</span>
<span class="size-3 rounded-full bg-slate-500" /> <span class="font-semibold text-zinc-400 tabular-nums">{{ stats?.inactive?.toLocaleString() || 0 }} · {{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</span>
<span class="text-white">Chưa hiển thị</span>
</div>
<span class="font-semibold text-slate-400">{{ stats?.inactive?.toLocaleString() || 0 }}</span>
</div> </div>
<div class="h-4 overflow-hidden rounded-full bg-white/10"> <div class="h-2.5 overflow-hidden rounded-full bg-white/[0.04]">
<div class="h-full rounded-full bg-slate-500 transition-all duration-500" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" /> <div class="h-full rounded-full bg-zinc-600 transition-all duration-700" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" />
</div> </div>
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</p>
</div> </div>
</div> </div>
</div> </div>
@@ -309,50 +359,32 @@ function barWidth(views: number) {
<Transition name="modal-fade"> <Transition name="modal-fade">
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3"> <div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="syncOpen = false" /> <div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="syncOpen = false" />
<div class="relative w-full max-w-md rounded-xl border border-white/10 bg-slate-900 p-6 shadow-2xl"> <div class="relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#131418] p-6 shadow-2xl">
<button type="button" <button type="button"
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10" class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-zinc-400 transition hover:bg-white/[0.05] hover:text-white"
@click="syncOpen = false"> @click="syncOpen = false">
<AppIcon name="x" class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2> <div class="mb-5">
<p class="mt-1 text-sm text-slate-400">Chọn nguồn để đồng bộ phim vào hệ thống</p> <h2 class="text-lg font-bold text-white">Đồng bộ phim</h2>
<p class="mt-1 text-sm text-zinc-500">Chọn nguồn để đồng bộ phim vào hệ thống</p>
</div>
<div class="mt-5 space-y-3"> <div class="space-y-3">
<label class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20"> <label v-for="source in syncSourceOptions" :key="source.key"
class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.12] hover:bg-white/[0.05]">
<div> <div>
<p class="text-sm font-semibold text-white">OPhim</p> <p class="text-sm font-semibold text-white">{{ source.label }}</p>
<p class="text-xs text-slate-400">ophim1.com</p> <p class="text-xs text-zinc-500">{{ source.domain }}</p>
</div> </div>
<AdminToggle v-model="syncSources.ophim" /> <AdminToggle v-model="syncSources[source.key]" />
</label>
<label class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div>
<p class="text-sm font-semibold text-white">NguonC</p>
<p class="text-xs text-slate-400">phim.nguonc.com</p>
</div>
<AdminToggle v-model="syncSources.nguonc" />
</label>
<label class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div>
<p class="text-sm font-semibold text-white">KKPhim</p>
<p class="text-xs text-slate-400">phimapi.com</p>
</div>
<AdminToggle v-model="syncSources.kkphim" />
</label> </label>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button type="button" <button type="button" class="admin-btn-secondary" @click="syncOpen = false">Huỷ</button>
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10" <button type="button" class="admin-btn-primary"
@click="syncOpen = false">
Huỷ
</button>
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="syncing || !Object.values(syncSources).some(Boolean)" :disabled="syncing || !Object.values(syncSources).some(Boolean)"
@click="handleSync"> @click="handleSync">
<AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" /> <AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
@@ -365,15 +397,3 @@ function barWidth(views: number) {
</Teleport> </Teleport>
</div> </div>
</template> </template>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.18s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>
+57 -50
View File
@@ -45,58 +45,65 @@ async function handleLogin() {
</script> </script>
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-slate-950 p-4"> <div class="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#0A0B0E] p-4">
<div class="w-full max-w-md"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(250,204,21,0.08),transparent_40%),radial-gradient(circle_at_bottom_left,rgba(59,130,246,0.05),transparent_40%)]" />
<div class="mb-8 text-center">
<div class="mb-4 inline-flex items-center gap-2"> <div class="relative w-full max-w-md">
<div class="grid size-10 place-items-center rounded-xl bg-yellow-400"> <div class="rounded-3xl border border-white/[0.06] bg-[#131418]/80 p-1 shadow-2xl backdrop-blur-sm">
<span class="text-lg font-black text-slate-950">C</span> <div class="rounded-[22px] bg-gradient-to-b from-white/[0.08] to-transparent p-6">
<div class="mb-8 text-center">
<div class="mb-4 inline-flex items-center gap-3">
<div class="grid size-12 place-items-center rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-500 shadow-lg shadow-yellow-400/20">
<span class="text-xl font-black text-slate-950">C</span>
</div>
<div class="text-left">
<span class="block text-2xl font-black text-white">CineK Admin</span>
<span class="text-xs text-slate-400">Quản hệ thống</span>
</div>
</div>
<p class="text-sm text-slate-400">Đăng nhập để tiếp tục</p>
</div> </div>
<span class="text-2xl font-black text-white">CineK Admin</span>
<form @submit.prevent="handleLogin">
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-semibold text-white">Email</label>
<div class="relative">
<AppIcon name="mail" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="email" type="email" required placeholder="admin@cinek.vn"
class="admin-input pl-10">
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-white">Mật khẩu</label>
<div class="relative">
<AppIcon name="lock" class="absolute left-3.5 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="admin-input pl-10 pr-10">
<button type="button"
class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white"
@click="showPassword = !showPassword">
<AppIcon name="eye-off" v-if="showPassword" class="size-4" />
<AppIcon name="eye" v-else class="size-4" />
</button>
</div>
</div>
</div>
<button type="submit"
class="admin-btn-primary mt-6 w-full"
:disabled="loading">
<AppIcon name="log-in" class="size-4" />
{{ loading ? 'Đang đăng nhập...' : 'Đăng nhập' }}
</button>
<p v-if="error" class="mt-4 rounded-xl bg-red-500/10 p-3 text-center text-sm text-red-300">
{{ error }}
</p>
</form>
</div> </div>
<p class="text-sm text-slate-400">Đăng nhập để quản hệ thống</p>
</div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-6">
<form @submit.prevent="handleLogin">
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-semibold text-white">Email</label>
<div class="relative">
<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>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-white">Mật khẩu</label>
<div class="relative">
<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">
<AppIcon name="eye-off" v-if="showPassword" class="size-4" />
<AppIcon name="eye" v-else class="size-4" />
</button>
</div>
</div>
</div>
<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">
<AppIcon name="log-in" class="size-4" />
{{ loading ? 'Đang đăng nhập...' : 'Đăng nhập' }}
</button>
<p v-if="error" class="mt-4 rounded-lg bg-red-500/12 p-3 text-center text-sm text-red-300">
{{ error }}
</p>
</form>
</div> </div>
<p class="mt-6 text-center text-xs text-slate-500"> <p class="mt-6 text-center text-xs text-slate-500">
+438 -362
View File
@@ -56,8 +56,17 @@ const expandedSources = ref<Record<string, boolean>>({})
const selectedTargetServer = ref(0) const selectedTargetServer = ref(0)
const collapsedServers = ref<Record<number, boolean>>({}) const collapsedServers = ref<Record<number, boolean>>({})
const expandedLinks = ref<Record<string, boolean>>({}) const expandedLinks = ref<Record<string, boolean>>({})
const activeSection = ref<"info" | "content" | "actors" | "images" | "episodes">("info")
const sections = [
{ key: "info" as const, label: "Thông tin", icon: "info" as const },
{ key: "content" as const, label: "Mô tả", icon: "type" as const },
{ key: "actors" as const, label: "Diễn viên", icon: "users" as const },
{ key: "images" as const, label: "Hình ảnh", icon: "image" as const },
{ key: "episodes" as const, label: "Tập phim", icon: "server" as const },
]
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
const { bust } = useCacheBust()
watch(movie, (m) => { watch(movie, (m) => {
if (m) { if (m) {
@@ -69,9 +78,9 @@ watch(movie, (m) => {
: [] : []
customServers.value = m.customServers?.length customServers.value = m.customServers?.length
? m.customServers.map((server: any) => ({ ? m.customServers.map((server: any) => ({
name: server.name || '', name: server.name || '',
episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })), episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })),
})) }))
: [] : []
} }
}, { immediate: true }) }, { immediate: true })
@@ -229,6 +238,7 @@ async function handleSave() {
}, },
}) })
saved.value = true saved.value = true
bust() // Invalidate cached data across all pages
await refresh() await refresh()
setTimeout(() => { saved.value = false }, 2500) setTimeout(() => { saved.value = false }, 2500)
} catch (err) { } catch (err) {
@@ -265,424 +275,490 @@ function useApiActors() {
</script> </script>
<template> <template>
<div> <div class="space-y-5">
<NuxtLink to="/admin/phim" <NuxtLink to="/admin/phim"
class="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-400 transition hover:text-white"> class="inline-flex items-center gap-2 text-sm font-medium text-zinc-400 transition hover:text-white">
<AppIcon name="arrow-left" class="size-4" /> <AppIcon name="arrow-left" class="size-4" />
Quay lại danh sách phim Quay lại danh sách phim
</NuxtLink> </NuxtLink>
<div v-if="!movie" class="rounded-xl border border-white/10 bg-slate-900/50 p-12 text-center"> <div v-if="!movie" class="admin-card p-12 text-center">
<p class="text-slate-400">Đang tải...</p> <p class="text-sm text-zinc-500">Đang tải...</p>
</div> </div>
<div v-else> <div v-else class="space-y-5">
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <!-- Header Card -->
<div class="min-w-0"> <div class="admin-card p-4 sm:p-5">
<h1 class="truncate text-2xl font-black text-white">{{ movie.name }}</h1> <div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<p class="mt-1 text-sm text-slate-400"> <div class="shrink-0">
{{ movie.originName }} · {{ movie.source?.toUpperCase() }} · {{ movie.year || 'N/A' }} <div
</p> class="aspect-[16/9] w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] sm:w-44">
<img v-if="customPoster || movie.poster" :src="customPoster || movie.poster" :alt="movie.name"
class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600">
<AppIcon name="image" class="size-6" />
</div>
</div>
</div>
<div class="min-w-0 flex-1">
<h1 class="admin-section-title truncate">{{ movie.name }}</h1>
<p class="admin-section-subtitle mt-1">
{{ movie.originName }} · {{ movie.source?.toUpperCase() }} · {{ movie.year || 'N/A' }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="admin-badge"
:class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-zinc-400/10 text-zinc-400'">
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
</span>
<span v-if="movie.quality" class="admin-badge bg-white/[0.06] text-zinc-400">{{ movie.quality }}</span>
<span class="admin-badge bg-white/[0.06] text-zinc-400">{{ movie.episode || '—' }} tập</span>
</div>
</div>
</div> </div>
<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">
<AppIcon name="save" class="size-4" />
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }}
</button>
</div> </div>
<Transition name="fade"> <Transition name="fade">
<div v-if="saved" <div v-if="saved"
class="mb-4 flex items-center gap-2 rounded-lg border border-green-400/20 bg-green-400/10 px-4 py-3 text-sm font-semibold text-green-400"> class="flex items-center gap-2 rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-400">
<AppIcon name="check" class="size-4" /> <AppIcon name="check" class="size-4" />
Đã lưu thành công Đã lưu thành công
</div> </div>
</Transition> </Transition>
<div class="grid gap-6 lg:grid-cols-12 items-start"> <!-- Sidebar nav + content -->
<!-- LEFT COLUMN: Main Content & Episode Management (7 cols) --> <div class="grid gap-5 lg:grid-cols-12 lg:items-start">
<div class="space-y-6 lg:col-span-7"> <!-- Content -->
<!-- tả phim --> <div class="space-y-5 lg:col-span-9">
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <!-- Section: Thông tin -->
<div class="mb-4 flex items-center justify-between gap-3"> <div v-if="activeSection === 'info'" class="admin-card p-5">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-blue-400/10">
<AppIcon name="type" class="size-4 text-blue-400" />
</div>
<div>
<h2 class="text-base font-black text-white"> tả phim</h2>
<p class="text-xs text-slate-400">
{{ customContent ? 'Mô tả tuỳ chỉnh (Đã áp dụng)' : 'Mô tả mặc định từ API' }}
</p>
</div>
</div>
<button v-if="apiContent && !customContent" type="button"
class="inline-flex h-7 items-center gap-1 rounded-lg bg-yellow-400/10 px-2.5 text-xs font-bold text-yellow-400 transition hover:bg-yellow-400/20"
@click="useApiContent">
<AppIcon name="pen" class="size-3" />
Dùng tả này
</button>
<button v-else-if="customContent" type="button"
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
@click="clearField('customContent')">
Quay lại mặc định
</button>
</div>
<!-- State 1: Chưa bấm dùng tả tuỳ chỉnh -> Hiển thị tả mặc định API -->
<div v-if="!customContent" class="rounded-lg border border-white/10 bg-white/5 p-3.5">
<div class="mb-2 flex items-center justify-between">
<span class="inline-flex items-center gap-1.5 rounded-full bg-blue-400/10 px-2.5 py-0.5 text-[10px] font-black uppercase text-blue-400">
<span class="size-1.5 rounded-full bg-blue-400"></span>
Nội dung từ API nguồn
</span>
</div>
<p class="text-xs leading-6 text-slate-300 whitespace-pre-line">
{{ apiContent || 'Chưa có mô tả từ nguồn API.' }}
</p>
</div>
<!-- State 2: Đã bấm dùng tả / chỉnh sửa -> Khung nhập full ra toàn bộ chiều cao -->
<div v-else>
<textarea v-model="customContent" :rows="contentRows" placeholder="Nhập mô tả phim..."
style="field-sizing: content; min-height: 140px;"
class="w-full rounded-lg border border-yellow-400/40 bg-white/5 p-3 text-xs leading-6 text-white outline-none placeholder:text-slate-500 focus:border-yellow-400" />
</div>
</div>
<!-- Diễn viên -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-pink-400/10">
<AppIcon name="users" class="size-4 text-pink-400" />
</div>
<div>
<h2 class="text-base font-black text-white">Diễn viên</h2>
<p class="text-xs text-slate-400">Danh sách diễn viên của phim ({{ customActors.length }})</p>
</div>
</div>
<div class="flex gap-2">
<button v-if="apiActors.length" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-yellow-400/10 px-3 text-xs font-bold text-yellow-400 transition hover:bg-yellow-400/20"
@click="useApiActors">
<AppIcon name="pen" class="size-3.5" />
Import từ API ({{ apiActors.length }})
</button>
<button type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-pink-500/20 px-3 text-xs font-bold text-pink-400 transition hover:bg-pink-500/30"
@click="addActor">
<AppIcon name="plus" class="size-3.5" />
Thêm diễn viên
</button>
</div>
</div>
<div v-if="customActors.length" class="max-h-64 overflow-y-auto space-y-1.5 pr-1">
<div v-for="(actor, index) in customActors" :key="index"
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 p-1.5">
<img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name"
class="size-6 shrink-0 rounded-full object-cover ring-1 ring-white/10">
<div v-else class="grid size-6 shrink-0 place-items-center rounded-full bg-pink-400/10 text-[10px] font-black text-pink-400">
{{ (actor.name || '?')[0].toUpperCase() }}
</div>
<input v-model="actor.name" type="text" placeholder="Tên diễn viên"
class="h-7 w-28 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-32">
<input v-model="actor.originalName" type="text" placeholder="Tên gốc"
class="h-7 w-24 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-28">
<input v-model="actor.role" type="text" placeholder="Vai diễn"
class="h-7 w-20 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-24">
<input v-model="actor.avatar" type="url" placeholder="Avatar URL"
class="h-7 min-w-0 flex-1 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded text-red-400 transition hover:bg-red-400/10"
@click="removeActor(index)">
<AppIcon name="trash" class="size-3.5" />
</button>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
<p class="text-sm text-slate-500">Chưa diễn viên nào. Bấm "Thêm diễn viên" hoặc "Import từ API" để bắt đầu.</p>
</div>
</div>
<!-- Server & tập phim -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-emerald-400/10">
<AppIcon name="server" class="size-4 text-emerald-400" />
</div>
<div>
<h3 class="text-base font-black text-white">Server & tập phim</h3>
<p class="text-xs text-slate-400">Danh sách server phát hiển thị trên website</p>
</div>
</div>
<button type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-emerald-500/20 px-3 text-xs font-bold text-emerald-400 transition hover:bg-emerald-500/30"
@click="addServer">
<AppIcon name="plus" class="size-3.5" />
Thêm server
</button>
</div>
<div v-if="customServers.length" class="space-y-3">
<div v-for="(server, serverIndex) in customServers" :key="serverIndex"
class="rounded-lg border border-white/10 bg-white/5">
<div class="flex items-center justify-between gap-3 p-3">
<button type="button" class="flex min-w-0 flex-1 items-center gap-2 text-left"
@click="toggleServer(serverIndex)">
<AppIcon name="chevron-down" class="size-4 shrink-0 text-slate-400 transition"
:class="collapsedServers[serverIndex] ? '-rotate-90' : ''" />
<span class="truncate text-sm font-bold text-white">{{ server.name || `Server ${serverIndex + 1}` }}</span>
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-semibold text-slate-300">
{{ server.episodes.length }} tập
</span>
</button>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeServer(serverIndex)">
<AppIcon name="trash" class="size-3.5" />
</button>
</div>
<div v-if="!collapsedServers[serverIndex]" class="border-t border-white/10 p-3">
<input v-model="server.name" type="text" placeholder="Tên server (vd: Vietsub, Thuyết minh...)"
class="mb-3 h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 text-sm font-bold text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<div v-if="server.episodes.length" class="space-y-1.5">
<div v-for="(ep, episodeIndex) in server.episodes" :key="episodeIndex"
class="rounded-md border border-white/10 bg-white/5">
<div class="flex items-center gap-2 p-2">
<span class="w-7 shrink-0 text-center text-xs font-black text-slate-400">{{ episodeIndex + 1 }}</span>
<input v-model="ep.name" type="text" placeholder="Tên tập"
class="h-8 w-24 shrink-0 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<select
class="h-8 min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white outline-none focus:border-yellow-400/50"
@change="handleSourceLinkChange(serverIndex, episodeIndex, $event)">
<option value="" class="bg-slate-900 text-white">Chọn link từ API</option>
<option v-for="item in availableEpisodes" :key="item.key" :value="item.key"
class="bg-slate-900 text-white">
{{ item.label }}
</option>
</select>
<button type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded text-slate-400 transition hover:bg-white/10 hover:text-white"
:class="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'bg-yellow-400/10 text-yellow-400' : ''"
:title="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'Ẩn link' : 'Sửa link'"
@click="toggleLinks(`${serverIndex}-${episodeIndex}`)">
<AppIcon name="circle" class="size-3" />
</button>
<button type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded text-red-400 transition hover:bg-red-400/10"
@click="removeServerEpisode(serverIndex, episodeIndex)">
<AppIcon name="trash" class="size-3" />
</button>
</div>
<div v-if="expandedLinks[`${serverIndex}-${episodeIndex}`]" class="border-t border-white/10 p-2">
<div class="grid gap-2 sm:grid-cols-2">
<input v-model="ep.linkEmbed" type="url" placeholder="Link embed (iframe)"
class="h-8 w-full rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<input v-model="ep.linkM3u8" type="url" placeholder="Link HLS (.m3u8)"
class="h-8 w-full rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
</div>
</div>
</div>
</div>
<button type="button"
class="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-white/20 px-3 text-xs font-semibold text-white transition hover:bg-white/10"
@click="addServerEpisode(serverIndex)">
<AppIcon name="plus" class="size-3.5" />
Thêm tập
</button>
</div>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
<p class="text-sm text-slate-500">Chưa server nào. Bấm "Thêm server" để bắt đầu.</p>
</div>
</div>
</div>
<!-- RIGHT COLUMN: Metadata & API Sources Sidebar (5 cols) -->
<div class="space-y-6 lg:col-span-5">
<!-- Thông tin phim -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center gap-3"> <div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-amber-400/10"> <div class="admin-icon-square">
<AppIcon name="info" class="size-4 text-amber-400" /> <AppIcon name="info" class="size-4" />
</div> </div>
<div> <div>
<h2 class="text-base font-black text-white">Thông tin phim</h2> <h2 class="text-sm font-bold text-white">Thông tin chi tiết</h2>
<p class="text-xs text-slate-400">Thông tin chi tiết từ hệ thống</p> <p class="text-xs text-zinc-500">Dữ liệu từ hệ thống</p>
</div> </div>
</div> </div>
<dl class="grid grid-cols-2 gap-x-3 gap-y-2.5 text-xs"> <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div> <div>
<dt class="text-slate-500">Nguồn</dt> <dt class="admin-label mb-1">Nguồn</dt>
<dd class="truncate font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd> <dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
</div> </div>
<div> <div>
<dt class="text-slate-500">Slug</dt> <dt class="admin-label mb-1">Slug</dt>
<dd class="truncate text-white" :title="movie.slug">{{ movie.slug }}</dd> <dd class="truncate text-zinc-200" :title="movie.slug">{{ movie.slug }}</dd>
</div> </div>
<div> <div>
<dt class="text-slate-500">Năm phát hành</dt> <dt class="admin-label mb-1">Năm phát hành</dt>
<dd class="text-white">{{ movie.year || '—' }}</dd> <dd class="text-zinc-200">{{ movie.year || '—' }}</dd>
</div> </div>
<div> <div>
<dt class="text-slate-500">Số tập</dt> <dt class="admin-label mb-1">Số tập</dt>
<dd class="text-white">{{ movie.episode || '—' }}</dd> <dd class="text-zinc-200">{{ movie.episode || '—' }}</dd>
</div> </div>
<div> <div>
<dt class="text-slate-500">Chất lượng</dt> <dt class="admin-label mb-1">Chất lượng</dt>
<dd class="text-white">{{ movie.quality || '—' }}</dd> <dd class="text-zinc-200">{{ movie.quality || '—' }}</dd>
</div> </div>
<div> <div>
<dt class="text-slate-500">Trạng thái</dt> <dt class="admin-label mb-1">Trạng thái</dt>
<dd :class="movie.active ? 'font-semibold text-green-400' : 'text-slate-400'"> <dd class="font-semibold" :class="movie.active ? 'text-emerald-400' : 'text-zinc-400'">
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }} {{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
</dd> </dd>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<dt class="text-slate-500">Đồng bộ gần nhất</dt> <dt class="admin-label mb-1">Đồng bộ gần nhất</dt>
<dd class="text-white">{{ new Date(movie.syncedAt).toLocaleDateString('vi-VN') }}</dd> <dd class="text-zinc-200">{{ new Date(movie.syncedAt).toLocaleString('vi-VN') }}</dd>
</div> </div>
</dl> </dl>
<div v-if="movie.categories?.length" class="mt-4 border-t border-white/10 pt-3"> <div v-if="movie.categories?.length" class="mt-4 border-t border-white/[0.06] pt-3">
<h4 class="mb-1.5 text-[11px] font-black uppercase text-slate-400">Thể loại</h4> <h4 class="admin-label mb-2">Thể loại</h4>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1.5">
<span v-for="cat in movie.categories" :key="cat" <span v-for="cat in movie.categories" :key="cat"
class="rounded-full bg-white/8 px-2.5 py-0.5 text-[11px] font-semibold text-slate-300"> class="rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium text-zinc-300">{{ cat }}</span>
{{ cat }}
</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Ảnh phim --> <!-- Section: tả -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5"> <div v-if="activeSection === 'content'" class="admin-card 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">
<AppIcon name="image" class="size-4 text-purple-400" />
</div>
<div>
<h2 class="text-base font-black text-white">Hình ảnh</h2>
<p class="text-xs text-slate-400">Poster & Thumbnail tuỳ chỉnh</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-400">Poster (ảnh ngang)</label>
<input v-model="customPoster" type="url" placeholder="https://..."
class="h-9 w-full rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<div v-if="customPoster || movie.poster" class="mt-2 flex items-center justify-between gap-2">
<img :src="customPoster || movie.poster" :alt="movie.name"
class="h-20 w-32 rounded-lg object-cover ring-1 ring-white/10">
<button v-if="customPoster" type="button"
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
@click="clearField('customPoster')">
Xoá poster
</button>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-400">Thumbnail (ảnh dọc)</label>
<input v-model="customThumb" type="url" placeholder="https://..."
class="h-9 w-full rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<div v-if="customThumb || movie.thumb" class="mt-2 flex items-center justify-between gap-2">
<img :src="customThumb || movie.thumb" :alt="movie.name"
class="h-20 w-14 rounded-lg object-cover ring-1 ring-white/10">
<button v-if="customThumb" type="button"
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
@click="clearField('customThumb')">
Xoá thumbnail
</button>
</div>
</div>
</div>
</div>
<!-- Nguồn API sẵn -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center justify-between gap-3"> <div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-cyan-400/10"> <div class="admin-icon-square">
<AppIcon name="layers" class="size-4 text-cyan-400" /> <AppIcon name="type" class="size-4" />
</div> </div>
<div> <div>
<h3 class="text-base font-black text-white">Nguồn API sẵn</h3> <h2 class="text-sm font-bold text-white"> tả phim</h2>
<p class="text-xs text-slate-400">Dữ liệu tập phim từ các API</p> <p class="text-xs text-zinc-500">{{ customContent ? 'Tuỳ chỉnh · Đang áp dụng' : 'Mặc định từ API' }}
</p>
</div>
</div>
<button v-if="apiContent && !customContent" type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20" @click="useApiContent">
<AppIcon name="pen" class="size-3" />
Dùng tả API
</button>
<button v-else-if="customContent" type="button" class="admin-btn-sm text-red-400 hover:bg-red-400/10"
@click="clearField('customContent')">
Quay lại mặc định
</button>
</div>
<div v-if="!customContent" class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
<span class="admin-badge mb-2 bg-blue-400/10 text-blue-400">Nội dung API</span>
<p class="whitespace-pre-line text-sm leading-7 text-zinc-300">
{{ apiContent || 'Chưa có mô tả từ nguồn API.' }}
</p>
</div>
<div v-else>
<textarea v-model="customContent" :rows="contentRows" placeholder="Nhập mô tả phim..."
style="field-sizing: content; min-height: 140px;"
class="w-full rounded-xl border border-yellow-400/30 bg-white/[0.03] p-3 text-sm leading-7 text-white outline-none transition placeholder:text-zinc-500 focus:border-yellow-400/50 focus:bg-white/[0.05]" />
</div>
</div>
<!-- Section: Diễn viên -->
<div v-if="activeSection === 'actors'" class="admin-card p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="users" class="size-4" />
</div>
<div>
<h2 class="text-sm font-bold text-white">Diễn viên</h2>
<p class="text-xs text-zinc-500">{{ customActors.length }} diễn viên</p>
</div>
</div>
<div class="flex gap-2">
<button v-if="apiActors.length" type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20" @click="useApiActors">
<AppIcon name="pen" class="size-3" />
Import ({{ apiActors.length }})
</button>
<button type="button" class="admin-btn-sm bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
@click="addActor">
<AppIcon name="plus" class="size-3" />
Thêm
</button>
</div>
</div>
<div v-if="customActors.length" class="max-h-96 space-y-2 overflow-y-auto pr-1">
<div v-for="(actor, index) in customActors" :key="index"
class="grid grid-cols-12 items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] p-2">
<div class="col-span-1 flex justify-center">
<img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name"
class="size-7 rounded-full object-cover ring-1 ring-white/10">
<div v-else
class="grid size-7 place-items-center rounded-full bg-white/[0.06] text-xs font-bold text-zinc-400">
{{ (actor.name || '?')[0].toUpperCase() }}
</div>
</div>
<input v-model="actor.name" type="text" placeholder="Tên" class="admin-input-sm col-span-3">
<input v-model="actor.originalName" type="text" placeholder="Tên gốc" class="admin-input-sm col-span-3">
<input v-model="actor.role" type="text" placeholder="Vai" class="admin-input-sm col-span-2">
<input v-model="actor.avatar" type="url" placeholder="Avatar URL" class="admin-input-sm col-span-2">
<button type="button"
class="col-span-1 grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeActor(index)">
<AppIcon name="trash" class="size-4" />
</button>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-white/[0.1] p-6 text-center">
<p class="text-sm text-zinc-500">Chưa diễn viên. Bấm "Thêm" hoặc "Import" để bắt đầu.</p>
</div>
</div>
<!-- Section: Hình ảnh -->
<div v-if="activeSection === 'images'" class="grid gap-5 lg:grid-cols-2">
<div class="admin-card p-5">
<div class="mb-3 flex items-center justify-between gap-2">
<div>
<h3 class="text-sm font-bold text-white">Poster</h3>
<p class="text-xs text-zinc-500">Tỷ lệ 16:9 · ảnh ngang</p>
</div>
<span v-if="customPoster" class="admin-badge bg-yellow-400/10 text-yellow-400">Tuỳ chỉnh</span>
<span v-else-if="movie.poster" class="admin-badge bg-white/[0.06] text-zinc-400">Mặc định</span>
</div>
<input v-model="customPoster" type="url" placeholder="Dán URL poster..." class="admin-input-sm w-full">
<div
class="relative mt-3 aspect-[16/9] w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
<img v-if="customPoster || movie.poster" :src="customPoster || movie.poster" :alt="movie.name"
class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600">
<div class="flex flex-col items-center gap-1.5">
<AppIcon name="image" class="size-8" />
<span class="text-sm font-medium">Chưa ảnh poster</span>
</div>
</div>
<button v-if="customPoster" type="button"
class="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-semibold text-red-300 backdrop-blur-sm transition hover:bg-black/80 hover:text-red-200"
@click="clearField('customPoster')">
<AppIcon name="trash" class="size-3" />
Xoá
</button>
</div>
</div>
<div class="admin-card p-5">
<div class="mb-3 flex items-center justify-between gap-2">
<div>
<h3 class="text-sm font-bold text-white">Thumbnail</h3>
<p class="text-xs text-zinc-500">Tỷ lệ 2:3 · ảnh dọc</p>
</div>
<span v-if="customThumb" class="admin-badge bg-yellow-400/10 text-yellow-400">Tuỳ chỉnh</span>
<span v-else-if="movie.thumb" class="admin-badge bg-white/[0.06] text-zinc-400">Mặc định</span>
</div>
<input v-model="customThumb" type="url" placeholder="Dán URL thumbnail..." class="admin-input-sm w-full">
<div
class="relative mx-auto mt-3 aspect-[2/3] w-full max-w-[260px] overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
<img v-if="customThumb || movie.thumb" :src="customThumb || movie.thumb" :alt="movie.name"
class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600">
<div class="flex flex-col items-center gap-1.5">
<AppIcon name="image" class="size-8" />
<span class="text-sm font-medium">Chưa ảnh</span>
</div>
</div>
<button v-if="customThumb" type="button"
class="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-semibold text-red-300 backdrop-blur-sm transition hover:bg-black/80 hover:text-red-200"
@click="clearField('customThumb')">
<AppIcon name="trash" class="size-3" />
Xoá
</button>
</div>
</div>
</div>
<!-- Section: Tập phim -->
<div v-if="activeSection === 'episodes'" class="grid gap-5 xl:grid-cols-12 xl:items-start">
<!-- Custom Servers -->
<div class="space-y-5 xl:col-span-7">
<div class="admin-card p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="server" class="size-4" />
</div>
<div>
<h3 class="text-sm font-bold text-white">Server & tập phim</h3>
<p class="text-xs text-zinc-500">Danh sách server hiển thị trên website</p>
</div>
</div>
<button type="button" class="admin-btn-sm bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
@click="addServer">
<AppIcon name="plus" class="size-3" />
Thêm server
</button>
</div>
<div v-if="customServers.length" class="space-y-3">
<div v-for="(server, serverIndex) in customServers" :key="serverIndex"
class="rounded-xl border border-white/[0.06] bg-white/[0.02]">
<div class="flex items-center justify-between gap-3 p-3">
<button type="button" class="flex min-w-0 flex-1 items-center gap-2 text-left"
@click="toggleServer(serverIndex)">
<AppIcon name="chevron-down" class="size-4 shrink-0 text-zinc-400 transition"
:class="collapsedServers[serverIndex] ? '-rotate-90' : ''" />
<span class="truncate text-sm font-bold text-white">{{ server.name || `Server ${serverIndex +
1}` }}</span>
<span
class="shrink-0 rounded-full bg-white/[0.08] px-2 py-0.5 text-[10px] font-semibold text-zinc-300">{{
server.episodes.length }} tập</span>
</button>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeServer(serverIndex)">
<AppIcon name="trash" class="size-3.5" />
</button>
</div>
<div v-if="!collapsedServers[serverIndex]" class="border-t border-white/[0.06] p-3">
<input v-model="server.name" type="text" placeholder="Tên server (vd: Vietsub, Thuyết minh...)"
class="admin-input-sm mb-3 w-full font-semibold">
<div v-if="server.episodes.length" class="space-y-1.5">
<div v-for="(ep, episodeIndex) in server.episodes" :key="episodeIndex"
class="rounded-lg border border-white/[0.06] bg-white/[0.02]">
<div class="flex items-center gap-2 p-2">
<span class="w-6 shrink-0 text-center text-xs font-bold text-zinc-500">{{ episodeIndex + 1
}}</span>
<input v-model="ep.name" type="text" placeholder="Tên tập"
class="admin-input-sm w-24 shrink-0">
<select class="admin-input-sm min-w-0 flex-1"
@change="handleSourceLinkChange(serverIndex, episodeIndex, $event)">
<option value="" class="bg-[#131418] text-white">Chọn link từ API</option>
<option v-for="item in availableEpisodes" :key="item.key" :value="item.key"
class="bg-[#131418] text-white">{{ item.label }}</option>
</select>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-zinc-400 transition hover:bg-white/[0.06] hover:text-white"
:class="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'bg-yellow-400/10 text-yellow-400' : ''"
:title="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'Ẩn link' : 'Sửa link'"
@click="toggleLinks(`${serverIndex}-${episodeIndex}`)">
<AppIcon name="pencil" class="size-3" />
</button>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeServerEpisode(serverIndex, episodeIndex)">
<AppIcon name="trash" class="size-3" />
</button>
</div>
<div v-if="expandedLinks[`${serverIndex}-${episodeIndex}`]"
class="border-t border-white/[0.06] p-2">
<div class="grid gap-2 sm:grid-cols-2">
<input v-model="ep.linkEmbed" type="url" placeholder="Link embed (iframe)"
class="admin-input-sm w-full">
<input v-model="ep.linkM3u8" type="url" placeholder="Link HLS (.m3u8)"
class="admin-input-sm w-full">
</div>
</div>
</div>
</div>
<button type="button"
class="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-white/[0.15] px-3 text-xs font-semibold text-zinc-300 transition hover:bg-white/[0.05] hover:text-white"
@click="addServerEpisode(serverIndex)">
<AppIcon name="plus" class="size-3.5" />
Thêm tập
</button>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-white/[0.1] p-6 text-center">
<p class="text-sm text-zinc-500">Chưa server. Bấm "Thêm server" để bắt đầu.</p>
</div> </div>
</div> </div>
</div> </div>
<!-- API Sources Panel -->
<div v-if="sourceData?.sources?.length" class="space-y-3"> <div class="xl:col-span-5">
<div class="mb-4 flex flex-col gap-2 rounded-lg border border-white/10 bg-white/5 p-3 sm:flex-row sm:items-center sm:justify-between"> <div class="admin-card p-5 xl:sticky xl:top-5">
<label class="text-xs font-semibold text-slate-400">Thêm vào server:</label> <div class="mb-4 flex items-center gap-3">
<select v-model="selectedTargetServer" <div class="admin-icon-square">
class="h-8 rounded-md border border-white/10 bg-white/5 px-2.5 text-xs text-white outline-none focus:border-yellow-400/50"> <AppIcon name="layers" class="size-4" />
<option v-for="(server, index) in customServers" :key="index" :value="index" </div>
class="bg-slate-900 text-white"> <div>
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập) <h3 class="text-sm font-bold text-white">Nguồn API sẵn</h3>
</option> <p class="text-xs text-zinc-500">Thêm nhanh tập từ API</p>
</select> </div>
<button v-if="!customServers.length" type="button" </div>
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" <div v-if="sourceData?.sources?.length" class="space-y-3">
@click="addServer"> <div class="flex flex-col gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
<AppIcon name="plus" class="size-3.5" /> <label class="admin-label">Thêm vào server</label>
Tạo server <select v-model="selectedTargetServer" class="admin-input-sm w-full">
</button> <option v-for="(server, index) in customServers" :key="index" :value="index"
</div> class="bg-[#131418] text-white">
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập)
<div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source" </option>
class="rounded-lg border border-white/10 bg-white/5"> </select>
<button type="button" <button v-if="!customServers.length" type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/5" class="admin-btn-sm w-full justify-center bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
@click="toggleSource(source.source)"> @click="addServer">
<span class="text-sm font-black text-white"> <AppIcon name="plus" class="size-3" />
{{ source.source.toUpperCase() }} · {{ source.name }} Tạo server
</span> </button>
<AppIcon name="chevron-down" </div>
class="size-4 text-slate-400 transition" <div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source"
:class="expandedSources[source.source] ? 'rotate-180' : ''" /> class="rounded-xl border border-white/[0.06] bg-white/[0.02]">
</button> <button type="button"
<div v-if="expandedSources[source.source]" class="border-t border-white/10 p-4"> class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/[0.03]"
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0"> @click="toggleSource(source.source)">
<div class="mb-2 flex items-center justify-between gap-2"> <span class="text-sm font-bold text-white uppercase">{{ source.source }} · {{ source.name
<h4 class="text-xs font-black uppercase text-slate-400">{{ server.name }}</h4> }}</span>
<button type="button" <AppIcon name="chevron-down" class="size-4 text-zinc-400 transition"
class="inline-flex h-7 items-center gap-1 rounded-lg bg-yellow-400/10 px-2 text-xs font-semibold text-yellow-400 transition hover:bg-yellow-400/20" :class="expandedSources[source.source] ? 'rotate-180' : ''" />
@click="quickAddAllEpisodes(sourceIndex, serverIndex)"> </button>
<AppIcon name="plus" class="size-3" /> <div v-if="expandedSources[source.source]" class="border-t border-white/[0.06] p-4">
Thêm tất cả <div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0">
</button> <div class="mb-2 flex items-center justify-between gap-2">
</div> <h4 class="admin-label">{{ server.name }}</h4>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> <button type="button"
<div v-for="(ep, epIndex) in server.episodes" :key="epIndex" class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20"
class="group relative rounded-md border border-white/10 bg-white/5 px-2.5 py-1.5"> @click="quickAddAllEpisodes(sourceIndex, serverIndex)">
<p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}</p> <AppIcon name="plus" class="size-3" />
<p v-if="ep.linkM3u8" class="truncate text-[10px] text-slate-500">{{ ep.linkM3u8 }}</p> Thêm tất cả
<p v-else-if="ep.linkEmbed" class="truncate text-[10px] text-slate-500">{{ ep.linkEmbed }}</p> </button>
<button type="button" </div>
class="absolute right-1 top-1 grid size-6 place-items-center rounded bg-yellow-400/10 text-yellow-400 opacity-0 transition hover:bg-yellow-400/20 group-hover:opacity-100" <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
:title="`Thêm ${ep.name || `Tập ${epIndex + 1}`} vào server`" <div v-for="(ep, epIndex) in server.episodes" :key="epIndex"
@click="quickAddEpisode(sourceIndex, serverIndex, epIndex)"> class="group relative rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-1.5">
<AppIcon name="plus" class="size-3" /> <p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}
</button> </p>
<p v-if="ep.linkM3u8" class="truncate text-[10px] text-zinc-500">{{ ep.linkM3u8 }}</p>
<p v-else-if="ep.linkEmbed" class="truncate text-[10px] text-zinc-500">{{ ep.linkEmbed }}
</p>
<button type="button"
class="absolute right-1 top-1 grid size-6 place-items-center rounded bg-yellow-400/10 text-yellow-400 opacity-0 transition hover:bg-yellow-400/20 group-hover:opacity-100"
:title="`Thêm ${ep.name || `Tập ${epIndex + 1}`} vào server`"
@click="quickAddEpisode(sourceIndex, serverIndex, epIndex)">
<AppIcon name="plus" class="size-3" />
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="rounded-xl border border-dashed border-white/[0.1] p-6 text-center">
<p class="text-sm text-zinc-500">Chưa nguồn API hoặc chưa đồng bộ tập phim.</p>
</div>
</div> </div>
</div> </div>
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
<p class="text-sm text-slate-500">Chưa nguồn API nào hoặc chưa đồng bộ tập phim.</p>
</div>
</div> </div>
</div> </div>
<!-- Right Sidebar: Navigation + Save -->
<aside class="space-y-5 lg:sticky lg:top-5 lg:col-span-3">
<!-- Nav -->
<div class="admin-card p-2">
<nav class="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible no-scrollbar">
<button v-for="s in sections" :key="s.key"
class="relative flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-semibold transition lg:w-full"
:class="activeSection === s.key ? 'bg-yellow-400/10 text-yellow-400' : 'text-zinc-400 hover:bg-white/[0.03] hover:text-white'"
@click="activeSection = s.key">
<span v-if="activeSection === s.key"
class="absolute left-0 top-1/2 hidden h-5 w-1 -translate-y-1/2 rounded-r-full bg-yellow-400 lg:block" />
<AppIcon :name="s.icon" class="size-4" />
<span class="flex-1 whitespace-nowrap">{{ s.label }}</span>
<span v-if="s.key === 'actors' && customActors.length"
class="rounded-full bg-white/[0.06] px-1.5 py-0.5 text-[10px] font-bold text-zinc-300">{{
customActors.length }}</span>
<span v-else-if="s.key === 'episodes' && customServers.length"
class="rounded-full bg-white/[0.06] px-1.5 py-0.5 text-[10px] font-bold text-zinc-300">{{
customServers.length }}</span>
</button>
</nav>
</div>
<!-- Save button sticky -->
<div class="admin-card p-4">
<button type="button" class="w-full admin-btn-primary justify-center" :disabled="saving"
@click="handleSave">
<AppIcon name="save" class="size-4" />
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }}
</button>
<Transition name="fade">
<div v-if="saved"
class="mt-2 flex items-center gap-2 rounded-lg border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold text-emerald-400">
<AppIcon name="check" class="size-3" />
Đã lưu thành công
</div>
</Transition>
</div>
<!-- Quick info -->
<div class="admin-card p-4">
<h4 class="mb-3 text-xs font-bold uppercase tracking-wider text-zinc-500">Thông tin nhanh</h4>
<dl class="grid grid-cols-2 gap-x-2 gap-y-2 text-sm">
<dt class="text-zinc-500">Nguồn</dt>
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
<dt class="text-zinc-500">Năm</dt>
<dd class="text-zinc-200">{{ movie.year || '—' }}</dd>
<dt class="text-zinc-500">Tập</dt>
<dd class="text-zinc-200">{{ movie.episode || '—' }}</dd>
<dt class="text-zinc-500">Trạng thái</dt>
<dd class="font-semibold" :class="movie.active ? 'text-emerald-400' : 'text-zinc-400'">
{{ movie.active ? 'Hiển thị' : 'Ẩn' }}
</dd>
</dl>
</div>
</aside>
</div> </div>
</div> </div>
</div> </div>
+112 -152
View File
@@ -22,6 +22,8 @@ const deleteConfirmOpen = ref(false)
const syncSources = ref({ ophim: true, nguonc: true, kkphim: true }) const syncSources = ref({ ophim: true, nguonc: true, kkphim: true })
const syncResult = ref<{ total: number, created: number, updated: number, sourceStats: Record<string, { fetched: number, error?: string }> } | null>(null) const syncResult = ref<{ total: number, created: number, updated: number, sourceStats: Record<string, { fetched: number, error?: string }> } | null>(null)
const { bust } = useCacheBust()
let searchTimeout: ReturnType<typeof setTimeout> | undefined let searchTimeout: ReturnType<typeof setTimeout> | undefined
watch(searchInput, (val) => { watch(searchInput, (val) => {
@@ -72,6 +74,7 @@ async function toggleActive(movie: any) {
method: 'PATCH', method: 'PATCH',
body: { active: movie.active }, body: { active: movie.active },
}) })
bust()
await refresh() await refresh()
} }
@@ -90,6 +93,7 @@ async function handleSync() {
body: { sources }, body: { sources },
}) })
syncResult.value = result syncResult.value = result
bust() // Invalidate cached data across all pages
await refresh() await refresh()
} catch (err) { } catch (err) {
console.error('Sync failed:', err) console.error('Sync failed:', err)
@@ -103,6 +107,7 @@ async function handleDeleteAll() {
try { try {
await $fetch('/api/admin/movies', { method: 'DELETE' }) await $fetch('/api/admin/movies', { method: 'DELETE' })
deleteConfirmOpen.value = false deleteConfirmOpen.value = false
bust() // Invalidate cached data across all pages
await refresh() await refresh()
} catch (err) { } catch (err) {
console.error('Delete failed:', err) console.error('Delete failed:', err)
@@ -192,151 +197,143 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
} }
return 'Vừa xong' return 'Vừa xong'
} }
function sortIcon(column: string): string {
if (sortBy.value !== column) return 'chevron-down'
return sortOrder.value === 'asc' ? 'chevron-up' : 'chevron-down'
}
const syncSourceOptions = [
{ key: 'ophim', label: 'OPhim', domain: 'ophim1.com' },
{ key: 'nguonc', label: 'NguonC', domain: 'phim.nguonc.com' },
{ key: 'kkphim', label: 'KKPhim', domain: 'phimapi.com' },
] as const
</script> </script>
<template> <template>
<div> <div>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 class="text-2xl font-black text-white">Quản phim</h1> <h1 class="admin-section-title">Quản phim</h1>
<p class="mt-1 text-sm text-slate-400">Quản phim hiển thị trên CineK ({{ data?.total || 0 }} phim)</p> <p class="admin-section-subtitle mt-1">Quản phim hiển thị trên CineK ({{ data?.total || 0 }} phim)</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" <button type="button" class="admin-btn-danger" @click="deleteConfirmOpen = true">
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">
<AppIcon name="trash" class="size-4" /> <AppIcon name="trash" class="size-4" />
Xoá tất cả Xoá tất cả
</button> </button>
<button type="button" <button type="button" class="admin-btn-primary" @click="syncOpen = true">
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">
<AppIcon name="refresh" class="size-4" /> <AppIcon name="refresh" class="size-4" />
Đồng bộ từ API Đồng bộ từ API
</button> </button>
</div> </div>
</div> </div>
<div class="rounded-xl border border-white/10 bg-slate-900/50"> <div class="admin-card overflow-hidden">
<div class="flex flex-col gap-3 border-b border-white/10 p-4 sm:flex-row sm:items-center"> <div class="flex flex-col gap-3 border-b border-white/[0.06] p-4 sm:flex-row sm:items-center">
<div class="relative flex-1"> <div class="relative flex-1">
<AppIcon name="search" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <AppIcon name="search" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..." <input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..." class="admin-input pl-10">
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50">
</div> </div>
<select v-model="statusFilter" <select v-model="statusFilter" class="admin-input w-full sm:w-auto">
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50"> <option value="" class="bg-[#131418]">Tất cả trạng thái</option>
<option value="" class="bg-slate-900">Tất cả trạng thái</option> <option value="active" class="bg-[#131418]">Đang hiển th</option>
<option value="active" class="bg-slate-900">Đang hiển thị</option> <option value="inactive" class="bg-[#131418]">Chưa hiển thị</option>
<option value="inactive" class="bg-slate-900">Chưa hiển thị</option>
</select> </select>
<select v-model="sourceFilter" <select v-model="sourceFilter" class="admin-input w-full sm:w-auto">
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50"> <option value="" class="bg-[#131418]">Tất cả nguồn</option>
<option value="" class="bg-slate-900">Tất cả nguồn</option> <option value="ophim" class="bg-[#131418]">OPhim</option>
<option value="ophim" class="bg-slate-900">OPhim</option> <option value="nguonc" class="bg-[#131418]">NguonC</option>
<option value="nguonc" class="bg-slate-900">NguonC</option> <option value="kkphim" class="bg-[#131418]">KKPhim</option>
<option value="kkphim" class="bg-slate-900">KKPhim</option>
</select> </select>
<select v-model="typeFilter" <select v-model="typeFilter" class="admin-input w-full sm:w-auto">
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50"> <option value="" class="bg-[#131418]">Tất cả loại</option>
<option value="" class="bg-slate-900">Tất cả loại</option> <option value="series" class="bg-[#131418]">Phim bộ</option>
<option value="series" class="bg-slate-900">Phim bộ</option> <option value="single" class="bg-[#131418]">Phim lẻ</option>
<option value="single" class="bg-slate-900">Phim lẻ</option>
</select> </select>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto admin-scrollbar">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400"> <tr class="border-b border-white/[0.06] text-left text-xs font-bold uppercase tracking-wide text-slate-400">
<th class="w-12 px-4 py-3 text-center">STT</th> <th class="w-14 px-5 py-3">STT</th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('name')"> <th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('name')">
<div class="flex items-center justify-center gap-1"> <div class="flex items-center gap-1">
Phim Phim
<AppIcon v-if="sortBy === 'name'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" /> <AppIcon :name="sortIcon('name')" class="size-3" :class="sortBy === 'name' ? 'text-yellow-400' : 'opacity-50'" />
</div> </div>
</th> </th>
<th class="px-4 py-3 text-center">Nguồn trùng</th> <th class="px-5 py-3">Nguồn</th>
<th class="px-4 py-3 text-center">Tập</th> <th class="px-5 py-3">Tập</th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('views')"> <th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('views')">
<div class="flex items-center justify-center gap-1"> <div class="flex items-center gap-1">
Lượt xem Lượt xem
<AppIcon v-if="sortBy === 'views'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" /> <AppIcon :name="sortIcon('views')" class="size-3" :class="sortBy === 'views' ? 'text-yellow-400' : 'opacity-50'" />
</div> </div>
</th> </th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('apiUpdatedAt')"> <th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('apiUpdatedAt')">
<div class="flex items-center justify-center gap-1"> <div class="flex items-center gap-1">
Cập nhật API Cập nhật API
<AppIcon v-if="sortBy === 'apiUpdatedAt'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" /> <AppIcon :name="sortIcon('apiUpdatedAt')" class="size-3" :class="sortBy === 'apiUpdatedAt' ? 'text-yellow-400' : 'opacity-50'" />
</div> </div>
</th> </th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('active')"> <th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('active')">
<div class="flex items-center justify-center gap-1"> <div class="flex items-center gap-1">
Trạng thái Trạng thái
<AppIcon v-if="sortBy === 'active'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" /> <AppIcon :name="sortIcon('active')" class="size-3" :class="sortBy === 'active' ? 'text-yellow-400' : 'opacity-50'" />
</div> </div>
</th> </th>
<th class="px-4 py-3 text-center">Chỉnh sửa</th> <th class="px-5 py-3">Tuỳ chỉnh</th>
<th class="px-4 py-3 text-center">Thao tác</th> <th class="px-5 py-3 text-right">Thao tác</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-white/5"> <tbody class="divide-y divide-white/[0.04]">
<tr v-for="movie in movies" :key="movie.id" class="transition hover:bg-white/5"> <tr v-for="movie in movies" :key="movie.id" class="transition hover:bg-white/[0.02]">
<td class="px-4 py-3 text-center text-sm text-slate-400"> <td class="px-5 py-3.5 text-sm font-semibold text-slate-500">
{{ (currentPage - 1) * 20 + movies.indexOf(movie) + 1 }} {{ (currentPage - 1) * 20 + movies.indexOf(movie) + 1 }}
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ movie.name }}</p> <p class="truncate text-sm font-bold text-white">{{ movie.name }}</p>
<p class="truncate text-xs text-slate-400">{{ movie.originName || movie.slug }}</p> <p class="truncate text-xs text-slate-400">{{ movie.originName || movie.slug }}</p>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<span class="rounded-full bg-white/10 px-2.5 py-1 text-xs font-semibold text-white"> <span class="admin-badge bg-blue-400/10 text-blue-400">{{ (movie.sources || []).length }} nguồn</span>
{{ (movie.sources || []).length }} nguồn
</span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-slate-300"> <td class="px-5 py-3.5 text-sm">
<span <span class="font-bold text-yellow-300"
class="font-semibold text-yellow-300"
:title="(movie.sources || []).map((s: any) => `${s.source.toUpperCase()}: ${s.episode || s.episodeTotal || '?'}`).join('\n')"> :title="(movie.sources || []).map((s: any) => `${s.source.toUpperCase()}: ${s.episode || s.episodeTotal || '?'}`).join('\n')">
{{ formatEpisode(movie) }} {{ formatEpisode(movie) }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-slate-300"> <td class="px-5 py-3.5 text-sm text-slate-300">{{ (movie.views || 0).toLocaleString() }}</td>
{{ (movie.views || 0).toLocaleString() }} <td class="px-5 py-3.5 text-sm text-slate-300">
</td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
<span :title="formatDate(movie.apiUpdatedAt || movie.syncedAt)"> <span :title="formatDate(movie.apiUpdatedAt || movie.syncedAt)">
{{ formatRelativeTime(movie.apiUpdatedAt || movie.syncedAt) }} {{ formatRelativeTime(movie.apiUpdatedAt || movie.syncedAt) }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold" <span class="admin-badge" :class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-slate-400/10 text-slate-400'">
:class="movie.active ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'"> {{ movie.active ? 'Hiển thị' : 'Ẩn' }}
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center gap-1">
<span v-if="movie.customContent" <span v-if="movie.customContent" class="admin-badge bg-blue-400/10 text-blue-400"> tả</span>
class="rounded bg-blue-400/10 px-2 py-0.5 text-[10px] font-semibold text-blue-400"> tả</span> <span v-if="movie.customPoster" class="admin-badge bg-purple-400/10 text-purple-400">Poster</span>
<span v-if="movie.customPoster" <span v-if="movie.customThumb" class="admin-badge bg-pink-400/10 text-pink-400">Thumb</span>
class="rounded bg-purple-400/10 px-2 py-0.5 text-[10px] font-semibold text-purple-400">Poster</span> <span v-if="movie.customServers?.length" class="admin-badge bg-emerald-400/10 text-emerald-400">
<span v-if="movie.customThumb"
class="rounded bg-pink-400/10 px-2 py-0.5 text-[10px] font-semibold text-pink-400">Thumb</span>
<span v-if="movie.customServers?.length"
class="rounded bg-emerald-400/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
{{ movie.customServers.length }} server · {{ movie.customServers.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0) }} tập {{ movie.customServers.length }} server · {{ movie.customServers.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0) }} tập
</span> </span>
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length" <span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length" class="text-xs text-slate-500">Mặc định</span>
class="text-xs text-slate-500">Mặc định</span>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink :to="`/admin/phim/${movie.id}`" <NuxtLink :to="`/admin/phim/${movie.id}`"
class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/10 hover:text-white" class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/[0.05] hover:text-white"
title="Chỉnh sửa"> title="Chỉnh sửa">
<AppIcon name="pencil" class="size-4" /> <AppIcon name="pencil" class="size-4" />
</NuxtLink> </NuxtLink>
@@ -346,7 +343,7 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</td> </td>
</tr> </tr>
<tr v-if="!movies.length"> <tr v-if="!movies.length">
<td colspan="9" class="px-4 py-12 text-center text-sm text-slate-400"> <td colspan="9" class="px-5 py-12 text-center text-sm text-slate-400">
{{ debouncedKeyword ? 'Không tìm thấy phim nào.' : 'Chưa có phim nào. Bấm "Đồng bộ từ API" để lấy phim.' }} {{ debouncedKeyword ? 'Không tìm thấy phim nào.' : 'Chưa có phim nào. Bấm "Đồng bộ từ API" để lấy phim.' }}
</td> </td>
</tr> </tr>
@@ -354,32 +351,29 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</table> </table>
</div> </div>
<div v-if="totalPages > 1" class="flex items-center justify-between border-t border-white/10 p-4"> <div v-if="totalPages > 1" class="flex items-center justify-between border-t border-white/[0.06] p-4">
<p class="text-sm text-slate-400">Trang {{ currentPage }} / {{ totalPages }}</p> <p class="text-sm text-slate-400">Trang {{ currentPage }} / {{ totalPages }}</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" <button type="button"
class="grid size-8 place-items-center rounded-lg border border-white/10 text-slate-400 transition hover:bg-white/10 hover:text-white disabled:opacity-30" class="grid size-9 place-items-center rounded-lg border border-white/[0.08] text-slate-400 transition hover:bg-white/[0.05] hover:text-white disabled:opacity-30"
:disabled="currentPage <= 1" @click="currentPage--"> :disabled="currentPage <= 1" @click="currentPage--">
<AppIcon name="chevron-left" class="size-4" />
</button> </button>
<template v-for="(page, index) in visiblePages" :key="index"> <template v-for="(page, index) in visiblePages" :key="index">
<span v-if="page === 'ellipsis'" <span v-if="page === 'ellipsis'" class="grid size-9 place-items-center text-sm text-slate-500">...</span>
class="grid size-8 place-items-center text-sm text-slate-400">
...
</span>
<button v-else type="button" <button v-else type="button"
class="grid size-8 place-items-center rounded-lg border text-sm transition" class="grid size-9 place-items-center rounded-lg border text-sm font-semibold transition"
:class="page === currentPage :class="page === currentPage
? 'border-yellow-400 bg-yellow-400 text-slate-950' ? 'border-yellow-400 bg-yellow-400 text-slate-950'
: 'border-white/10 text-slate-400 hover:bg-white/10 hover:text-white'" : 'border-white/[0.08] text-slate-400 hover:bg-white/[0.05] hover:text-white'"
@click="currentPage = page"> @click="currentPage = page">
{{ page }} {{ page }}
</button> </button>
</template> </template>
<button type="button" <button type="button"
class="grid size-8 place-items-center rounded-lg border border-white/10 text-slate-400 transition hover:bg-white/10 hover:text-white disabled:opacity-30" class="grid size-9 place-items-center rounded-lg border border-white/[0.08] text-slate-400 transition hover:bg-white/[0.05] hover:text-white disabled:opacity-30"
:disabled="currentPage >= totalPages" @click="currentPage++"> :disabled="currentPage >= totalPages" @click="currentPage++">
<AppIcon name="chevron-right" class="size-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -389,50 +383,34 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
<Transition name="modal-fade"> <Transition name="modal-fade">
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3"> <div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="syncOpen = false" /> <div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="syncOpen = false" />
<div class="relative w-full max-w-md rounded-xl border border-white/10 bg-slate-900 p-6 shadow-2xl"> <div class="relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#131418] p-6 shadow-2xl">
<button type="button" <button type="button"
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10" class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-slate-400 transition hover:bg-white/[0.05] hover:text-white"
@click="syncOpen = false"> @click="syncOpen = false">
<AppIcon name="x" class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2> <div class="mb-5">
<p class="mt-1 text-sm text-slate-400">Chọn nguồn để đồng bộ phim vào hệ thống</p> <h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
<p class="mt-1 text-sm text-slate-400">Chọn nguồn để đồng bộ phim vào hệ thống</p>
</div>
<div class="mt-5 space-y-3"> <div class="space-y-3">
<label <label v-for="source in syncSourceOptions" :key="source.key"
class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20"> class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.1] hover:bg-white/[0.05]">
<div> <div>
<p class="text-sm font-semibold text-white">OPhim</p> <p class="text-sm font-bold text-white">{{ source.label }}</p>
<p class="text-xs text-slate-400">ophim1.com</p> <p class="text-xs text-slate-400">{{ source.domain }}</p>
</div> </div>
<AdminToggle v-model="syncSources.ophim" /> <AdminToggle v-model="syncSources[source.key]" />
</label>
<label
class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div>
<p class="text-sm font-semibold text-white">NguonC</p>
<p class="text-xs text-slate-400">phim.nguonc.com</p>
</div>
<AdminToggle v-model="syncSources.nguonc" />
</label>
<label
class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div>
<p class="text-sm font-semibold text-white">KKPhim</p>
<p class="text-xs text-slate-400">phimapi.com</p>
</div>
<AdminToggle v-model="syncSources.kkphim" />
</label> </label>
</div> </div>
<div v-if="syncResult" class="mt-4 rounded-lg border border-white/10 bg-white/5 p-4"> <div v-if="syncResult" class="mt-4 rounded-xl border border-white/[0.06] bg-white/[0.03] p-4">
<p class="text-sm font-semibold text-white">Kết quả đồng bộ</p> <p class="text-sm font-bold text-white">Kết quả đồng bộ</p>
<div class="mt-2 space-y-1 text-xs text-slate-400"> <div class="mt-2 space-y-1 text-xs text-slate-400">
<p>Tổng phim lấy được: <span class="font-bold text-white">{{ syncResult.total }}</span></p> <p>Tổng phim lấy được: <span class="font-bold text-white">{{ syncResult.total }}</span></p>
<p>Đã tạo: <span class="font-bold text-green-400">{{ syncResult.created }}</span></p> <p>Đã tạo: <span class="font-bold text-emerald-400">{{ syncResult.created }}</span></p>
<p>Đã cập nhật: <span class="font-bold text-yellow-400">{{ syncResult.updated }}</span></p> <p>Đã cập nhật: <span class="font-bold text-yellow-400">{{ syncResult.updated }}</span></p>
<div v-if="syncResult.sourceStats" class="mt-2"> <div v-if="syncResult.sourceStats" class="mt-2">
<p class="font-semibold text-white">Theo nguồn:</p> <p class="font-semibold text-white">Theo nguồn:</p>
@@ -447,13 +425,8 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button type="button" <button type="button" class="admin-btn-secondary" @click="syncOpen = false">Huỷ</button>
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10" <button type="button" class="admin-btn-primary"
@click="syncOpen = false">
Huỷ
</button>
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="syncing || !Object.values(syncSources).some(Boolean)" @click="handleSync"> :disabled="syncing || !Object.values(syncSources).some(Boolean)" @click="handleSync">
<AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" /> <AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
{{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }} {{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }}
@@ -466,19 +439,18 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
<Transition name="modal-fade"> <Transition name="modal-fade">
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-70 grid place-items-center px-3"> <div v-if="deleteConfirmOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="deleteConfirmOpen = false" /> <div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="deleteConfirmOpen = false" />
<div class="relative w-full max-w-sm rounded-xl border border-white/10 bg-slate-900 p-6 shadow-2xl"> <div class="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#131418] p-6 shadow-2xl">
<div class="mb-4 grid size-12 place-items-center rounded-full bg-red-500/10">
<AppIcon name="trash" class="size-6 text-red-400" />
</div>
<h2 class="text-xl font-black text-white">Xoá tất cả phim?</h2> <h2 class="text-xl font-black text-white">Xoá tất cả phim?</h2>
<p class="mt-2 text-sm leading-6 text-slate-400"> <p class="mt-2 text-sm leading-6 text-slate-400">
Hành động này sẽ xoá toàn bộ {{ data?.total || 0 }} phim trong hệ thống, bao gồm cả các tuỳ chỉnh. Không thể hoàn tác. Hành động này sẽ xoá toàn bộ {{ data?.total || 0 }} phim trong hệ thống, bao gồm cả các tuỳ chỉnh. Không thể hoàn tác.
</p> </p>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button type="button" class="admin-btn-secondary" @click="deleteConfirmOpen = false">Huỷ</button>
<button type="button" <button type="button"
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10" class="admin-btn bg-red-500 text-white hover:bg-red-400 disabled:opacity-50"
@click="deleteConfirmOpen = false">
Huỷ
</button>
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-red-500 px-5 text-sm font-black text-white transition hover:bg-red-400 disabled:opacity-50"
:disabled="deleting" @click="handleDeleteAll"> :disabled="deleting" @click="handleDeleteAll">
<AppIcon name="trash" class="size-4" /> <AppIcon name="trash" class="size-4" />
{{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }} {{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }}
@@ -490,15 +462,3 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</Teleport> </Teleport>
</div> </div>
</template> </template>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.18s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>
+43 -45
View File
@@ -59,75 +59,73 @@ const members = computed(() => data.value?.items || [])
<template> <template>
<div> <div>
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-black text-white">Quản thành viên</h1> <h1 class="admin-section-title">Quản thành viên</h1>
<p class="mt-1 text-sm text-slate-400">Quản tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p> <p class="admin-section-subtitle mt-1">Quản tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
</div> </div>
<div class="rounded-xl border border-white/10 bg-slate-900/50"> <div class="admin-card overflow-hidden">
<div class="flex items-center gap-3 border-b border-white/10 p-4"> <div class="flex items-center gap-3 border-b border-white/[0.06] p-4">
<div class="relative flex-1"> <div class="relative flex-1">
<AppIcon name="search" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <AppIcon name="search" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="searchInput" type="search" placeholder="Tìm kiếm thành viên..." <input v-model="searchInput" type="search" placeholder="Tìm kiếm thành viên..."
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50"> class="admin-input pl-10">
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto admin-scrollbar">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400"> <tr class="border-b border-white/[0.06] text-left text-xs font-bold uppercase tracking-wide text-slate-400">
<th class="w-12 px-4 py-3 text-center">STT</th> <th class="w-14 px-5 py-3">STT</th>
<th class="px-4 py-3 text-center">Thành viên</th> <th class="px-5 py-3">Thành viên</th>
<th class="px-4 py-3 text-center">Vai trò</th> <th class="px-5 py-3">Vai trò</th>
<th class="px-4 py-3 text-center">Trạng thái</th> <th class="px-5 py-3">Trạng thái</th>
<th class="px-4 py-3 text-center">Tham gia</th> <th class="px-5 py-3">Tham gia</th>
<th class="px-4 py-3 text-center">Thao tác</th> <th class="px-5 py-3 text-right">Thao tác</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-white/5"> <tbody class="divide-y divide-white/[0.04]">
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/5"> <tr v-for="member in members" :key="member.id" class="transition hover:bg-white/[0.02]">
<td class="px-4 py-3 text-center text-sm text-slate-400">{{ members.indexOf(member) + 1 }}</td> <td class="px-5 py-3.5 text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}</td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<div class="min-w-0"> <div class="flex items-center gap-3">
<p class="truncate text-sm font-semibold text-white">{{ member.name || '—' }}</p> <div class="grid size-9 place-items-center rounded-full bg-gradient-to-br from-slate-700 to-slate-800 text-sm font-bold text-slate-300">
<p class="truncate text-xs text-slate-400">{{ member.email }}</p> {{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
</div>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<select :value="member.role" <select :value="member.role"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-white outline-none" class="h-8 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs font-bold text-white outline-none transition hover:bg-white/[0.05] focus:border-yellow-400/50"
:class="{
'text-red-400': member.role === 'admin',
'text-blue-400': member.role === 'moderator',
'text-slate-400': member.role === 'user',
}"
@change="changeRole(member, ($event.target as HTMLSelectElement).value)"> @change="changeRole(member, ($event.target as HTMLSelectElement).value)">
<option value="user" class="bg-slate-900">User</option> <option value="user" class="bg-[#131418]">User</option>
<option value="moderator" class="bg-slate-900">Moderator</option> <option value="moderator" class="bg-[#131418]">Moderator</option>
<option value="admin" class="bg-slate-900">Admin</option> <option value="admin" class="bg-[#131418]">Admin</option>
</select> </select>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5">
<AdminToggle :model-value="member.active" <AdminToggle :model-value="member.active"
@update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" /> @update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" />
</td> </td>
<td class="px-4 py-3 text-center text-sm text-slate-400"> <td class="px-5 py-3.5 text-sm text-slate-400">
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }} {{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-5 py-3.5 text-right">
<div class="flex items-center justify-center gap-1"> <button type="button"
<button type="button" class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10"
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10" :disabled="currentUser?.id === member.id"
:disabled="currentUser?.id === member.id" :title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'" @click="deleteMember(member)">
@click="deleteMember(member)"> <AppIcon name="trash" class="size-4" />
<AppIcon name="trash" class="size-4" /> </button>
</button>
</div>
</td> </td>
</tr> </tr>
<tr v-if="!members.length"> <tr v-if="!members.length">
<td colspan="6" class="px-4 py-12 text-center text-sm text-slate-400"> <td colspan="6" class="px-5 py-12 text-center text-sm text-slate-400">
{{ debouncedKeyword ? 'Không tìm thấy thành viên nào.' : 'Chưa có thành viên nào.' }} {{ debouncedKeyword ? 'Không tìm thấy thành viên nào.' : 'Chưa có thành viên nào.' }}
</td> </td>
</tr> </tr>
@@ -135,7 +133,7 @@ const members = computed(() => data.value?.items || [])
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-white/10 p-4"> <div class="flex items-center justify-between border-t border-white/[0.06] p-4">
<p class="text-sm text-slate-400">Hiển thị {{ members.length }} / {{ data?.total || 0 }} thành viên</p> <p class="text-sm text-slate-400">Hiển thị {{ members.length }} / {{ data?.total || 0 }} thành viên</p>
</div> </div>
</div> </div>
+37 -5
View File
@@ -3,7 +3,7 @@ const route = useRoute()
const selectedServer = ref(0) const selectedServer = ref(0)
const movieInfoOpen = ref(false) const movieInfoOpen = ref(false)
const contentExpanded = ref(false) const contentExpanded = ref(false)
const activeTab = ref<'episodes' | 'actors'>('episodes') const activeTab = ref<'episodes' | 'actors' | 'suggestions'>('episodes')
const isFavoriteMovie = ref(false) const isFavoriteMovie = ref(false)
const actionMessage = ref('') const actionMessage = ref('')
const actionBusy = ref(false) const actionBusy = ref(false)
@@ -19,6 +19,17 @@ const { data: movie, pending, error } = await useFetch(() => `/api/movies/${rout
const activeServer = computed(() => servers.value[selectedServer.value]) const activeServer = computed(() => servers.value[selectedServer.value])
const actors = computed(() => movie.value?.actors ?? []) const actors = computed(() => movie.value?.actors ?? [])
const actorSummary = computed(() => actors.value.map((actor: any) => actor.name).filter(Boolean).slice(0, 6).join(', ')) const actorSummary = computed(() => actors.value.map((actor: any) => actor.name).filter(Boolean).slice(0, 6).join(', '))
// Suggestions
const currentCategories = computed(() => movie.value?.categories ?? [])
const { data: allMoviesData } = await useFetch('/api/movies', { query: computed(() => ({ page: 1 })) })
const suggestedMovies = computed(() => {
const items = (allMoviesData.value?.items ?? []) as any[]
const currentSlug = route.params.slug
const cats = currentCategories.value
if (!cats.length) return []
return items.filter((m: any) => m.slug !== currentSlug && m.categories?.some((c: string) => cats.includes(c))).slice(0, 8)
})
const episodeCount = computed(() => { const episodeCount = computed(() => {
const total = servers.value.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0) const total = servers.value.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0)
return total || undefined return total || undefined
@@ -405,6 +416,14 @@ useHead(() => ({
<span v-if="activeTab === 'actors'" <span v-if="activeTab === 'actors'"
class="absolute bottom-0 left-0 w-full h-0.5 bg-[#F5C518] rounded-t-md" /> class="absolute bottom-0 left-0 w-full h-0.5 bg-[#F5C518] rounded-t-md" />
</button> </button>
<button type="button"
class="pb-3 text-[13px] md:text-[15px] font-semibold transition-colors relative whitespace-nowrap"
:class="activeTab === 'suggestions' ? 'text-[#F5C518]' : 'text-slate-400 hover:text-white'"
@click="activeTab = 'suggestions'">
Đề xuất
<span v-if="activeTab === 'suggestions'"
class="absolute bottom-0 left-0 w-full h-0.5 bg-[#F5C518] rounded-t-md" />
</button>
</div> </div>
<!-- Episodes Tab --> <!-- Episodes Tab -->
@@ -417,11 +436,11 @@ useHead(() => ({
</div> </div>
<button v-for="(server, index) in servers" :key="index" type="button" <button v-for="(server, index) in servers" :key="index" type="button"
class="group flex items-center gap-1.5 text-[13px] transition-colors rounded-md px-3.5 py-2 border font-medium" class="group flex items-center gap-1.5 text-[13px] transition-colors rounded-md px-3.5 py-2 border font-medium"
:class="selectedServer === index :class="selectedServer === index ? 'border-white/30 text-white bg-white/5' : 'border-transparent text-white/60 bg-white/5 hover:text-white hover:bg-white/8'"
? 'border-white/30 text-white bg-white/5' @click="selectedServer = index">
: 'border-transparent text-white/60 hover:text-white'" @click="selectedServer = index"> <AppIcon name="captions" class="size-3.5 opacity-60" />
<span>{{ serverLabel(server, index) }}</span> <span>{{ serverLabel(server, index) }}</span>
<span class="w-5 h-5 rounded-full font-bold text-[10px]" <span class="w-5 h-5 rounded-full font-bold text-[10px] flex items-center justify-center"
:class="selectedServer === index ? 'bg-white text-black' : 'bg-white/20 text-white'"> :class="selectedServer === index ? 'bg-white text-black' : 'bg-white/20 text-white'">
{{ server.episodes?.length || 0 }} {{ server.episodes?.length || 0 }}
</span> </span>
@@ -469,6 +488,19 @@ useHead(() => ({
Chưa thông tin diễn viên. Chưa thông tin diễn viên.
</p> </p>
</div> </div>
<!-- Suggestions Tab -->
<div v-if="activeTab === 'suggestions'">
<h3 class="text-xl md:text-2xl font-bold text-text-primary mb-6"> thể bạn sẽ thích</h3>
<div
class="flex snap-x gap-3 overflow-x-auto pb-3 [&::-webkit-scrollbar]:hidden [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent">
<HomeMovieCard v-for="m in suggestedMovies" :key="m.slug" :movie="m" />
</div>
<p v-if="!suggestedMovies.length"
class="mt-4 rounded-lg border border-white/10 bg-[#191b24] p-6 text-center text-sm text-slate-400">
Không phim đề xuất phù hợp.
</p>
</div>
</div> </div>
<!-- Comment Section --> <!-- Comment Section -->