mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 13:27:46 +00:00
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:
+1
-1
@@ -7,7 +7,7 @@ const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLoadingIndicator color="#facc15" :height="2" />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
<NuxtPage :key="$route.fullPath" />
|
||||
</NuxtLayout>
|
||||
<AppFooter v-if="!isAdmin" />
|
||||
<ScrollToTop v-if="!isAdmin" />
|
||||
|
||||
@@ -76,3 +76,101 @@ select:disabled {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ const model = defineModel<boolean>({ required: true })
|
||||
|
||||
<template>
|
||||
<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="model ? 'bg-yellow-400' : 'bg-white/15'" @click="model = !model">
|
||||
<span class="pointer-events-none inline-block size-5 rounded-full bg-white shadow-lg transition duration-200 ease-in-out"
|
||||
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-slate-600'" @click="model = !model">
|
||||
<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'" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -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
@@ -33,42 +33,43 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950">
|
||||
<div class="min-h-screen bg-[#0A0B0E]">
|
||||
<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'">
|
||||
<div class="flex h-16 items-center justify-between border-b border-white/10 px-4">
|
||||
<NuxtLink to="/admin" class="flex items-center gap-2">
|
||||
<div class="grid size-8 place-items-center rounded-lg bg-yellow-400">
|
||||
<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-3">
|
||||
<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" />
|
||||
</div>
|
||||
<span class="text-lg font-black text-white">CineK Admin</span>
|
||||
</NuxtLink>
|
||||
<button type="button" class="grid size-8 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden"
|
||||
<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">
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
</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"
|
||||
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)
|
||||
? 'bg-yellow-400/10 text-yellow-400'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white'">
|
||||
<AppIcon :name="item.icon" class="size-5" />
|
||||
: 'text-slate-400 hover:bg-white/[0.03] hover:text-white'">
|
||||
<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 }}
|
||||
</NuxtLink>
|
||||
</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="/"
|
||||
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" />
|
||||
Về trang chủ
|
||||
</NuxtLink>
|
||||
<button type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-400/10"
|
||||
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">
|
||||
<AppIcon name="log-out" class="size-5" />
|
||||
Đăng xuất
|
||||
@@ -78,19 +79,25 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
|
||||
<div class="lg:pl-64">
|
||||
<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">
|
||||
<button type="button" class="grid size-10 place-items-center rounded-lg text-white hover:bg-white/10 lg:hidden"
|
||||
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-xl text-slate-400 transition hover:bg-white/[0.05] hover:text-white lg:hidden"
|
||||
@click="sidebarOpen = true">
|
||||
<AppIcon name="menu" class="size-5" />
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<div class="grid size-9 place-items-center rounded-full bg-yellow-400/10">
|
||||
<span class="text-sm font-black text-yellow-400">{{ displayInitial }}</span>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<div class="hidden items-center gap-2 text-sm text-slate-400 md:flex">
|
||||
<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 class="hidden sm:block">
|
||||
<p class="text-sm font-semibold text-white">{{ displayName }}</p>
|
||||
<p class="text-xs capitalize text-slate-400">{{ user?.role || 'admin' }}</p>
|
||||
<div class="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-[#131418] px-3 py-1.5">
|
||||
<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">
|
||||
{{ 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>
|
||||
</header>
|
||||
@@ -101,7 +108,7 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+56
-47
@@ -16,66 +16,75 @@ const allowRegistration = ref(true)
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-black text-white">Cài đặt</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Quản lý cài đặt hệ thống CineK</p>
|
||||
<h1 class="admin-section-title">Cài đặt</h1>
|
||||
<p class="admin-section-subtitle mt-1">Quản lý cài đặt hệ thống CineK</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<div class="grid size-10 place-items-center rounded-lg bg-yellow-400/10">
|
||||
<AppIcon name="settings" class="size-4 text-yellow-400" />
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<div class="admin-card p-5">
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<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 cơ bản về website</p>
|
||||
</div>
|
||||
</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 cơ bản về website</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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">Mô 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 class="space-y-4">
|
||||
<div>
|
||||
<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 class="admin-card p-5">
|
||||
<h2 class="mb-5 text-lg font-black text-white">Tùy chọn hệ thống</h2>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-white">Mô tả website</label>
|
||||
<textarea v-model="siteDescription" rows="3"
|
||||
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>
|
||||
<div class="space-y-3">
|
||||
<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">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 ký</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Cho phép người dùng mới đăng ký tài khoản</p>
|
||||
</div>
|
||||
<AdminToggle v-model="allowRegistration" />
|
||||
</label>
|
||||
</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 class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<h2 class="mb-5 text-lg font-black text-white">Tùy chọn hệ thống</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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">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 ký</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Cho phép người dùng mới đăng ký tài khoản</p>
|
||||
</div>
|
||||
<AdminToggle v-model="allowRegistration" />
|
||||
</label>
|
||||
<div class="space-y-6">
|
||||
<div class="admin-card-gradient p-5">
|
||||
<h3 class="mb-2 text-lg font-black text-white">Cần giúp đỡ?</h3>
|
||||
<p class="text-sm leading-relaxed text-slate-400">
|
||||
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ì.
|
||||
</p>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
+206
-186
@@ -40,7 +40,7 @@ function percentOf(value: number, total: number) {
|
||||
}
|
||||
|
||||
// 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 ''
|
||||
const angle = (value / total) * 360
|
||||
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`
|
||||
}
|
||||
|
||||
// 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
|
||||
const maxViews = computed(() => {
|
||||
const movies = stats.value?.topMovies || []
|
||||
@@ -61,245 +75,281 @@ const maxViews = computed(() => {
|
||||
|
||||
function barWidth(views: number) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-black text-white">Dashboard</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Tổng quan hệ thống CineK</p>
|
||||
<h1 class="admin-section-title">Dashboard</h1>
|
||||
<p class="admin-section-subtitle mt-1">Tổng quan hệ thống CineK</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300"
|
||||
@click="syncOpen = true">
|
||||
<button type="button" class="admin-btn-primary" @click="syncOpen = true">
|
||||
<AppIcon name="refresh" class="size-4" />
|
||||
Đồng bộ phim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-400">Tổng phim</p>
|
||||
<p class="mt-2 text-3xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<!-- Bento: KPI grid -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:grid-rows-2">
|
||||
<!-- Hero: Total movies (col-span-2, row-span-2) -->
|
||||
<div class="admin-card-gradient relative overflow-hidden p-6 sm:col-span-2 lg:row-span-2">
|
||||
<!-- subtle accent glow -->
|
||||
<div class="pointer-events-none absolute -right-16 -top-16 size-48 rounded-full bg-yellow-400/10 blur-3xl" />
|
||||
|
||||
<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 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 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>
|
||||
<p class="text-sm font-semibold text-slate-400">Đang hiển thị</p>
|
||||
<p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-green-400/10 text-green-400">
|
||||
<AppIcon name="trending-up" class="size-5" />
|
||||
<p class="admin-label">Đang hiển thị</p>
|
||||
<div class="grid size-8 place-items-center rounded-lg bg-emerald-400/10 text-emerald-400">
|
||||
<AppIcon name="trending-up" class="size-4" />
|
||||
</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 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>
|
||||
<p class="text-sm font-semibold text-slate-400">Phim bộ</p>
|
||||
<p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-blue-400/10 text-blue-400">
|
||||
<AppIcon name="tv" class="size-5" />
|
||||
<p class="admin-label">Phim bộ</p>
|
||||
<div class="grid size-8 place-items-center rounded-lg bg-blue-400/10 text-blue-400">
|
||||
<AppIcon name="tv" class="size-4" />
|
||||
</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 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>
|
||||
<p class="text-sm font-semibold text-slate-400">Phim lẻ</p>
|
||||
<p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p>
|
||||
</div>
|
||||
<div class="grid size-11 place-items-center rounded-xl bg-purple-400/10 text-purple-400">
|
||||
<AppIcon name="play" class="size-5" />
|
||||
<p class="admin-label">Phim lẻ</p>
|
||||
<div class="grid size-8 place-items-center rounded-lg bg-purple-400/10 text-purple-400">
|
||||
<AppIcon name="play" class="size-4" />
|
||||
</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>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-3">
|
||||
<!-- Donut Chart: Source Distribution -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Phân bổ nguồn phim</p>
|
||||
<!-- Bento: Charts row -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||
<!-- Donut: Source distribution -->
|
||||
<div class="admin-card p-5">
|
||||
<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="relative">
|
||||
<svg viewBox="0 0 100 100" class="size-44">
|
||||
<!-- OPhim segment -->
|
||||
<svg viewBox="0 0 100 100" class="size-40">
|
||||
<template v-if="stats?.ophim">
|
||||
<path :d="donutSegment(stats.ophim, stats.total, 0)" fill="#facc15" />
|
||||
</template>
|
||||
<!-- NguonC segment -->
|
||||
<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>
|
||||
<!-- KKPhim segment -->
|
||||
<template v-if="stats?.kkphim">
|
||||
<path :d="donutSegment(stats.kkphim, stats.total, ((stats.ophim || 0) + (stats.nguonc || 0)) / (stats.total || 1) * 360)" fill="#60a5fa" />
|
||||
</template>
|
||||
<!-- Inner circle for donut effect -->
|
||||
<circle cx="50" cy="50" r="24" fill="#0f172a" />
|
||||
<circle cx="50" cy="50" r="22" fill="#131418" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 grid place-items-center">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-[10px] text-slate-400">total</p>
|
||||
<p class="admin-num text-xl">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-[10px] uppercase tracking-widest text-zinc-500">phim</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="mt-4 space-y-1.5">
|
||||
<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">
|
||||
<span class="size-3 rounded-full bg-yellow-400" />
|
||||
<span class="text-slate-300">OPhim</span>
|
||||
<span class="size-2 rounded-full" :class="s.color" />
|
||||
<span class="text-zinc-400">{{ s.label }}</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.ophim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.ophim || 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-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>
|
||||
<span class="font-semibold text-zinc-200">{{ stats?.[s.key] || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Donut Chart: Series vs Single -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Phim bộ vs Phim lẻ</p>
|
||||
<!-- Donut: Series vs Single -->
|
||||
<div class="admin-card p-5">
|
||||
<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="relative">
|
||||
<svg viewBox="0 0 100 100" class="size-44">
|
||||
<svg viewBox="0 0 100 100" class="size-40">
|
||||
<template v-if="stats?.series">
|
||||
<path :d="donutSegment(stats.series, stats.total, 0)" fill="#3b82f6" />
|
||||
</template>
|
||||
<template v-if="stats?.single">
|
||||
<path :d="donutSegment(stats.single, stats.total, (stats.series || 0) / (stats.total || 1) * 360)" fill="#a855f7" />
|
||||
</template>
|
||||
<circle cx="50" cy="50" r="24" fill="#0f172a" />
|
||||
<circle cx="50" cy="50" r="22" fill="#131418" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 grid place-items-center">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-[10px] text-slate-400">total</p>
|
||||
<p class="admin-num text-xl">{{ stats?.total?.toLocaleString() || 0 }}</p>
|
||||
<p class="text-[10px] uppercase tracking-widest text-zinc-500">phim</p>
|
||||
</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 gap-2">
|
||||
<span class="size-3 rounded-full bg-blue-500" />
|
||||
<span class="text-slate-300">Phim bộ</span>
|
||||
<span class="size-2 rounded-full bg-blue-500" />
|
||||
<span class="text-zinc-400">Phim bộ</span>
|
||||
</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 class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-purple-500" />
|
||||
<span class="text-slate-300">Phim lẻ</span>
|
||||
<span class="size-2 rounded-full bg-purple-500" />
|
||||
<span class="text-zinc-400">Phim lẻ</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">{{ stats?.single || 0 }} <span class="text-slate-500">({{ percentOf(stats?.single || 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-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>
|
||||
<span class="font-semibold text-zinc-200">{{ stats?.single || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bar Chart: Top Movies by Views -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Top 5 phim nhiều lượt xem</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<!-- Top movies (col-span-2) -->
|
||||
<div class="admin-card p-5 sm:col-span-2 lg:col-span-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<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 class="mb-1 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="truncate text-xs text-white">{{ movie.name }}</span>
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span class="flex size-5 flex-shrink-0 items-center justify-center rounded-md text-[10px] font-bold"
|
||||
: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>
|
||||
<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 class="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="i === 0 ? 'bg-yellow-400' : i === 1 ? 'bg-slate-300' : i === 2 ? 'bg-amber-500' : 'bg-slate-500'"
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div class="h-full rounded-full transition-all duration-700"
|
||||
:class="i === 0 ? 'bg-yellow-400' : 'bg-zinc-500'"
|
||||
:style="{ width: barWidth(movie.views || 0) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!stats?.topMovies?.length" class="text-center text-xs text-slate-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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Active vs Inactive Comparison Bar -->
|
||||
<div class="mb-6 rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<p class="text-sm font-semibold text-slate-400">Trạng thái hiển thị</p>
|
||||
<div class="mt-4 flex gap-6">
|
||||
<div class="flex-1">
|
||||
<!-- Status comparison (full width) -->
|
||||
<div class="admin-card p-6">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<p class="admin-label">Trạng thái hiển thị</p>
|
||||
<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="flex items-center gap-2">
|
||||
<span class="size-3 rounded-full bg-green-400" />
|
||||
<span class="text-white">Đang hiển thị</span>
|
||||
</div>
|
||||
<span class="font-semibold text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</span>
|
||||
<span class="text-zinc-400">Đang hiển thị</span>
|
||||
<span class="font-semibold text-emerald-400 tabular-nums">{{ stats?.active?.toLocaleString() || 0 }} · {{ percentOf(stats?.active || 0, stats?.total || 0) }}%</span>
|
||||
</div>
|
||||
<div class="h-4 overflow-hidden rounded-full bg-white/10">
|
||||
<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-2.5 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<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>
|
||||
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.active || 0, stats?.total || 0) }}%</p>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div>
|
||||
<div class="mb-2 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-white">Chưa hiển thị</span>
|
||||
</div>
|
||||
<span class="font-semibold text-slate-400">{{ stats?.inactive?.toLocaleString() || 0 }}</span>
|
||||
<span class="text-zinc-400">Chưa hiển thị</span>
|
||||
<span class="font-semibold text-zinc-400 tabular-nums">{{ stats?.inactive?.toLocaleString() || 0 }} · {{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</span>
|
||||
</div>
|
||||
<div class="h-4 overflow-hidden rounded-full bg-white/10">
|
||||
<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-2.5 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div class="h-full rounded-full bg-zinc-600 transition-all duration-700" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" />
|
||||
</div>
|
||||
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,50 +359,32 @@ function barWidth(views: number) {
|
||||
<Transition name="modal-fade">
|
||||
<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="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"
|
||||
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">
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
|
||||
<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 class="mb-5">
|
||||
<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">
|
||||
<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 class="space-y-3">
|
||||
<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>
|
||||
<p class="text-sm font-semibold text-white">OPhim</p>
|
||||
<p class="text-xs text-slate-400">ophim1.com</p>
|
||||
<p class="text-sm font-semibold text-white">{{ source.label }}</p>
|
||||
<p class="text-xs text-zinc-500">{{ source.domain }}</p>
|
||||
</div>
|
||||
<AdminToggle v-model="syncSources.ophim" />
|
||||
</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" />
|
||||
<AdminToggle v-model="syncSources[source.key]" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<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"
|
||||
@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"
|
||||
<button type="button" class="admin-btn-secondary" @click="syncOpen = false">Huỷ</button>
|
||||
<button type="button" class="admin-btn-primary"
|
||||
:disabled="syncing || !Object.values(syncSources).some(Boolean)"
|
||||
@click="handleSync">
|
||||
<AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
|
||||
@@ -365,15 +397,3 @@ function barWidth(views: number) {
|
||||
</Teleport>
|
||||
</div>
|
||||
</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
@@ -45,58 +45,65 @@ async function handleLogin() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-950 p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mb-4 inline-flex items-center gap-2">
|
||||
<div class="grid size-10 place-items-center rounded-xl bg-yellow-400">
|
||||
<span class="text-lg font-black text-slate-950">C</span>
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#0A0B0E] p-4">
|
||||
<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="relative w-full max-w-md">
|
||||
<div class="rounded-3xl border border-white/[0.06] bg-[#131418]/80 p-1 shadow-2xl backdrop-blur-sm">
|
||||
<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 lý hệ thống</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400">Đăng nhập để tiếp tục</p>
|
||||
</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>
|
||||
<p class="text-sm text-slate-400">Đăng nhập để quản lý 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>
|
||||
|
||||
<p class="mt-6 text-center text-xs text-slate-500">
|
||||
|
||||
+438
-362
@@ -56,8 +56,17 @@ const expandedSources = ref<Record<string, boolean>>({})
|
||||
const selectedTargetServer = ref(0)
|
||||
const collapsedServers = ref<Record<number, 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 saved = ref(false)
|
||||
const { bust } = useCacheBust()
|
||||
|
||||
watch(movie, (m) => {
|
||||
if (m) {
|
||||
@@ -69,9 +78,9 @@ watch(movie, (m) => {
|
||||
: []
|
||||
customServers.value = m.customServers?.length
|
||||
? m.customServers.map((server: any) => ({
|
||||
name: server.name || '',
|
||||
episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })),
|
||||
}))
|
||||
name: server.name || '',
|
||||
episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -229,6 +238,7 @@ async function handleSave() {
|
||||
},
|
||||
})
|
||||
saved.value = true
|
||||
bust() // Invalidate cached data across all pages
|
||||
await refresh()
|
||||
setTimeout(() => { saved.value = false }, 2500)
|
||||
} catch (err) {
|
||||
@@ -265,424 +275,490 @@ function useApiActors() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="space-y-5">
|
||||
<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" />
|
||||
Quay lại danh sách phim
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="!movie" class="rounded-xl border border-white/10 bg-slate-900/50 p-12 text-center">
|
||||
<p class="text-slate-400">Đang tải...</p>
|
||||
<div v-if="!movie" class="admin-card p-12 text-center">
|
||||
<p class="text-sm text-zinc-500">Đang tải...</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-2xl font-black text-white">{{ movie.name }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{{ movie.originName }} · {{ movie.source?.toUpperCase() }} · {{ movie.year || 'N/A' }}
|
||||
</p>
|
||||
<div v-else class="space-y-5">
|
||||
<!-- Header Card -->
|
||||
<div class="admin-card p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div class="shrink-0">
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="saved"
|
||||
class="mb-4 flex items-center gap-2 rounded-lg border border-green-400/20 bg-green-400/10 px-4 py-3 text-sm font-semibold text-green-400">
|
||||
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" />
|
||||
Đã lưu thành công
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-12 items-start">
|
||||
<!-- LEFT COLUMN: Main Content & Episode Management (7 cols) -->
|
||||
<div class="space-y-6 lg:col-span-7">
|
||||
<!-- Mô tả 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-blue-400/10">
|
||||
<AppIcon name="type" class="size-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-black text-white">Mô 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 mô 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 mô tả tuỳ chỉnh -> Hiển thị mô 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 mô 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 có 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 có 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">
|
||||
<!-- Sidebar nav + content -->
|
||||
<div class="grid gap-5 lg:grid-cols-12 lg:items-start">
|
||||
<!-- Content -->
|
||||
<div class="space-y-5 lg:col-span-9">
|
||||
<!-- Section: Thông tin -->
|
||||
<div v-if="activeSection === 'info'" 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-amber-400/10">
|
||||
<AppIcon name="info" class="size-4 text-amber-400" />
|
||||
<div class="admin-icon-square">
|
||||
<AppIcon name="info" class="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-black text-white">Thông tin phim</h2>
|
||||
<p class="text-xs text-slate-400">Thông tin chi tiết từ hệ thống</p>
|
||||
<h2 class="text-sm font-bold text-white">Thông tin chi tiết</h2>
|
||||
<p class="text-xs text-zinc-500">Dữ liệu từ hệ thống</p>
|
||||
</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>
|
||||
<dt class="text-slate-500">Nguồn</dt>
|
||||
<dd class="truncate font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
|
||||
<dt class="admin-label mb-1">Nguồn</dt>
|
||||
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Slug</dt>
|
||||
<dd class="truncate text-white" :title="movie.slug">{{ movie.slug }}</dd>
|
||||
<dt class="admin-label mb-1">Slug</dt>
|
||||
<dd class="truncate text-zinc-200" :title="movie.slug">{{ movie.slug }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Năm phát hành</dt>
|
||||
<dd class="text-white">{{ movie.year || '—' }}</dd>
|
||||
<dt class="admin-label mb-1">Năm phát hành</dt>
|
||||
<dd class="text-zinc-200">{{ movie.year || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Số tập</dt>
|
||||
<dd class="text-white">{{ movie.episode || '—' }}</dd>
|
||||
<dt class="admin-label mb-1">Số tập</dt>
|
||||
<dd class="text-zinc-200">{{ movie.episode || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Chất lượng</dt>
|
||||
<dd class="text-white">{{ movie.quality || '—' }}</dd>
|
||||
<dt class="admin-label mb-1">Chất lượng</dt>
|
||||
<dd class="text-zinc-200">{{ movie.quality || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Trạng thái</dt>
|
||||
<dd :class="movie.active ? 'font-semibold text-green-400' : 'text-slate-400'">
|
||||
<dt class="admin-label mb-1">Trạng thái</dt>
|
||||
<dd class="font-semibold" :class="movie.active ? 'text-emerald-400' : 'text-zinc-400'">
|
||||
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-slate-500">Đồng bộ gần nhất</dt>
|
||||
<dd class="text-white">{{ new Date(movie.syncedAt).toLocaleDateString('vi-VN') }}</dd>
|
||||
<dt class="admin-label mb-1">Đồng bộ gần nhất</dt>
|
||||
<dd class="text-zinc-200">{{ new Date(movie.syncedAt).toLocaleString('vi-VN') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="movie.categories?.length" class="mt-4 border-t border-white/10 pt-3">
|
||||
<h4 class="mb-1.5 text-[11px] font-black uppercase text-slate-400">Thể loại</h4>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div v-if="movie.categories?.length" class="mt-4 border-t border-white/[0.06] pt-3">
|
||||
<h4 class="admin-label mb-2">Thể loại</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<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">
|
||||
{{ cat }}
|
||||
</span>
|
||||
class="rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium text-zinc-300">{{ cat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ảnh 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="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 có sẵn -->
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||
<!-- Section: Mô tả -->
|
||||
<div v-if="activeSection === 'content'" 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="grid size-9 place-items-center rounded-lg bg-cyan-400/10">
|
||||
<AppIcon name="layers" class="size-4 text-cyan-400" />
|
||||
<div class="admin-icon-square">
|
||||
<AppIcon name="type" class="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-black text-white">Nguồn API có sẵn</h3>
|
||||
<p class="text-xs text-slate-400">Dữ liệu tập phim từ các API</p>
|
||||
<h2 class="text-sm font-bold text-white">Mô tả phim</h2>
|
||||
<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 mô 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 có 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 có ả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 có ả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 có server. Bấm "Thêm server" để bắt đầu.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceData?.sources?.length" class="space-y-3">
|
||||
<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">
|
||||
<label class="text-xs font-semibold text-slate-400">Thêm vào server:</label>
|
||||
<select v-model="selectedTargetServer"
|
||||
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">
|
||||
<option v-for="(server, index) in customServers" :key="index" :value="index"
|
||||
class="bg-slate-900 text-white">
|
||||
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập)
|
||||
</option>
|
||||
</select>
|
||||
<button v-if="!customServers.length" type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/10 px-3 text-xs font-semibold text-white transition hover:bg-white/20"
|
||||
@click="addServer">
|
||||
<AppIcon name="plus" class="size-3.5" />
|
||||
Tạo server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source"
|
||||
class="rounded-lg border border-white/10 bg-white/5">
|
||||
<button type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/5"
|
||||
@click="toggleSource(source.source)">
|
||||
<span class="text-sm font-black text-white">
|
||||
{{ source.source.toUpperCase() }} · {{ source.name }}
|
||||
</span>
|
||||
<AppIcon name="chevron-down"
|
||||
class="size-4 text-slate-400 transition"
|
||||
:class="expandedSources[source.source] ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="expandedSources[source.source]" class="border-t border-white/10 p-4">
|
||||
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-xs font-black uppercase text-slate-400">{{ server.name }}</h4>
|
||||
<button type="button"
|
||||
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"
|
||||
@click="quickAddAllEpisodes(sourceIndex, serverIndex)">
|
||||
<AppIcon name="plus" class="size-3" />
|
||||
Thêm tất cả
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div v-for="(ep, epIndex) in server.episodes" :key="epIndex"
|
||||
class="group relative rounded-md border border-white/10 bg-white/5 px-2.5 py-1.5">
|
||||
<p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}</p>
|
||||
<p v-if="ep.linkM3u8" class="truncate text-[10px] text-slate-500">{{ ep.linkM3u8 }}</p>
|
||||
<p v-else-if="ep.linkEmbed" class="truncate text-[10px] text-slate-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>
|
||||
<!-- API Sources Panel -->
|
||||
<div class="xl:col-span-5">
|
||||
<div class="admin-card p-5 xl:sticky xl:top-5">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="admin-icon-square">
|
||||
<AppIcon name="layers" class="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-white">Nguồn API có sẵn</h3>
|
||||
<p class="text-xs text-zinc-500">Thêm nhanh tập từ API</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sourceData?.sources?.length" class="space-y-3">
|
||||
<div class="flex flex-col gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<label class="admin-label">Thêm vào server</label>
|
||||
<select v-model="selectedTargetServer" class="admin-input-sm w-full">
|
||||
<option v-for="(server, index) in customServers" :key="index" :value="index"
|
||||
class="bg-[#131418] text-white">
|
||||
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập)
|
||||
</option>
|
||||
</select>
|
||||
<button v-if="!customServers.length" type="button"
|
||||
class="admin-btn-sm w-full justify-center bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
|
||||
@click="addServer">
|
||||
<AppIcon name="plus" class="size-3" />
|
||||
Tạo server
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source"
|
||||
class="rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<button type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/[0.03]"
|
||||
@click="toggleSource(source.source)">
|
||||
<span class="text-sm font-bold text-white uppercase">{{ source.source }} · {{ source.name
|
||||
}}</span>
|
||||
<AppIcon name="chevron-down" class="size-4 text-zinc-400 transition"
|
||||
:class="expandedSources[source.source] ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="expandedSources[source.source]" class="border-t border-white/[0.06] p-4">
|
||||
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="admin-label">{{ server.name }}</h4>
|
||||
<button type="button"
|
||||
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20"
|
||||
@click="quickAddAllEpisodes(sourceIndex, serverIndex)">
|
||||
<AppIcon name="plus" class="size-3" />
|
||||
Thêm tất cả
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div v-for="(ep, epIndex) in server.episodes" :key="epIndex"
|
||||
class="group relative rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-1.5">
|
||||
<p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}
|
||||
</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 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 có nguồn API hoặc chưa đồng bộ tập phim.</p>
|
||||
</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 có nguồn API nào hoặc chưa đồng bộ tập phim.</p>
|
||||
</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>
|
||||
|
||||
+112
-152
@@ -22,6 +22,8 @@ const deleteConfirmOpen = ref(false)
|
||||
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 { bust } = useCacheBust()
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
watch(searchInput, (val) => {
|
||||
@@ -72,6 +74,7 @@ async function toggleActive(movie: any) {
|
||||
method: 'PATCH',
|
||||
body: { active: movie.active },
|
||||
})
|
||||
bust()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
@@ -90,6 +93,7 @@ async function handleSync() {
|
||||
body: { sources },
|
||||
})
|
||||
syncResult.value = result
|
||||
bust() // Invalidate cached data across all pages
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err)
|
||||
@@ -103,6 +107,7 @@ async function handleDeleteAll() {
|
||||
try {
|
||||
await $fetch('/api/admin/movies', { method: 'DELETE' })
|
||||
deleteConfirmOpen.value = false
|
||||
bust() // Invalidate cached data across all pages
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
@@ -192,151 +197,143 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-black text-white">Quản lý phim</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Quản lý phim hiển thị trên CineK ({{ data?.total || 0 }} phim)</p>
|
||||
<h1 class="admin-section-title">Quản lý phim</h1>
|
||||
<p class="admin-section-subtitle mt-1">Quản lý phim hiển thị trên CineK ({{ data?.total || 0 }} phim)</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-black text-red-400 transition hover:bg-red-400/20"
|
||||
@click="deleteConfirmOpen = true">
|
||||
<button type="button" class="admin-btn-danger" @click="deleteConfirmOpen = true">
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
Xoá tất cả
|
||||
</button>
|
||||
<button type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
|
||||
@click="syncOpen = true">
|
||||
<button type="button" class="admin-btn-primary" @click="syncOpen = true">
|
||||
<AppIcon name="refresh" class="size-4" />
|
||||
Đồng bộ từ API
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50">
|
||||
<div class="flex flex-col gap-3 border-b border-white/10 p-4 sm:flex-row sm:items-center">
|
||||
<div class="admin-card overflow-hidden">
|
||||
<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">
|
||||
<AppIcon name="search" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..."
|
||||
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50">
|
||||
<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..." class="admin-input pl-10">
|
||||
</div>
|
||||
<select v-model="statusFilter"
|
||||
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-slate-900">Tất cả trạng thái</option>
|
||||
<option value="active" class="bg-slate-900">Đang hiển thị</option>
|
||||
<option value="inactive" class="bg-slate-900">Chưa hiển thị</option>
|
||||
<select v-model="statusFilter" class="admin-input w-full sm:w-auto">
|
||||
<option value="" class="bg-[#131418]">Tất cả trạng thái</option>
|
||||
<option value="active" class="bg-[#131418]">Đang hiển thị</option>
|
||||
<option value="inactive" class="bg-[#131418]">Chưa hiển thị</option>
|
||||
</select>
|
||||
<select v-model="sourceFilter"
|
||||
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-slate-900">Tất cả nguồn</option>
|
||||
<option value="ophim" class="bg-slate-900">OPhim</option>
|
||||
<option value="nguonc" class="bg-slate-900">NguonC</option>
|
||||
<option value="kkphim" class="bg-slate-900">KKPhim</option>
|
||||
<select v-model="sourceFilter" class="admin-input w-full sm:w-auto">
|
||||
<option value="" class="bg-[#131418]">Tất cả nguồn</option>
|
||||
<option value="ophim" class="bg-[#131418]">OPhim</option>
|
||||
<option value="nguonc" class="bg-[#131418]">NguonC</option>
|
||||
<option value="kkphim" class="bg-[#131418]">KKPhim</option>
|
||||
</select>
|
||||
<select v-model="typeFilter"
|
||||
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-slate-900">Tất cả loại</option>
|
||||
<option value="series" class="bg-slate-900">Phim bộ</option>
|
||||
<option value="single" class="bg-slate-900">Phim lẻ</option>
|
||||
<select v-model="typeFilter" class="admin-input w-full sm:w-auto">
|
||||
<option value="" class="bg-[#131418]">Tất cả loại</option>
|
||||
<option value="series" class="bg-[#131418]">Phim bộ</option>
|
||||
<option value="single" class="bg-[#131418]">Phim lẻ</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto admin-scrollbar">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
|
||||
<th class="w-12 px-4 py-3 text-center">STT</th>
|
||||
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('name')">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<tr class="border-b border-white/[0.06] text-left text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||
<th class="w-14 px-5 py-3">STT</th>
|
||||
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('name')">
|
||||
<div class="flex items-center gap-1">
|
||||
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>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center">Nguồn trùng</th>
|
||||
<th class="px-4 py-3 text-center">Tập</th>
|
||||
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('views')">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<th class="px-5 py-3">Nguồn</th>
|
||||
<th class="px-5 py-3">Tập</th>
|
||||
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('views')">
|
||||
<div class="flex items-center gap-1">
|
||||
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>
|
||||
</th>
|
||||
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('apiUpdatedAt')">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('apiUpdatedAt')">
|
||||
<div class="flex items-center gap-1">
|
||||
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>
|
||||
</th>
|
||||
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('active')">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('active')">
|
||||
<div class="flex items-center gap-1">
|
||||
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>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center">Chỉnh sửa</th>
|
||||
<th class="px-4 py-3 text-center">Thao tác</th>
|
||||
<th class="px-5 py-3">Tuỳ chỉnh</th>
|
||||
<th class="px-5 py-3 text-right">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<tr v-for="movie in movies" :key="movie.id" class="transition hover:bg-white/5">
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-400">
|
||||
<tbody class="divide-y divide-white/[0.04]">
|
||||
<tr v-for="movie in movies" :key="movie.id" class="transition hover:bg-white/[0.02]">
|
||||
<td class="px-5 py-3.5 text-sm font-semibold text-slate-500">
|
||||
{{ (currentPage - 1) * 20 + movies.indexOf(movie) + 1 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<td class="px-5 py-3.5">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="rounded-full bg-white/10 px-2.5 py-1 text-xs font-semibold text-white">
|
||||
{{ (movie.sources || []).length }} nguồn
|
||||
</span>
|
||||
<td class="px-5 py-3.5">
|
||||
<span class="admin-badge bg-blue-400/10 text-blue-400">{{ (movie.sources || []).length }} nguồn</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
<span
|
||||
class="font-semibold text-yellow-300"
|
||||
<td class="px-5 py-3.5 text-sm">
|
||||
<span class="font-bold text-yellow-300"
|
||||
:title="(movie.sources || []).map((s: any) => `${s.source.toUpperCase()}: ${s.episode || s.episodeTotal || '?'}`).join('\n')">
|
||||
{{ formatEpisode(movie) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
{{ (movie.views || 0).toLocaleString() }}
|
||||
</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>
|
||||
<td class="px-5 py-3.5 text-sm text-slate-300">
|
||||
<span :title="formatDate(movie.apiUpdatedAt || movie.syncedAt)">
|
||||
{{ formatRelativeTime(movie.apiUpdatedAt || movie.syncedAt) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
:class="movie.active ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'">
|
||||
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
|
||||
<td class="px-5 py-3.5">
|
||||
<span class="admin-badge" :class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-slate-400/10 text-slate-400'">
|
||||
{{ movie.active ? 'Hiển thị' : 'Ẩn' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
<span v-if="movie.customContent"
|
||||
class="rounded bg-blue-400/10 px-2 py-0.5 text-[10px] font-semibold text-blue-400">Mô tả</span>
|
||||
<span v-if="movie.customPoster"
|
||||
class="rounded bg-purple-400/10 px-2 py-0.5 text-[10px] font-semibold text-purple-400">Poster</span>
|
||||
<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">
|
||||
<td class="px-5 py-3.5">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span v-if="movie.customContent" class="admin-badge bg-blue-400/10 text-blue-400">Mô tả</span>
|
||||
<span v-if="movie.customPoster" class="admin-badge bg-purple-400/10 text-purple-400">Poster</span>
|
||||
<span v-if="movie.customThumb" class="admin-badge bg-pink-400/10 text-pink-400">Thumb</span>
|
||||
<span v-if="movie.customServers?.length" class="admin-badge bg-emerald-400/10 text-emerald-400">
|
||||
{{ movie.customServers.length }} server · {{ movie.customServers.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0) }} tập
|
||||
</span>
|
||||
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length"
|
||||
class="text-xs text-slate-500">Mặc định</span>
|
||||
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length" class="text-xs text-slate-500">Mặc định</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<td class="px-5 py-3.5">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<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">
|
||||
<AppIcon name="pencil" class="size-4" />
|
||||
</NuxtLink>
|
||||
@@ -346,7 +343,7 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
</td>
|
||||
</tr>
|
||||
<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.' }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -354,32 +351,29 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
</table>
|
||||
</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>
|
||||
<div class="flex items-center gap-2">
|
||||
<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--">
|
||||
‹
|
||||
<AppIcon name="chevron-left" class="size-4" />
|
||||
</button>
|
||||
<template v-for="(page, index) in visiblePages" :key="index">
|
||||
<span v-if="page === 'ellipsis'"
|
||||
class="grid size-8 place-items-center text-sm text-slate-400">
|
||||
...
|
||||
</span>
|
||||
<span v-if="page === 'ellipsis'" class="grid size-9 place-items-center text-sm text-slate-500">...</span>
|
||||
<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
|
||||
? '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">
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
<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++">
|
||||
›
|
||||
<AppIcon name="chevron-right" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,50 +383,34 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
<Transition name="modal-fade">
|
||||
<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="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"
|
||||
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">
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
|
||||
<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 class="mb-5">
|
||||
<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">
|
||||
<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 class="space-y-3">
|
||||
<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.1] hover:bg-white/[0.05]">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white">OPhim</p>
|
||||
<p class="text-xs text-slate-400">ophim1.com</p>
|
||||
<p class="text-sm font-bold text-white">{{ source.label }}</p>
|
||||
<p class="text-xs text-slate-400">{{ source.domain }}</p>
|
||||
</div>
|
||||
<AdminToggle v-model="syncSources.ophim" />
|
||||
</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" />
|
||||
<AdminToggle v-model="syncSources[source.key]" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="syncResult" class="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||
<p class="text-sm font-semibold text-white">Kết quả đồng bộ</p>
|
||||
<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-bold text-white">Kết quả đồng bộ</p>
|
||||
<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ạ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>
|
||||
<div v-if="syncResult.sourceStats" class="mt-2">
|
||||
<p class="font-semibold text-white">Theo nguồn:</p>
|
||||
@@ -447,13 +425,8 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<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"
|
||||
@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"
|
||||
<button type="button" class="admin-btn-secondary" @click="syncOpen = false">Huỷ</button>
|
||||
<button type="button" class="admin-btn-primary"
|
||||
:disabled="syncing || !Object.values(syncSources).some(Boolean)" @click="handleSync">
|
||||
<AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
|
||||
{{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }}
|
||||
@@ -466,19 +439,18 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
<Transition name="modal-fade">
|
||||
<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="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>
|
||||
<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.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" class="admin-btn-secondary" @click="deleteConfirmOpen = false">Huỷ</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"
|
||||
@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"
|
||||
class="admin-btn bg-red-500 text-white hover:bg-red-400 disabled:opacity-50"
|
||||
:disabled="deleting" @click="handleDeleteAll">
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
{{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }}
|
||||
@@ -490,15 +462,3 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
</Teleport>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -59,75 +59,73 @@ const members = computed(() => data.value?.items || [])
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-black text-white">Quản lý thành viên</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Quản lý tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
|
||||
<h1 class="admin-section-title">Quản lý thành viên</h1>
|
||||
<p class="admin-section-subtitle mt-1">Quản lý tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/50">
|
||||
<div class="flex items-center gap-3 border-b border-white/10 p-4">
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="flex items-center gap-3 border-b border-white/[0.06] p-4">
|
||||
<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..."
|
||||
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 class="overflow-x-auto">
|
||||
<div class="overflow-x-auto admin-scrollbar">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
|
||||
<th class="w-12 px-4 py-3 text-center">STT</th>
|
||||
<th class="px-4 py-3 text-center">Thành viên</th>
|
||||
<th class="px-4 py-3 text-center">Vai trò</th>
|
||||
<th class="px-4 py-3 text-center">Trạng thái</th>
|
||||
<th class="px-4 py-3 text-center">Tham gia</th>
|
||||
<th class="px-4 py-3 text-center">Thao tác</th>
|
||||
<tr class="border-b border-white/[0.06] text-left text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||
<th class="w-14 px-5 py-3">STT</th>
|
||||
<th class="px-5 py-3">Thành viên</th>
|
||||
<th class="px-5 py-3">Vai trò</th>
|
||||
<th class="px-5 py-3">Trạng thái</th>
|
||||
<th class="px-5 py-3">Tham gia</th>
|
||||
<th class="px-5 py-3 text-right">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/5">
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-400">{{ members.indexOf(member) + 1 }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-white">{{ member.name || '—' }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
||||
<tbody class="divide-y divide-white/[0.04]">
|
||||
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/[0.02]">
|
||||
<td class="px-5 py-3.5 text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}</td>
|
||||
<td class="px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<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">
|
||||
{{ (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>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<td class="px-5 py-3.5">
|
||||
<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="{
|
||||
'text-red-400': member.role === 'admin',
|
||||
'text-blue-400': member.role === 'moderator',
|
||||
'text-slate-400': member.role === 'user',
|
||||
}"
|
||||
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"
|
||||
@change="changeRole(member, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="user" class="bg-slate-900">User</option>
|
||||
<option value="moderator" class="bg-slate-900">Moderator</option>
|
||||
<option value="admin" class="bg-slate-900">Admin</option>
|
||||
<option value="user" class="bg-[#131418]">User</option>
|
||||
<option value="moderator" class="bg-[#131418]">Moderator</option>
|
||||
<option value="admin" class="bg-[#131418]">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<td class="px-5 py-3.5">
|
||||
<AdminToggle :model-value="member.active"
|
||||
@update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" />
|
||||
</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') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button type="button"
|
||||
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
|
||||
:disabled="currentUser?.id === member.id"
|
||||
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
|
||||
@click="deleteMember(member)">
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<td class="px-5 py-3.5 text-right">
|
||||
<button type="button"
|
||||
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10"
|
||||
:disabled="currentUser?.id === member.id"
|
||||
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
|
||||
@click="deleteMember(member)">
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<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.' }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -135,7 +133,7 @@ const members = computed(() => data.value?.items || [])
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ const route = useRoute()
|
||||
const selectedServer = ref(0)
|
||||
const movieInfoOpen = ref(false)
|
||||
const contentExpanded = ref(false)
|
||||
const activeTab = ref<'episodes' | 'actors'>('episodes')
|
||||
const activeTab = ref<'episodes' | 'actors' | 'suggestions'>('episodes')
|
||||
const isFavoriteMovie = ref(false)
|
||||
const actionMessage = ref('')
|
||||
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 actors = computed(() => movie.value?.actors ?? [])
|
||||
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 total = servers.value.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0)
|
||||
return total || undefined
|
||||
@@ -405,6 +416,14 @@ useHead(() => ({
|
||||
<span v-if="activeTab === 'actors'"
|
||||
class="absolute bottom-0 left-0 w-full h-0.5 bg-[#F5C518] rounded-t-md" />
|
||||
</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>
|
||||
|
||||
<!-- Episodes Tab -->
|
||||
@@ -417,11 +436,11 @@ useHead(() => ({
|
||||
</div>
|
||||
<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="selectedServer === index
|
||||
? 'border-white/30 text-white bg-white/5'
|
||||
: 'border-transparent text-white/60 hover:text-white'" @click="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'"
|
||||
@click="selectedServer = index">
|
||||
<AppIcon name="captions" class="size-3.5 opacity-60" />
|
||||
<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'">
|
||||
{{ server.episodes?.length || 0 }}
|
||||
</span>
|
||||
@@ -469,6 +488,19 @@ useHead(() => ({
|
||||
Chưa có thông tin diễn viên.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions Tab -->
|
||||
<div v-if="activeTab === 'suggestions'">
|
||||
<h3 class="text-xl md:text-2xl font-bold text-text-primary mb-6">Có 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 có phim đề xuất phù hợp.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment Section -->
|
||||
|
||||
Reference in New Issue
Block a user