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