refactor: update border colors and improve styling in thanh-vien.vue

- Changed border color classes for better visibility and consistency.
- Adjusted hover effects for table rows to enhance user experience.
- Modified gradient class for member initials for improved aesthetics.
- Updated select input styles for better alignment with design guidelines.
This commit is contained in:
ngthanhvu
2026-08-05 05:46:40 -04:00
parent a36e6c77f8
commit 262836b75e
6 changed files with 360 additions and 476 deletions
+4 -4
View File
@@ -95,7 +95,7 @@ select:disabled {
} }
.admin-input { .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; @apply h-11 w-full rounded-xl border border-white/8 bg-white/3 px-4 text-sm text-white placeholder:text-zinc-500 outline-none transition focus:border-yellow-400/40 focus:bg-white/5 focus:ring-2 focus:ring-yellow-400/15;
} }
.admin-btn { .admin-btn {
@@ -107,7 +107,7 @@ select:disabled {
} }
.admin-btn-secondary { .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]; @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/8 bg-white/4 text-zinc-100 hover:bg-white/8;
} }
.admin-btn-danger { .admin-btn-danger {
@@ -136,7 +136,7 @@ select:disabled {
/* Compact controls for dense edit rows */ /* Compact controls for dense edit rows */
.admin-input-sm { .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]; @apply h-8 rounded-lg border border-white/8 bg-white/3 px-2.5 text-xs text-white placeholder:text-zinc-500 outline-none transition focus:border-yellow-400/40 focus:bg-white/5;
} }
.admin-btn-sm { .admin-btn-sm {
@@ -144,7 +144,7 @@ select:disabled {
} }
.admin-icon-square { .admin-icon-square {
@apply grid size-9 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-zinc-300; @apply grid size-9 place-items-center rounded-xl border border-white/8 bg-white/3 text-zinc-300;
} }
} }
+3 -3
View File
@@ -42,7 +42,7 @@ const allowRegistration = ref(true)
<div> <div>
<label class="mb-2 block text-sm font-semibold text-white"> tả website</label> <label class="mb-2 block text-sm font-semibold text-white"> tả website</label>
<textarea v-model="siteDescription" rows="3" <textarea v-model="siteDescription" rows="3"
class="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-2.5 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-yellow-400/50 focus:bg-white/[0.05] focus:ring-2 focus:ring-yellow-400/20"></textarea> class="w-full rounded-xl border border-white/8 bg-white/3 px-4 py-2.5 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-yellow-400/50 focus:bg-white/5 focus:ring-2 focus:ring-yellow-400/20"></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -51,7 +51,7 @@ const allowRegistration = ref(true)
<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-3"> <div class="space-y-3">
<label class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.1] hover:bg-white/[0.05]"> <label class="flex cursor-pointer items-center justify-between rounded-xl border border-white/6 bg-white/3 p-4 transition hover:border-white/10 hover:bg-white/5">
<div> <div>
<p class="text-sm font-bold 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>
@@ -59,7 +59,7 @@ const allowRegistration = ref(true)
<AdminToggle v-model="maintenanceMode" /> <AdminToggle v-model="maintenanceMode" />
</label> </label>
<label class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.1] hover:bg-white/[0.05]"> <label class="flex cursor-pointer items-center justify-between rounded-xl border border-white/6 bg-white/3 p-4 transition hover:border-white/10 hover:bg-white/5">
<div> <div>
<p class="text-sm font-bold text-white">Cho phép đăng </p> <p class="text-sm font-bold text-white">Cho phép đăng </p>
<p class="mt-1 text-xs text-slate-400">Cho phép người dùng mới đăng tài khoản</p> <p class="mt-1 text-xs text-slate-400">Cho phép người dùng mới đăng tài khoản</p>
+17 -17
View File
@@ -112,7 +112,7 @@ const syncSourceOptions = [
<p class="admin-label">Tổng số phim</p> <p class="admin-label">Tổng số phim</p>
<p class="admin-num mt-3 text-5xl leading-none sm:text-6xl">{{ stats?.total?.toLocaleString() || 0 }}</p> <p class="admin-num mt-3 text-5xl leading-none sm:text-6xl">{{ stats?.total?.toLocaleString() || 0 }}</p>
</div> </div>
<div class="grid size-10 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-yellow-400"> <div class="grid size-10 place-items-center rounded-xl border border-white/8 bg-white/3 text-yellow-400">
<AppIcon name="film" class="size-5" /> <AppIcon name="film" class="size-5" />
</div> </div>
</div> </div>
@@ -123,13 +123,13 @@ const syncSourceOptions = [
<p class="admin-label">Phân bổ theo nguồn</p> <p class="admin-label">Phân bổ theo nguồn</p>
<span class="text-xs text-zinc-500">{{ sourceSegments.length }} nguồn</span> <span class="text-xs text-zinc-500">{{ sourceSegments.length }} nguồn</span>
</div> </div>
<div v-if="sourceSegments.length" class="flex h-3 w-full overflow-hidden rounded-full bg-white/[0.04]"> <div v-if="sourceSegments.length" class="flex h-3 w-full overflow-hidden rounded-full bg-white/4">
<div v-for="seg in sourceSegments" :key="seg.key" <div v-for="seg in sourceSegments" :key="seg.key"
class="h-full transition-all duration-700" class="h-full transition-all duration-700"
:style="{ width: seg.pct + '%', backgroundColor: seg.color }" :style="{ width: seg.pct + '%', backgroundColor: seg.color }"
:title="`${seg.label}: ${seg.value} (${Math.round(seg.pct)}%)`" /> :title="`${seg.label}: ${seg.value} (${Math.round(seg.pct)}%)`" />
</div> </div>
<div v-else class="h-3 w-full rounded-full bg-white/[0.04]" /> <div v-else class="h-3 w-full rounded-full bg-white/4" />
<div class="mt-3 flex flex-wrap gap-x-5 gap-y-1.5"> <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"> <div v-for="seg in sourceSegments" :key="seg.key" class="flex items-center gap-2">
@@ -141,7 +141,7 @@ const syncSourceOptions = [
</div> </div>
<!-- Footer mini stats --> <!-- Footer mini stats -->
<div class="mt-auto grid grid-cols-2 gap-4 border-t border-white/[0.06] pt-5"> <div class="mt-auto grid grid-cols-2 gap-4 border-t border-white/6 pt-5">
<div> <div>
<p class="admin-label">Đang hiển thị</p> <p class="admin-label">Đang hiển thị</p>
<p class="mt-1.5 flex items-baseline gap-2"> <p class="mt-1.5 flex items-baseline gap-2">
@@ -215,7 +215,7 @@ const syncSourceOptions = [
<div class="admin-card p-5"> <div class="admin-card p-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="admin-label">Phân bổ nguồn</p> <p class="admin-label">Phân bổ nguồn</p>
<span class="admin-badge bg-white/[0.05] text-zinc-400">Nguồn</span> <span class="admin-badge bg-white/5 text-zinc-400">Nguồn</span>
</div> </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">
@@ -258,7 +258,7 @@ const syncSourceOptions = [
<div class="admin-card p-5"> <div class="admin-card p-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="admin-label">Bộ vs Lẻ</p> <p class="admin-label">Bộ vs Lẻ</p>
<span class="admin-badge bg-white/[0.05] text-zinc-400">Loại</span> <span class="admin-badge bg-white/5 text-zinc-400">Loại</span>
</div> </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">
@@ -307,15 +307,15 @@ const syncSourceOptions = [
<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.5 flex items-center justify-between gap-3"> <div class="mb-1.5 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2.5"> <div class="flex min-w-0 items-center gap-2.5">
<span class="flex size-5 flex-shrink-0 items-center justify-center rounded-md text-[10px] font-bold" <span class="flex size-5 shrink-0 items-center justify-center rounded-md text-[10px] font-bold"
:class="i === 0 ? 'bg-yellow-400 text-zinc-950' : 'bg-white/[0.06] text-zinc-400'"> :class="i === 0 ? 'bg-yellow-400 text-zinc-950' : 'bg-white/6 text-zinc-400'">
{{ i + 1 }} {{ i + 1 }}
</span> </span>
<span class="truncate text-sm text-zinc-100">{{ movie.name }}</span> <span class="truncate text-sm text-zinc-100">{{ movie.name }}</span>
</div> </div>
<span class="flex-shrink-0 text-xs font-semibold text-zinc-400 tabular-nums">{{ movie.views?.toLocaleString() }}</span> <span class="shrink-0 text-xs font-semibold text-zinc-400 tabular-nums">{{ movie.views?.toLocaleString() }}</span>
</div> </div>
<div class="h-1.5 overflow-hidden rounded-full bg-white/[0.04]"> <div class="h-1.5 overflow-hidden rounded-full bg-white/4">
<div class="h-full rounded-full transition-all duration-700" <div class="h-full rounded-full transition-all duration-700"
:class="i === 0 ? 'bg-yellow-400' : 'bg-zinc-500'" :class="i === 0 ? 'bg-yellow-400' : 'bg-zinc-500'"
:style="{ width: barWidth(movie.views || 0) + '%' }" /> :style="{ width: barWidth(movie.views || 0) + '%' }" />
@@ -330,7 +330,7 @@ const syncSourceOptions = [
<div class="admin-card p-6"> <div class="admin-card p-6">
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<p class="admin-label">Trạng thái hiển thị</p> <p class="admin-label">Trạng thái hiển thị</p>
<span class="admin-badge bg-white/[0.05] text-zinc-400">{{ stats?.total?.toLocaleString() || 0 }} phim</span> <span class="admin-badge bg-white/5 text-zinc-400">{{ stats?.total?.toLocaleString() || 0 }} phim</span>
</div> </div>
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div> <div>
@@ -338,8 +338,8 @@ const syncSourceOptions = [
<span class="text-zinc-400">Đang hiển thị</span> <span class="text-zinc-400">Đang hiển thị</span>
<span class="font-semibold text-emerald-400 tabular-nums">{{ stats?.active?.toLocaleString() || 0 }} · {{ percentOf(stats?.active || 0, stats?.total || 0) }}%</span> <span class="font-semibold text-emerald-400 tabular-nums">{{ stats?.active?.toLocaleString() || 0 }} · {{ percentOf(stats?.active || 0, stats?.total || 0) }}%</span>
</div> </div>
<div class="h-2.5 overflow-hidden rounded-full bg-white/[0.04]"> <div class="h-2.5 overflow-hidden rounded-full bg-white/4">
<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 class="h-full rounded-full bg-linear-to-r from-emerald-500 to-emerald-400 transition-all duration-700" :style="{ width: percentOf(stats?.active || 0, stats?.total || 0) + '%' }" />
</div> </div>
</div> </div>
<div> <div>
@@ -347,7 +347,7 @@ const syncSourceOptions = [
<span class="text-zinc-400">Chưa hiển thị</span> <span class="text-zinc-400">Chưa hiển thị</span>
<span class="font-semibold text-zinc-400 tabular-nums">{{ stats?.inactive?.toLocaleString() || 0 }} · {{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</span> <span class="font-semibold text-zinc-400 tabular-nums">{{ stats?.inactive?.toLocaleString() || 0 }} · {{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</span>
</div> </div>
<div class="h-2.5 overflow-hidden rounded-full bg-white/[0.04]"> <div class="h-2.5 overflow-hidden rounded-full bg-white/4">
<div class="h-full rounded-full bg-zinc-600 transition-all duration-700" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" /> <div class="h-full rounded-full bg-zinc-600 transition-all duration-700" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" />
</div> </div>
</div> </div>
@@ -359,9 +359,9 @@ const syncSourceOptions = [
<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-2xl border border-white/[0.08] bg-[#131418] p-6 shadow-2xl"> <div class="relative w-full max-w-md rounded-2xl border border-white/8 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-zinc-400 transition hover:bg-white/[0.05] hover:text-white" class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-zinc-400 transition hover:bg-white/5 hover:text-white"
@click="syncOpen = false"> @click="syncOpen = false">
<AppIcon name="x" class="size-5" /> <AppIcon name="x" class="size-5" />
</button> </button>
@@ -373,7 +373,7 @@ const syncSourceOptions = [
<div class="space-y-3"> <div class="space-y-3">
<label v-for="source in syncSourceOptions" :key="source.key" <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]"> class="flex cursor-pointer items-center justify-between rounded-xl border border-white/6 bg-white/3 p-4 transition hover:border-white/12 hover:bg-white/5">
<div> <div>
<p class="text-sm font-semibold text-white">{{ source.label }}</p> <p class="text-sm font-semibold text-white">{{ source.label }}</p>
<p class="text-xs text-zinc-500">{{ source.domain }}</p> <p class="text-xs text-zinc-500">{{ source.domain }}</p>
+9 -11
View File
@@ -46,14 +46,16 @@ async function handleLogin() {
<template> <template>
<div class="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#0A0B0E] p-4"> <div class="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#0A0B0E] p-4">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(250,204,21,0.08),transparent_40%),radial-gradient(circle_at_bottom_left,rgba(59,130,246,0.05),transparent_40%)]" /> <div
class="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="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-3xl border border-white/6 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="rounded-[22px] bg-linear-to-b from-white/8 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-3"> <div class="mb-4 inline-flex items-center gap-3">
<div class="grid size-12 place-items-center rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-500 shadow-lg shadow-yellow-400/20"> <div
class="grid size-12 place-items-center rounded-2xl bg-linear-to-br from-yellow-400 to-amber-500 shadow-lg shadow-yellow-400/20">
<span class="text-xl font-black text-slate-950">C</span> <span class="text-xl font-black text-slate-950">C</span>
</div> </div>
<div class="text-left"> <div class="text-left">
@@ -70,8 +72,7 @@ async function handleLogin() {
<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.5 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="admin-input pl-10">
class="admin-input pl-10">
</div> </div>
</div> </div>
@@ -80,8 +81,7 @@ async function handleLogin() {
<div class="relative"> <div class="relative">
<AppIcon name="lock" class="absolute left-3.5 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="admin-input pl-10 pr-10">
class="admin-input pl-10 pr-10">
<button type="button" <button type="button"
class="absolute right-3.5 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">
@@ -92,9 +92,7 @@ async function handleLogin() {
</div> </div>
</div> </div>
<button type="submit" <button type="submit" class="admin-btn-primary mt-6 w-full" :disabled="loading">
class="admin-btn-primary mt-6 w-full"
: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>
+320 -434
View File
@@ -56,7 +56,7 @@ 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 activeSection = ref<"info" | "content" | "actors" | "images" | "episodes">("episodes")
const sections = [ const sections = [
{ key: "info" as const, label: "Thông tin", icon: "info" as const }, { key: "info" as const, label: "Thông tin", icon: "info" as const },
{ key: "content" as const, label: "Mô tả", icon: "type" as const }, { key: "content" as const, label: "Mô tả", icon: "type" as const },
@@ -132,17 +132,17 @@ function removeServer(serverIndex: number) {
} }
function addServerEpisode(serverIndex: number) { function addServerEpisode(serverIndex: number) {
customServers.value[serverIndex].episodes.push({ name: '', linkEmbed: '', linkM3u8: '' }) customServers.value[serverIndex]?.episodes?.push({ name: '', linkEmbed: '', linkM3u8: '' })
} }
function removeServerEpisode(serverIndex: number, episodeIndex: number) { function removeServerEpisode(serverIndex: number, episodeIndex: number) {
customServers.value[serverIndex].episodes.splice(episodeIndex, 1) customServers.value[serverIndex]?.episodes?.splice(episodeIndex, 1)
} }
function applySourceLink(serverIndex: number, episodeIndex: number, key: string) { function applySourceLink(serverIndex: number, episodeIndex: number, key: string) {
const item = availableEpisodes.value.find((ep) => ep.key === key) const item = availableEpisodes.value.find((ep) => ep.key === key)
if (!item) return if (!item) return
const ep = customServers.value[serverIndex].episodes[episodeIndex] const ep = customServers.value[serverIndex]?.episodes?.[episodeIndex]
if (ep) { if (ep) {
ep.linkEmbed = item.linkEmbed || '' ep.linkEmbed = item.linkEmbed || ''
ep.linkM3u8 = item.linkM3u8 || '' ep.linkM3u8 = item.linkM3u8 || ''
@@ -200,7 +200,7 @@ function quickAddEpisode(sourceIndex: number, serverIndex: number, episodeIndex:
if (!ep) return if (!ep) return
ensureTargetServer() ensureTargetServer()
const target = customServers.value[selectedTargetServer.value] const target = customServers.value[selectedTargetServer.value]
target.episodes.push({ target?.episodes?.push({
name: ep.name || '', name: ep.name || '',
linkEmbed: ep.linkEmbed || '', linkEmbed: ep.linkEmbed || '',
linkM3u8: ep.linkM3u8 || '', linkM3u8: ep.linkM3u8 || '',
@@ -215,7 +215,7 @@ function quickAddAllEpisodes(sourceIndex: number, serverIndex: number) {
ensureTargetServer() ensureTargetServer()
const target = customServers.value[selectedTargetServer.value] const target = customServers.value[selectedTargetServer.value]
server.episodes.forEach((ep) => { server.episodes.forEach((ep) => {
target.episodes.push({ target?.episodes?.push({
name: ep.name || '', name: ep.name || '',
linkEmbed: ep.linkEmbed || '', linkEmbed: ep.linkEmbed || '',
linkM3u8: ep.linkM3u8 || '', linkM3u8: ep.linkM3u8 || '',
@@ -292,8 +292,8 @@ function useApiActors() {
<div class="flex flex-col gap-4 sm:flex-row sm:items-start"> <div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="shrink-0"> <div class="shrink-0">
<div <div
class="aspect-[16/9] w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] sm:w-44"> class="aspect-video w-full overflow-hidden rounded-xl border border-white/6 bg-white/2 sm:w-44">
<img v-if="customPoster || movie.poster" :src="customPoster || movie.poster" :alt="movie.name" <img v-if="customPoster || movie.poster" :src="customPoster || movie.poster || undefined" :alt="movie.name"
class="h-full w-full object-cover"> class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600"> <div v-else class="grid h-full place-items-center text-zinc-600">
<AppIcon name="image" class="size-6" /> <AppIcon name="image" class="size-6" />
@@ -310,8 +310,8 @@ function useApiActors() {
:class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-zinc-400/10 text-zinc-400'"> :class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-zinc-400/10 text-zinc-400'">
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }} {{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
</span> </span>
<span v-if="movie.quality" class="admin-badge bg-white/[0.06] text-zinc-400">{{ movie.quality }}</span> <span v-if="movie.quality" class="admin-badge bg-white/6 text-zinc-400">{{ movie.quality }}</span>
<span class="admin-badge bg-white/[0.06] text-zinc-400">{{ movie.episode || '—' }} tập</span> <span class="admin-badge bg-white/6 text-zinc-400">{{ movie.episodeTotal || '—' }} tập</span>
</div> </div>
</div> </div>
</div> </div>
@@ -325,438 +325,324 @@ function useApiActors() {
</div> </div>
</Transition> </Transition>
<!-- Sidebar nav + content --> <!-- WordPress-like editor layout -->
<div class="grid gap-5 lg:grid-cols-12 lg:items-start"> <div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_340px] xl:items-start">
<!-- Content --> <!-- Main editor column -->
<div class="space-y-5 lg:col-span-9"> <div class="min-w-0 space-y-5">
<!-- Section: Thông tin --> <!-- Horizontal tabs -->
<div v-if="activeSection === 'info'" class="admin-card p-5"> <div class="admin-card overflow-hidden">
<div class="mb-4 flex items-center gap-3"> <nav class="flex items-center gap-1 overflow-x-auto border-b border-white/6 px-2 pt-2 no-scrollbar">
<div class="admin-icon-square"> <button v-for="s in sections" :key="s.key" type="button"
<AppIcon name="info" class="size-4" /> class="relative flex shrink-0 items-center gap-2 rounded-t-lg px-4 py-3 text-sm font-semibold transition"
</div> :class="activeSection === s.key
<div> ? 'bg-white/6 text-yellow-400 after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:rounded-full after:bg-yellow-400'
<h2 class="text-sm font-bold text-white">Thông tin chi tiết</h2> : 'text-zinc-400 hover:bg-white/3 hover:text-white'" @click="activeSection = s.key">
<p class="text-xs text-zinc-500">Dữ liệu từ hệ thống</p>
</div>
</div>
<dl class="grid grid-cols-2 gap-x-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: 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"> tả phim</h2>
<p class="text-xs text-zinc-500">{{ customContent ? 'Tuỳ chỉnh · Đang áp dụng' : 'Mặc định từ API' }}
</p>
</div>
</div>
<button v-if="apiContent && !customContent" type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20" @click="useApiContent">
<AppIcon name="pen" class="size-3" />
Dùng tả API
</button>
<button v-else-if="customContent" type="button" class="admin-btn-sm text-red-400 hover:bg-red-400/10"
@click="clearField('customContent')">
Quay lại mặc định
</button>
</div>
<div v-if="!customContent" class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
<span class="admin-badge mb-2 bg-blue-400/10 text-blue-400">Nội dung API</span>
<p class="whitespace-pre-line text-sm leading-7 text-zinc-300">
{{ apiContent || 'Chưa có mô tả từ nguồn API.' }}
</p>
</div>
<div v-else>
<textarea v-model="customContent" :rows="contentRows" placeholder="Nhập mô tả phim..."
style="field-sizing: content; min-height: 140px;"
class="w-full rounded-xl border border-yellow-400/30 bg-white/[0.03] p-3 text-sm leading-7 text-white outline-none transition placeholder:text-zinc-500 focus:border-yellow-400/50 focus:bg-white/[0.05]" />
</div>
</div>
<!-- Section: Diễn viên -->
<div v-if="activeSection === 'actors'" class="admin-card p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="users" class="size-4" />
</div>
<div>
<h2 class="text-sm font-bold text-white">Diễn viên</h2>
<p class="text-xs text-zinc-500">{{ customActors.length }} diễn viên</p>
</div>
</div>
<div class="flex gap-2">
<button v-if="apiActors.length" type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20" @click="useApiActors">
<AppIcon name="pen" class="size-3" />
Import ({{ apiActors.length }})
</button>
<button type="button" class="admin-btn-sm bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
@click="addActor">
<AppIcon name="plus" class="size-3" />
Thêm
</button>
</div>
</div>
<div v-if="customActors.length" class="max-h-96 space-y-2 overflow-y-auto pr-1">
<div v-for="(actor, index) in customActors" :key="index"
class="grid grid-cols-12 items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] p-2">
<div class="col-span-1 flex justify-center">
<img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name"
class="size-7 rounded-full object-cover ring-1 ring-white/10">
<div v-else
class="grid size-7 place-items-center rounded-full bg-white/[0.06] text-xs font-bold text-zinc-400">
{{ (actor.name || '?')[0].toUpperCase() }}
</div>
</div>
<input v-model="actor.name" type="text" placeholder="Tên" class="admin-input-sm col-span-3">
<input v-model="actor.originalName" type="text" placeholder="Tên gốc" class="admin-input-sm col-span-3">
<input v-model="actor.role" type="text" placeholder="Vai" class="admin-input-sm col-span-2">
<input v-model="actor.avatar" type="url" placeholder="Avatar URL" class="admin-input-sm col-span-2">
<button type="button"
class="col-span-1 grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeActor(index)">
<AppIcon name="trash" class="size-4" />
</button>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-white/[0.1] p-6 text-center">
<p class="text-sm text-zinc-500">Chưa diễn viên. Bấm "Thêm" hoặc "Import" để bắt đầu.</p>
</div>
</div>
<!-- Section: Hình ảnh -->
<div v-if="activeSection === 'images'" class="grid gap-5 lg:grid-cols-2">
<div class="admin-card p-5">
<div class="mb-3 flex items-center justify-between gap-2">
<div>
<h3 class="text-sm font-bold text-white">Poster</h3>
<p class="text-xs text-zinc-500">Tỷ lệ 16:9 · ảnh ngang</p>
</div>
<span v-if="customPoster" class="admin-badge bg-yellow-400/10 text-yellow-400">Tuỳ chỉnh</span>
<span v-else-if="movie.poster" class="admin-badge bg-white/[0.06] text-zinc-400">Mặc định</span>
</div>
<input v-model="customPoster" type="url" placeholder="Dán URL poster..." class="admin-input-sm w-full">
<div
class="relative mt-3 aspect-[16/9] w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
<img v-if="customPoster || movie.poster" :src="customPoster || movie.poster" :alt="movie.name"
class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600">
<div class="flex flex-col items-center gap-1.5">
<AppIcon name="image" class="size-8" />
<span class="text-sm font-medium">Chưa ảnh poster</span>
</div>
</div>
<button v-if="customPoster" type="button"
class="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-semibold text-red-300 backdrop-blur-sm transition hover:bg-black/80 hover:text-red-200"
@click="clearField('customPoster')">
<AppIcon name="trash" class="size-3" />
Xoá
</button>
</div>
</div>
<div class="admin-card p-5">
<div class="mb-3 flex items-center justify-between gap-2">
<div>
<h3 class="text-sm font-bold text-white">Thumbnail</h3>
<p class="text-xs text-zinc-500">Tỷ lệ 2:3 · ảnh dọc</p>
</div>
<span v-if="customThumb" class="admin-badge bg-yellow-400/10 text-yellow-400">Tuỳ chỉnh</span>
<span v-else-if="movie.thumb" class="admin-badge bg-white/[0.06] text-zinc-400">Mặc định</span>
</div>
<input v-model="customThumb" type="url" placeholder="Dán URL thumbnail..." class="admin-input-sm w-full">
<div
class="relative mx-auto mt-3 aspect-[2/3] w-full max-w-[260px] overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
<img v-if="customThumb || movie.thumb" :src="customThumb || movie.thumb" :alt="movie.name"
class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600">
<div class="flex flex-col items-center gap-1.5">
<AppIcon name="image" class="size-8" />
<span class="text-sm font-medium">Chưa ảnh</span>
</div>
</div>
<button v-if="customThumb" type="button"
class="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-semibold text-red-300 backdrop-blur-sm transition hover:bg-black/80 hover:text-red-200"
@click="clearField('customThumb')">
<AppIcon name="trash" class="size-3" />
Xoá
</button>
</div>
</div>
</div>
<!-- Section: Tập phim -->
<div v-if="activeSection === 'episodes'" class="grid gap-5 xl:grid-cols-12 xl:items-start">
<!-- Custom Servers -->
<div class="space-y-5 xl:col-span-7">
<div class="admin-card p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="server" class="size-4" />
</div>
<div>
<h3 class="text-sm font-bold text-white">Server & tập phim</h3>
<p class="text-xs text-zinc-500">Danh sách server hiển thị trên website</p>
</div>
</div>
<button type="button" class="admin-btn-sm bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
@click="addServer">
<AppIcon name="plus" class="size-3" />
Thêm server
</button>
</div>
<div v-if="customServers.length" class="space-y-3">
<div v-for="(server, serverIndex) in customServers" :key="serverIndex"
class="rounded-xl border border-white/[0.06] bg-white/[0.02]">
<div class="flex items-center justify-between gap-3 p-3">
<button type="button" class="flex min-w-0 flex-1 items-center gap-2 text-left"
@click="toggleServer(serverIndex)">
<AppIcon name="chevron-down" class="size-4 shrink-0 text-zinc-400 transition"
:class="collapsedServers[serverIndex] ? '-rotate-90' : ''" />
<span class="truncate text-sm font-bold text-white">{{ server.name || `Server ${serverIndex +
1}` }}</span>
<span
class="shrink-0 rounded-full bg-white/[0.08] px-2 py-0.5 text-[10px] font-semibold text-zinc-300">{{
server.episodes.length }} tập</span>
</button>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeServer(serverIndex)">
<AppIcon name="trash" class="size-3.5" />
</button>
</div>
<div v-if="!collapsedServers[serverIndex]" class="border-t border-white/[0.06] p-3">
<input v-model="server.name" type="text" placeholder="Tên server (vd: Vietsub, Thuyết minh...)"
class="admin-input-sm mb-3 w-full font-semibold">
<div v-if="server.episodes.length" class="space-y-1.5">
<div v-for="(ep, episodeIndex) in server.episodes" :key="episodeIndex"
class="rounded-lg border border-white/[0.06] bg-white/[0.02]">
<div class="flex items-center gap-2 p-2">
<span class="w-6 shrink-0 text-center text-xs font-bold text-zinc-500">{{ episodeIndex + 1
}}</span>
<input v-model="ep.name" type="text" placeholder="Tên tập"
class="admin-input-sm w-24 shrink-0">
<select class="admin-input-sm min-w-0 flex-1"
@change="handleSourceLinkChange(serverIndex, episodeIndex, $event)">
<option value="" class="bg-[#131418] text-white">Chọn link từ API</option>
<option v-for="item in availableEpisodes" :key="item.key" :value="item.key"
class="bg-[#131418] text-white">{{ item.label }}</option>
</select>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-zinc-400 transition hover:bg-white/[0.06] hover:text-white"
:class="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'bg-yellow-400/10 text-yellow-400' : ''"
:title="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'Ẩn link' : 'Sửa link'"
@click="toggleLinks(`${serverIndex}-${episodeIndex}`)">
<AppIcon name="pencil" class="size-3" />
</button>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeServerEpisode(serverIndex, episodeIndex)">
<AppIcon name="trash" class="size-3" />
</button>
</div>
<div v-if="expandedLinks[`${serverIndex}-${episodeIndex}`]"
class="border-t border-white/[0.06] p-2">
<div class="grid gap-2 sm:grid-cols-2">
<input v-model="ep.linkEmbed" type="url" placeholder="Link embed (iframe)"
class="admin-input-sm w-full">
<input v-model="ep.linkM3u8" type="url" placeholder="Link HLS (.m3u8)"
class="admin-input-sm w-full">
</div>
</div>
</div>
</div>
<button type="button"
class="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-white/[0.15] px-3 text-xs font-semibold text-zinc-300 transition hover:bg-white/[0.05] hover:text-white"
@click="addServerEpisode(serverIndex)">
<AppIcon name="plus" class="size-3.5" />
Thêm tập
</button>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-white/[0.1] p-6 text-center">
<p class="text-sm text-zinc-500">Chưa server. Bấm "Thêm server" để bắt đầu.</p>
</div>
</div>
</div>
<!-- API Sources Panel -->
<div class="xl:col-span-5">
<div class="admin-card p-5 xl:sticky xl:top-5">
<div class="mb-4 flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="layers" class="size-4" />
</div>
<div>
<h3 class="text-sm font-bold text-white">Nguồn API sẵn</h3>
<p class="text-xs text-zinc-500">Thêm nhanh tập từ API</p>
</div>
</div>
<div v-if="sourceData?.sources?.length" class="space-y-3">
<div class="flex flex-col gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
<label class="admin-label">Thêm vào server</label>
<select v-model="selectedTargetServer" class="admin-input-sm w-full">
<option v-for="(server, index) in customServers" :key="index" :value="index"
class="bg-[#131418] text-white">
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập)
</option>
</select>
<button v-if="!customServers.length" type="button"
class="admin-btn-sm w-full justify-center bg-white/[0.06] text-zinc-100 hover:bg-white/[0.1]"
@click="addServer">
<AppIcon name="plus" class="size-3" />
Tạo server
</button>
</div>
<div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source"
class="rounded-xl border border-white/[0.06] bg-white/[0.02]">
<button type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/[0.03]"
@click="toggleSource(source.source)">
<span class="text-sm font-bold text-white uppercase">{{ source.source }} · {{ source.name
}}</span>
<AppIcon name="chevron-down" class="size-4 text-zinc-400 transition"
:class="expandedSources[source.source] ? 'rotate-180' : ''" />
</button>
<div v-if="expandedSources[source.source]" class="border-t border-white/[0.06] p-4">
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0">
<div class="mb-2 flex items-center justify-between gap-2">
<h4 class="admin-label">{{ server.name }}</h4>
<button type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20"
@click="quickAddAllEpisodes(sourceIndex, serverIndex)">
<AppIcon name="plus" class="size-3" />
Thêm tất cả
</button>
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div v-for="(ep, epIndex) in server.episodes" :key="epIndex"
class="group relative rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-1.5">
<p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}
</p>
<p v-if="ep.linkM3u8" class="truncate text-[10px] text-zinc-500">{{ ep.linkM3u8 }}</p>
<p v-else-if="ep.linkEmbed" class="truncate text-[10px] text-zinc-500">{{ ep.linkEmbed }}
</p>
<button type="button"
class="absolute right-1 top-1 grid size-6 place-items-center rounded bg-yellow-400/10 text-yellow-400 opacity-0 transition hover:bg-yellow-400/20 group-hover:opacity-100"
:title="`Thêm ${ep.name || `Tập ${epIndex + 1}`} vào server`"
@click="quickAddEpisode(sourceIndex, serverIndex, epIndex)">
<AppIcon name="plus" class="size-3" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-white/[0.1] p-6 text-center">
<p class="text-sm text-zinc-500">Chưa nguồn API hoặc chưa đồng bộ tập phim.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Right Sidebar: Navigation + Save -->
<aside class="space-y-5 lg:sticky lg:top-5 lg:col-span-3">
<!-- Nav -->
<div class="admin-card p-2">
<nav class="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible no-scrollbar">
<button v-for="s in sections" :key="s.key"
class="relative flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-semibold transition lg:w-full"
:class="activeSection === s.key ? 'bg-yellow-400/10 text-yellow-400' : 'text-zinc-400 hover:bg-white/[0.03] hover:text-white'"
@click="activeSection = s.key">
<span v-if="activeSection === s.key"
class="absolute left-0 top-1/2 hidden h-5 w-1 -translate-y-1/2 rounded-r-full bg-yellow-400 lg:block" />
<AppIcon :name="s.icon" class="size-4" /> <AppIcon :name="s.icon" class="size-4" />
<span class="flex-1 whitespace-nowrap">{{ s.label }}</span> <span>{{ s.label }}</span>
<span v-if="s.key === 'actors' && customActors.length" <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">{{ class="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-zinc-300">{{ customActors.length
customActors.length }}</span> }}</span>
<span v-else-if="s.key === 'episodes' && customServers.length" <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">{{ class="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-zinc-300">{{customServers.reduce((a,
customServers.length }}</span> b) => a + b.episodes.length, 0)}}</span>
</button> </button>
</nav> </nav>
</div>
<!-- Save button sticky --> <div class="p-5">
<div class="admin-card p-4"> <!-- Section: Thông tin -->
<button type="button" class="w-full admin-btn-primary justify-center" :disabled="saving" <div v-if="activeSection === 'info'">
@click="handleSave"> <div class="mb-5 flex items-center gap-3">
<AppIcon name="save" class="size-4" /> <div class="admin-icon-square">
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }} <AppIcon name="info" class="size-4" />
</button> </div>
<Transition name="fade"> <div>
<div v-if="saved" <h2 class="text-sm font-bold text-white">Thông tin chi tiết</h2>
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"> <p class="text-xs text-zinc-500">Dữ liệu hiện tại của phim</p>
<AppIcon name="check" class="size-3" /> </div>
Đã lưu thành công </div>
<dl class="grid gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-white/6 bg-white/2 p-4">
<dt class="admin-label mb-1">Nguồn</dt>
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
</div>
<div class="rounded-xl border border-white/6 bg-white/2 p-4">
<dt class="admin-label mb-1">Slug</dt>
<dd class="truncate text-zinc-200" :title="movie.slug">{{ movie.slug }}</dd>
</div>
<div class="rounded-xl border border-white/6 bg-white/2 p-4">
<dt class="admin-label mb-1">Năm phát hành</dt>
<dd class="text-zinc-200">{{ movie.year || '—' }}</dd>
</div>
<div class="rounded-xl border border-white/6 bg-white/2 p-4">
<dt class="admin-label mb-1">Số tập</dt>
<dd class="text-zinc-200">{{ movie.episodeTotal || '—' }}</dd>
</div>
<div class="rounded-xl border border-white/6 bg-white/2 p-4">
<dt class="admin-label mb-1">Chất lượng</dt>
<dd class="text-zinc-200">{{ movie.quality || '—' }}</dd>
</div>
<div class="rounded-xl border border-white/6 bg-white/2 p-4">
<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>
</dl>
<div v-if="movie.categories?.length" class="mt-5 border-t border-white/6 pt-4">
<h4 class="admin-label mb-2">Thể loại</h4>
<div class="flex flex-wrap gap-2"><span v-for="cat in movie.categories" :key="cat"
class="rounded-full bg-white/6 px-3 py-1 text-xs font-medium text-zinc-300">{{ cat }}</span>
</div>
</div>
</div> </div>
</Transition>
<!-- Section: tả -->
<div v-if="activeSection === 'content'">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="type" class="size-4" />
</div>
<div>
<h2 class="text-sm font-bold text-white"> tả phim</h2>
<p class="text-xs text-zinc-500">{{ customContent ? 'Nội dung tuỳ chỉnh' : 'Nội dung từ API' }}
</p>
</div>
</div>
<button v-if="apiContent && !customContent" type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20"
@click="useApiContent">Dùng tả API</button>
<button v-else-if="customContent" type="button" class="admin-btn-sm text-red-400 hover:bg-red-400/10"
@click="clearField('customContent')">Về mặc định</button>
</div>
<textarea v-model="customContent" :rows="contentRows" :placeholder="apiContent || 'Nhập mô tả phim...'"
style="field-sizing: content; min-height: 320px;"
class="w-full rounded-xl border border-white/8 bg-white/3 p-4 text-sm leading-7 text-white outline-none transition placeholder:text-zinc-500 focus:border-yellow-400/40 focus:bg-white/5" />
</div>
<!-- Section: Diễn viên -->
<div v-if="activeSection === 'actors'">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="users" class="size-4" />
</div>
<div>
<h2 class="text-sm font-bold text-white">Diễn viên</h2>
<p class="text-xs text-zinc-500">{{ customActors.length }} diễn viên</p>
</div>
</div>
<div class="flex gap-2"><button v-if="apiActors.length" type="button"
class="admin-btn-sm bg-yellow-400/10 text-yellow-400 hover:bg-yellow-400/20"
@click="useApiActors">Import ({{ apiActors.length }})</button><button type="button"
class="admin-btn-sm bg-white/6 text-zinc-100 hover:bg-white/10" @click="addActor">
<AppIcon name="plus" class="size-3" />Thêm
</button></div>
</div>
<div v-if="customActors.length" class="space-y-2">
<div v-for="(actor, index) in customActors" :key="index"
class="grid gap-2 rounded-xl border border-white/6 bg-white/2 p-3 md:grid-cols-[44px_1fr_1fr_140px_1fr_36px] md:items-center">
<div class="grid size-10 place-items-center overflow-hidden rounded-full bg-white/6"><img
v-if="actor.avatar" :src="actor.avatar" :alt="actor.name" class="size-full object-cover"><span
v-else class="text-sm font-bold text-zinc-400">{{ (actor.name?.[0] || '?').toUpperCase() }}</span>
</div>
<input v-model="actor.name" type="text" placeholder="Tên" class="admin-input-sm"><input
v-model="actor.originalName" type="text" placeholder="Tên gốc" class="admin-input-sm"><input
v-model="actor.role" type="text" placeholder="Vai" class="admin-input-sm"><input
v-model="actor.avatar" type="url" placeholder="Avatar URL" class="admin-input-sm"><button
type="button" class="grid size-8 place-items-center rounded-lg text-red-400 hover:bg-red-400/10"
@click="removeActor(index)">
<AppIcon name="trash" class="size-4" />
</button>
</div>
</div>
<div v-else
class="rounded-xl border border-dashed border-white/10 p-10 text-center text-sm text-zinc-500">Chưa
diễn viên.</div>
</div>
<!-- Section: Hình ảnh -->
<div v-if="activeSection === 'images'" class="space-y-5">
<div><label class="admin-label mb-2 block">Ảnh poster ngang (16:9)</label>
<div class="flex gap-2"><input v-model="customPoster" type="url" placeholder="Dán URL poster ngang..."
class="admin-input-sm min-w-0 flex-1"><button v-if="customPoster" type="button"
class="admin-btn-sm text-red-400 hover:bg-red-400/10"
@click="clearField('customPoster')">Xoá</button></div>
</div>
<div><label class="admin-label mb-2 block">Ảnh poster dọc (2:3)</label>
<div class="flex gap-2"><input v-model="customThumb" type="url" placeholder="Dán URL poster dọc..."
class="admin-input-sm min-w-0 flex-1"><button v-if="customThumb" type="button"
class="admin-btn-sm text-red-400 hover:bg-red-400/10"
@click="clearField('customThumb')">Xoá</button></div>
</div>
<p class="rounded-xl border border-blue-400/15 bg-blue-400/5 p-4 text-sm leading-6 text-blue-200">
Preview ảnh được ghim sidebar bên phải để bạn theo dõi trong lúc nhập URL.</p>
</div>
<!-- Section: Tập phim -->
<div v-if="activeSection === 'episodes'" class="grid gap-5 2xl:grid-cols-12 2xl:items-start">
<div class="space-y-3 2xl:col-span-7">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-bold text-white">Server & tập phim</h3>
<p class="text-xs text-zinc-500">Danh sách hiển thị trên website</p>
</div><button type="button" class="admin-btn-sm bg-white/6 text-zinc-100 hover:bg-white/10"
@click="addServer">
<AppIcon name="plus" class="size-3" />Thêm server
</button>
</div>
<div v-for="(server, serverIndex) in customServers" :key="serverIndex"
class="rounded-xl border border-white/6 bg-white/2">
<div class="flex items-center gap-2 p-3"><button type="button"
class="flex min-w-0 flex-1 items-center gap-2 text-left" @click="toggleServer(serverIndex)">
<AppIcon name="chevron-down" class="size-4 text-zinc-400 transition"
:class="collapsedServers[serverIndex] ? '-rotate-90' : ''" /><span
class="truncate text-sm font-bold text-white">{{ server.name || `Server ${serverIndex + 1}`
}}</span><span class="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-zinc-300">{{
server.episodes.length }} tập</span>
</button><button type="button"
class="grid size-8 place-items-center rounded-lg text-red-400 hover:bg-red-400/10"
@click="removeServer(serverIndex)">
<AppIcon name="trash" class="size-4" />
</button></div>
<div v-if="!collapsedServers[serverIndex]" class="space-y-3 border-t border-white/6 p-3"><input
v-model="server.name" type="text" placeholder="Tên server"
class="admin-input-sm w-full font-semibold">
<div v-for="(ep, episodeIndex) in server.episodes" :key="episodeIndex"
class="rounded-lg border border-white/6 bg-black/10 p-2">
<div class="flex gap-2"><input v-model="ep.name" type="text" placeholder="Tên tập"
class="admin-input-sm w-24"><select class="admin-input-sm min-w-0 flex-1"
@change="handleSourceLinkChange(serverIndex, episodeIndex, $event)">
<option value="" class="bg-[#131418]">Chọn link từ API</option>
<option v-for="item in availableEpisodes" :key="item.key" :value="item.key"
class="bg-[#131418]">{{ item.label }}</option>
</select><button type="button"
class="grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-white/6"
@click="toggleLinks(`${serverIndex}-${episodeIndex}`)">
<AppIcon name="pencil" class="size-3" />
</button><button type="button"
class="grid size-8 place-items-center rounded-lg text-red-400 hover:bg-red-400/10"
@click="removeServerEpisode(serverIndex, episodeIndex)">
<AppIcon name="trash" class="size-3" />
</button></div>
<div v-if="expandedLinks[`${serverIndex}-${episodeIndex}`]"
class="mt-2 grid gap-2 sm:grid-cols-2"><input v-model="ep.linkEmbed" type="url"
placeholder="Link embed" class="admin-input-sm"><input v-model="ep.linkM3u8" type="url"
placeholder="Link m3u8" class="admin-input-sm"></div>
</div><button type="button"
class="admin-btn-sm border border-dashed border-white/15 text-zinc-300"
@click="addServerEpisode(serverIndex)">
<AppIcon name="plus" class="size-3" />Thêm tập
</button>
</div>
</div>
<div v-if="!customServers.length"
class="rounded-xl border border-dashed border-white/10 p-8 text-center text-sm text-zinc-500">
Chưa server.</div>
</div>
<div class="2xl:col-span-5">
<div class="rounded-xl border border-white/6 bg-white/2 p-4 2xl:sticky 2xl:top-5">
<div class="mb-3">
<h3 class="text-sm font-bold text-white">Nguồn API</h3>
<p class="text-xs text-zinc-500">Thêm nhanh tập vào server</p>
</div>
<select v-model="selectedTargetServer" class="admin-input-sm mb-3 w-full">
<option v-for="(server, index) in customServers" :key="index" :value="index" class="bg-[#131418]">
{{ server.name || `Server ${index + 1}` }}</option>
</select>
<div class="space-y-2">
<div v-for="(source, sourceIndex) in sourceData?.sources || []" :key="source.source"
class="rounded-lg border border-white/6"><button type="button"
class="flex w-full items-center justify-between p-3 text-left"
@click="toggleSource(source.source)"><span class="text-xs font-bold uppercase text-white">{{
source.source }} · {{ source.name }}</span>
<AppIcon name="chevron-down" class="size-4 text-zinc-400" />
</button>
<div v-if="expandedSources[source.source]" class="space-y-3 border-t border-white/6 p-3">
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex">
<div class="mb-2 flex items-center justify-between"><span class="admin-label">{{ server.name
}}</span><button type="button" class="text-xs font-semibold text-yellow-400"
@click="quickAddAllEpisodes(sourceIndex, serverIndex)">Thêm tất cả</button></div>
<div class="flex flex-wrap gap-2"><button v-for="(ep, epIndex) in server.episodes"
:key="epIndex" type="button"
class="rounded-lg bg-white/5 px-2.5 py-1.5 text-xs text-zinc-200 hover:bg-yellow-400/10 hover:text-yellow-400"
@click="quickAddEpisode(sourceIndex, serverIndex, epIndex)">{{ ep.name || `Tập ${epIndex
+ 1}` }}</button></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- WordPress-like right sidebar -->
<aside class="space-y-4 xl:sticky xl:top-5">
<div class="admin-card overflow-hidden">
<div class="flex items-center justify-between border-b border-white/6 px-4 py-3">
<div>
<h3 class="text-sm font-bold text-white">Đăng phim</h3>
<p class="text-xs text-zinc-500">Lưu cập nhật nội dung</p>
</div><span class="admin-badge"
:class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-zinc-400/10 text-zinc-400'">{{
movie.active ? 'Hiển thị' : 'Đang ẩn' }}</span>
</div>
<div class="space-y-3 p-4 text-sm">
<div class="flex justify-between gap-3"><span class="text-zinc-500">Nguồn</span><strong
class="text-white">{{ movie.source?.toUpperCase() }}</strong></div>
<div class="flex justify-between gap-3"><span class="text-zinc-500">Năm</span><span
class="text-zinc-200">{{ movie.year || '—' }}</span></div>
<div class="flex justify-between gap-3"><span class="text-zinc-500">Tổng tập</span><span
class="text-zinc-200">{{ movie.episodeTotal || '—' }}</span></div>
<div class="flex justify-between gap-3"><span class="text-zinc-500">Đồng bộ</span><span
class="truncate text-right text-zinc-200">{{ new Date(movie.syncedAt).toLocaleString('vi-VN')
}}</span></div>
</div>
<div class="border-t border-white/6 p-4"><button type="button"
class="admin-btn-primary w-full justify-center" :disabled="saving" @click="handleSave">
<AppIcon name="save" class="size-4" />{{ saving ? 'Đang lưu...' : 'Cập nhật phim' }}
</button>
<Transition name="fade">
<p v-if="saved" class="mt-2 text-center text-xs font-semibold text-emerald-400">Đã lưu thành công</p>
</Transition>
</div>
</div> </div>
<!-- Quick info --> <div class="admin-card overflow-hidden">
<div class="admin-card p-4"> <div class="border-b border-white/6 px-4 py-3">
<h4 class="mb-3 text-xs font-bold uppercase tracking-wider text-zinc-500">Thông tin nhanh</h4> <h3 class="text-sm font-bold text-white">Ảnh đại diện ngang</h3>
<dl class="grid grid-cols-2 gap-x-2 gap-y-2 text-sm"> <p class="text-xs text-zinc-500">Poster 16:9</p>
<dt class="text-zinc-500">Nguồn</dt> </div>
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd> <div class="p-4">
<dt class="text-zinc-500">Năm</dt> <div class="aspect-video overflow-hidden rounded-xl border border-white/8 bg-white/2"><img
<dd class="text-zinc-200">{{ movie.year || '—' }}</dd> v-if="customPoster || movie.poster" :src="customPoster || movie.poster || undefined" :alt="movie.name"
<dt class="text-zinc-500">Tập</dt> class="size-full object-cover">
<dd class="text-zinc-200">{{ movie.episode || '—' }}</dd> <div v-else class="grid size-full place-items-center text-zinc-600">
<dt class="text-zinc-500">Trạng thái</dt> <AppIcon name="image" class="size-7" />
<dd class="font-semibold" :class="movie.active ? 'text-emerald-400' : 'text-zinc-400'"> </div>
{{ movie.active ? 'Hiển thị' : 'Ẩn' }} </div><input v-model="customPoster" type="url" placeholder="URL ảnh ngang..."
</dd> class="admin-input-sm mt-3 w-full">
</dl> </div>
</div>
<div class="admin-card overflow-hidden">
<div class="border-b border-white/6 px-4 py-3">
<h3 class="text-sm font-bold text-white">Ảnh đại diện dọc</h3>
<p class="text-xs text-zinc-500">Thumbnail 2:3</p>
</div>
<div class="p-4">
<div
class="mx-auto aspect-2/3 w-full max-w-55 overflow-hidden rounded-xl border border-white/8 bg-white/2">
<img v-if="customThumb || movie.thumb || movie.poster" :src="customThumb || movie.thumb || movie.poster || undefined"
:alt="movie.name" class="size-full object-cover">
<div v-else class="grid size-full place-items-center text-zinc-600">
<AppIcon name="image" class="size-7" />
</div>
</div><input v-model="customThumb" type="url" placeholder="URL ảnh dọc..."
class="admin-input-sm mt-3 w-full">
</div>
</div> </div>
</aside> </aside>
</div> </div>
@@ -774,4 +660,4 @@ function useApiActors() {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>
+7 -7
View File
@@ -64,7 +64,7 @@ const members = computed(() => data.value?.items || [])
</div> </div>
<div class="admin-card overflow-hidden"> <div class="admin-card overflow-hidden">
<div class="flex items-center gap-3 border-b border-white/[0.06] p-4"> <div class="flex items-center gap-3 border-b border-white/6 p-4">
<div class="relative flex-1"> <div class="relative flex-1">
<AppIcon name="search" class="absolute left-3.5 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..."
@@ -75,7 +75,7 @@ const members = computed(() => data.value?.items || [])
<div class="overflow-x-auto admin-scrollbar"> <div class="overflow-x-auto admin-scrollbar">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-white/[0.06] text-left text-xs font-bold uppercase tracking-wide text-slate-400"> <tr class="border-b border-white/6 text-left text-xs font-bold uppercase tracking-wide text-slate-400">
<th class="w-14 px-5 py-3">STT</th> <th class="w-14 px-5 py-3">STT</th>
<th class="px-5 py-3">Thành viên</th> <th class="px-5 py-3">Thành viên</th>
<th class="px-5 py-3">Vai trò</th> <th class="px-5 py-3">Vai trò</th>
@@ -84,12 +84,12 @@ const members = computed(() => data.value?.items || [])
<th class="px-5 py-3 text-right">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/[0.04]"> <tbody class="divide-y divide-white/4">
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/[0.02]"> <tr v-for="member in members" :key="member.id" class="transition hover:bg-white/2">
<td class="px-5 py-3.5 text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}</td> <td class="px-5 py-3.5 text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}</td>
<td class="px-5 py-3.5"> <td class="px-5 py-3.5">
<div class="flex items-center gap-3"> <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"> <div class="grid size-9 place-items-center rounded-full bg-linear-to-br from-slate-700 to-slate-800 text-sm font-bold text-slate-300">
{{ (member.name || member.email || '?').charAt(0).toUpperCase() }} {{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
@@ -100,7 +100,7 @@ const members = computed(() => data.value?.items || [])
</td> </td>
<td class="px-5 py-3.5"> <td class="px-5 py-3.5">
<select :value="member.role" <select :value="member.role"
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="h-8 rounded-full border border-white/8 bg-white/3 px-3 text-xs font-bold text-white outline-none transition hover:bg-white/5 focus:border-yellow-400/50"
@change="changeRole(member, ($event.target as HTMLSelectElement).value)"> @change="changeRole(member, ($event.target as HTMLSelectElement).value)">
<option value="user" class="bg-[#131418]">User</option> <option value="user" class="bg-[#131418]">User</option>
<option value="moderator" class="bg-[#131418]">Moderator</option> <option value="moderator" class="bg-[#131418]">Moderator</option>
@@ -133,7 +133,7 @@ const members = computed(() => data.value?.items || [])
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-white/[0.06] p-4"> <div class="flex items-center justify-between border-t border-white/6 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>