feat: implement cache busting mechanism and UI improvements

- Added `useCacheBust` composable to manage cache invalidation after movie edits in the admin panel.
- Integrated cache busting in movie sync and delete actions to ensure data consistency across pages.
- Enhanced UI components in the admin movie management and member management pages for better readability and usability.
- Introduced a suggestions tab in the movie detail page to recommend similar movies based on categories.
- Updated styles for various elements to improve the overall aesthetic and user experience.
This commit is contained in:
ngthanhvu
2026-08-05 05:19:34 -04:00
parent 6619cae6cc
commit a36e6c77f8
13 changed files with 1117 additions and 873 deletions
+438 -362
View File
@@ -56,8 +56,17 @@ const expandedSources = ref<Record<string, boolean>>({})
const selectedTargetServer = ref(0)
const collapsedServers = ref<Record<number, boolean>>({})
const expandedLinks = ref<Record<string, boolean>>({})
const activeSection = ref<"info" | "content" | "actors" | "images" | "episodes">("info")
const sections = [
{ key: "info" as const, label: "Thông tin", icon: "info" as const },
{ key: "content" as const, label: "Mô tả", icon: "type" as const },
{ key: "actors" as const, label: "Diễn viên", icon: "users" as const },
{ key: "images" as const, label: "Hình ảnh", icon: "image" as const },
{ key: "episodes" as const, label: "Tập phim", icon: "server" as const },
]
const saving = ref(false)
const saved = ref(false)
const { bust } = useCacheBust()
watch(movie, (m) => {
if (m) {
@@ -69,9 +78,9 @@ watch(movie, (m) => {
: []
customServers.value = m.customServers?.length
? m.customServers.map((server: any) => ({
name: server.name || '',
episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })),
}))
name: server.name || '',
episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })),
}))
: []
}
}, { immediate: true })
@@ -229,6 +238,7 @@ async function handleSave() {
},
})
saved.value = true
bust() // Invalidate cached data across all pages
await refresh()
setTimeout(() => { saved.value = false }, 2500)
} catch (err) {
@@ -265,424 +275,490 @@ function useApiActors() {
</script>
<template>
<div>
<div class="space-y-5">
<NuxtLink to="/admin/phim"
class="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-400 transition hover:text-white">
class="inline-flex items-center gap-2 text-sm font-medium text-zinc-400 transition hover:text-white">
<AppIcon name="arrow-left" class="size-4" />
Quay lại danh sách phim
</NuxtLink>
<div v-if="!movie" class="rounded-xl border border-white/10 bg-slate-900/50 p-12 text-center">
<p class="text-slate-400">Đang tải...</p>
<div v-if="!movie" class="admin-card p-12 text-center">
<p class="text-sm text-zinc-500">Đang tải...</p>
</div>
<div v-else>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="truncate text-2xl font-black text-white">{{ movie.name }}</h1>
<p class="mt-1 text-sm text-slate-400">
{{ movie.originName }} · {{ movie.source?.toUpperCase() }} · {{ movie.year || 'N/A' }}
</p>
<div v-else class="space-y-5">
<!-- Header Card -->
<div class="admin-card p-4 sm:p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="shrink-0">
<div
class="aspect-[16/9] w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] sm:w-44">
<img v-if="customPoster || movie.poster" :src="customPoster || movie.poster" :alt="movie.name"
class="h-full w-full object-cover">
<div v-else class="grid h-full place-items-center text-zinc-600">
<AppIcon name="image" class="size-6" />
</div>
</div>
</div>
<div class="min-w-0 flex-1">
<h1 class="admin-section-title truncate">{{ movie.name }}</h1>
<p class="admin-section-subtitle mt-1">
{{ movie.originName }} · {{ movie.source?.toUpperCase() }} · {{ movie.year || 'N/A' }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="admin-badge"
:class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-zinc-400/10 text-zinc-400'">
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
</span>
<span v-if="movie.quality" class="admin-badge bg-white/[0.06] text-zinc-400">{{ movie.quality }}</span>
<span class="admin-badge bg-white/[0.06] text-zinc-400">{{ movie.episode || '—' }} tập</span>
</div>
</div>
</div>
<button type="button"
class="inline-flex h-10 shrink-0 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="saving" @click="handleSave">
<AppIcon name="save" class="size-4" />
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }}
</button>
</div>
<Transition name="fade">
<div v-if="saved"
class="mb-4 flex items-center gap-2 rounded-lg border border-green-400/20 bg-green-400/10 px-4 py-3 text-sm font-semibold text-green-400">
class="flex items-center gap-2 rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-400">
<AppIcon name="check" class="size-4" />
Đã lưu thành công
</div>
</Transition>
<div class="grid gap-6 lg:grid-cols-12 items-start">
<!-- LEFT COLUMN: Main Content & Episode Management (7 cols) -->
<div class="space-y-6 lg:col-span-7">
<!-- tả phim -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-blue-400/10">
<AppIcon name="type" class="size-4 text-blue-400" />
</div>
<div>
<h2 class="text-base font-black text-white"> tả phim</h2>
<p class="text-xs text-slate-400">
{{ customContent ? 'Mô tả tuỳ chỉnh (Đã áp dụng)' : 'Mô tả mặc định từ API' }}
</p>
</div>
</div>
<button v-if="apiContent && !customContent" type="button"
class="inline-flex h-7 items-center gap-1 rounded-lg bg-yellow-400/10 px-2.5 text-xs font-bold text-yellow-400 transition hover:bg-yellow-400/20"
@click="useApiContent">
<AppIcon name="pen" class="size-3" />
Dùng tả này
</button>
<button v-else-if="customContent" type="button"
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
@click="clearField('customContent')">
Quay lại mặc định
</button>
</div>
<!-- State 1: Chưa bấm dùng tả tuỳ chỉnh -> Hiển thị tả mặc định API -->
<div v-if="!customContent" class="rounded-lg border border-white/10 bg-white/5 p-3.5">
<div class="mb-2 flex items-center justify-between">
<span class="inline-flex items-center gap-1.5 rounded-full bg-blue-400/10 px-2.5 py-0.5 text-[10px] font-black uppercase text-blue-400">
<span class="size-1.5 rounded-full bg-blue-400"></span>
Nội dung từ API nguồn
</span>
</div>
<p class="text-xs leading-6 text-slate-300 whitespace-pre-line">
{{ apiContent || 'Chưa có mô tả từ nguồn API.' }}
</p>
</div>
<!-- State 2: Đã bấm dùng tả / chỉnh sửa -> Khung nhập full ra toàn bộ chiều cao -->
<div v-else>
<textarea v-model="customContent" :rows="contentRows" placeholder="Nhập mô tả phim..."
style="field-sizing: content; min-height: 140px;"
class="w-full rounded-lg border border-yellow-400/40 bg-white/5 p-3 text-xs leading-6 text-white outline-none placeholder:text-slate-500 focus:border-yellow-400" />
</div>
</div>
<!-- Diễn viên -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-pink-400/10">
<AppIcon name="users" class="size-4 text-pink-400" />
</div>
<div>
<h2 class="text-base font-black text-white">Diễn viên</h2>
<p class="text-xs text-slate-400">Danh sách diễn viên của phim ({{ customActors.length }})</p>
</div>
</div>
<div class="flex gap-2">
<button v-if="apiActors.length" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-yellow-400/10 px-3 text-xs font-bold text-yellow-400 transition hover:bg-yellow-400/20"
@click="useApiActors">
<AppIcon name="pen" class="size-3.5" />
Import từ API ({{ apiActors.length }})
</button>
<button type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-pink-500/20 px-3 text-xs font-bold text-pink-400 transition hover:bg-pink-500/30"
@click="addActor">
<AppIcon name="plus" class="size-3.5" />
Thêm diễn viên
</button>
</div>
</div>
<div v-if="customActors.length" class="max-h-64 overflow-y-auto space-y-1.5 pr-1">
<div v-for="(actor, index) in customActors" :key="index"
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 p-1.5">
<img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name"
class="size-6 shrink-0 rounded-full object-cover ring-1 ring-white/10">
<div v-else class="grid size-6 shrink-0 place-items-center rounded-full bg-pink-400/10 text-[10px] font-black text-pink-400">
{{ (actor.name || '?')[0].toUpperCase() }}
</div>
<input v-model="actor.name" type="text" placeholder="Tên diễn viên"
class="h-7 w-28 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-32">
<input v-model="actor.originalName" type="text" placeholder="Tên gốc"
class="h-7 w-24 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-28">
<input v-model="actor.role" type="text" placeholder="Vai diễn"
class="h-7 w-20 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-24">
<input v-model="actor.avatar" type="url" placeholder="Avatar URL"
class="h-7 min-w-0 flex-1 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded text-red-400 transition hover:bg-red-400/10"
@click="removeActor(index)">
<AppIcon name="trash" class="size-3.5" />
</button>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
<p class="text-sm text-slate-500">Chưa diễn viên nào. Bấm "Thêm diễn viên" hoặc "Import từ API" để bắt đầu.</p>
</div>
</div>
<!-- Server & tập phim -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-emerald-400/10">
<AppIcon name="server" class="size-4 text-emerald-400" />
</div>
<div>
<h3 class="text-base font-black text-white">Server & tập phim</h3>
<p class="text-xs text-slate-400">Danh sách server phát hiển thị trên website</p>
</div>
</div>
<button type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-emerald-500/20 px-3 text-xs font-bold text-emerald-400 transition hover:bg-emerald-500/30"
@click="addServer">
<AppIcon name="plus" class="size-3.5" />
Thêm server
</button>
</div>
<div v-if="customServers.length" class="space-y-3">
<div v-for="(server, serverIndex) in customServers" :key="serverIndex"
class="rounded-lg border border-white/10 bg-white/5">
<div class="flex items-center justify-between gap-3 p-3">
<button type="button" class="flex min-w-0 flex-1 items-center gap-2 text-left"
@click="toggleServer(serverIndex)">
<AppIcon name="chevron-down" class="size-4 shrink-0 text-slate-400 transition"
:class="collapsedServers[serverIndex] ? '-rotate-90' : ''" />
<span class="truncate text-sm font-bold text-white">{{ server.name || `Server ${serverIndex + 1}` }}</span>
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-semibold text-slate-300">
{{ server.episodes.length }} tập
</span>
</button>
<button type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
@click="removeServer(serverIndex)">
<AppIcon name="trash" class="size-3.5" />
</button>
</div>
<div v-if="!collapsedServers[serverIndex]" class="border-t border-white/10 p-3">
<input v-model="server.name" type="text" placeholder="Tên server (vd: Vietsub, Thuyết minh...)"
class="mb-3 h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 text-sm font-bold text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<div v-if="server.episodes.length" class="space-y-1.5">
<div v-for="(ep, episodeIndex) in server.episodes" :key="episodeIndex"
class="rounded-md border border-white/10 bg-white/5">
<div class="flex items-center gap-2 p-2">
<span class="w-7 shrink-0 text-center text-xs font-black text-slate-400">{{ episodeIndex + 1 }}</span>
<input v-model="ep.name" type="text" placeholder="Tên tập"
class="h-8 w-24 shrink-0 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<select
class="h-8 min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white outline-none focus:border-yellow-400/50"
@change="handleSourceLinkChange(serverIndex, episodeIndex, $event)">
<option value="" class="bg-slate-900 text-white">Chọn link từ API</option>
<option v-for="item in availableEpisodes" :key="item.key" :value="item.key"
class="bg-slate-900 text-white">
{{ item.label }}
</option>
</select>
<button type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded text-slate-400 transition hover:bg-white/10 hover:text-white"
:class="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'bg-yellow-400/10 text-yellow-400' : ''"
:title="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'Ẩn link' : 'Sửa link'"
@click="toggleLinks(`${serverIndex}-${episodeIndex}`)">
<AppIcon name="circle" class="size-3" />
</button>
<button type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded text-red-400 transition hover:bg-red-400/10"
@click="removeServerEpisode(serverIndex, episodeIndex)">
<AppIcon name="trash" class="size-3" />
</button>
</div>
<div v-if="expandedLinks[`${serverIndex}-${episodeIndex}`]" class="border-t border-white/10 p-2">
<div class="grid gap-2 sm:grid-cols-2">
<input v-model="ep.linkEmbed" type="url" placeholder="Link embed (iframe)"
class="h-8 w-full rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<input v-model="ep.linkM3u8" type="url" placeholder="Link HLS (.m3u8)"
class="h-8 w-full rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
</div>
</div>
</div>
</div>
<button type="button"
class="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-white/20 px-3 text-xs font-semibold text-white transition hover:bg-white/10"
@click="addServerEpisode(serverIndex)">
<AppIcon name="plus" class="size-3.5" />
Thêm tập
</button>
</div>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
<p class="text-sm text-slate-500">Chưa server nào. Bấm "Thêm server" để bắt đầu.</p>
</div>
</div>
</div>
<!-- RIGHT COLUMN: Metadata & API Sources Sidebar (5 cols) -->
<div class="space-y-6 lg:col-span-5">
<!-- Thông tin phim -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<!-- Sidebar nav + content -->
<div class="grid gap-5 lg:grid-cols-12 lg:items-start">
<!-- Content -->
<div class="space-y-5 lg:col-span-9">
<!-- Section: Thông tin -->
<div v-if="activeSection === 'info'" class="admin-card p-5">
<div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-amber-400/10">
<AppIcon name="info" class="size-4 text-amber-400" />
<div class="admin-icon-square">
<AppIcon name="info" class="size-4" />
</div>
<div>
<h2 class="text-base font-black text-white">Thông tin phim</h2>
<p class="text-xs text-slate-400">Thông tin chi tiết từ hệ thống</p>
<h2 class="text-sm font-bold text-white">Thông tin chi tiết</h2>
<p class="text-xs text-zinc-500">Dữ liệu từ hệ thống</p>
</div>
</div>
<dl class="grid grid-cols-2 gap-x-3 gap-y-2.5 text-xs">
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-slate-500">Nguồn</dt>
<dd class="truncate font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
<dt class="admin-label mb-1">Nguồn</dt>
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
</div>
<div>
<dt class="text-slate-500">Slug</dt>
<dd class="truncate text-white" :title="movie.slug">{{ movie.slug }}</dd>
<dt class="admin-label mb-1">Slug</dt>
<dd class="truncate text-zinc-200" :title="movie.slug">{{ movie.slug }}</dd>
</div>
<div>
<dt class="text-slate-500">Năm phát hành</dt>
<dd class="text-white">{{ movie.year || '—' }}</dd>
<dt class="admin-label mb-1">Năm phát hành</dt>
<dd class="text-zinc-200">{{ movie.year || '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Số tập</dt>
<dd class="text-white">{{ movie.episode || '—' }}</dd>
<dt class="admin-label mb-1">Số tập</dt>
<dd class="text-zinc-200">{{ movie.episode || '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Chất lượng</dt>
<dd class="text-white">{{ movie.quality || '—' }}</dd>
<dt class="admin-label mb-1">Chất lượng</dt>
<dd class="text-zinc-200">{{ movie.quality || '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Trạng thái</dt>
<dd :class="movie.active ? 'font-semibold text-green-400' : 'text-slate-400'">
<dt class="admin-label mb-1">Trạng thái</dt>
<dd class="font-semibold" :class="movie.active ? 'text-emerald-400' : 'text-zinc-400'">
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
</dd>
</div>
<div class="col-span-2">
<dt class="text-slate-500">Đồng bộ gần nhất</dt>
<dd class="text-white">{{ new Date(movie.syncedAt).toLocaleDateString('vi-VN') }}</dd>
<dt class="admin-label mb-1">Đồng bộ gần nhất</dt>
<dd class="text-zinc-200">{{ new Date(movie.syncedAt).toLocaleString('vi-VN') }}</dd>
</div>
</dl>
<div v-if="movie.categories?.length" class="mt-4 border-t border-white/10 pt-3">
<h4 class="mb-1.5 text-[11px] font-black uppercase text-slate-400">Thể loại</h4>
<div class="flex flex-wrap gap-1">
<div v-if="movie.categories?.length" class="mt-4 border-t border-white/[0.06] pt-3">
<h4 class="admin-label mb-2">Thể loại</h4>
<div class="flex flex-wrap gap-1.5">
<span v-for="cat in movie.categories" :key="cat"
class="rounded-full bg-white/8 px-2.5 py-0.5 text-[11px] font-semibold text-slate-300">
{{ cat }}
</span>
class="rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium text-zinc-300">{{ cat }}</span>
</div>
</div>
</div>
<!-- Ảnh phim -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<div class="mb-4 flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-lg bg-purple-400/10">
<AppIcon name="image" class="size-4 text-purple-400" />
</div>
<div>
<h2 class="text-base font-black text-white">Hình ảnh</h2>
<p class="text-xs text-slate-400">Poster & Thumbnail tuỳ chỉnh</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-400">Poster (ảnh ngang)</label>
<input v-model="customPoster" type="url" placeholder="https://..."
class="h-9 w-full rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<div v-if="customPoster || movie.poster" class="mt-2 flex items-center justify-between gap-2">
<img :src="customPoster || movie.poster" :alt="movie.name"
class="h-20 w-32 rounded-lg object-cover ring-1 ring-white/10">
<button v-if="customPoster" type="button"
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
@click="clearField('customPoster')">
Xoá poster
</button>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-400">Thumbnail (ảnh dọc)</label>
<input v-model="customThumb" type="url" placeholder="https://..."
class="h-9 w-full rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<div v-if="customThumb || movie.thumb" class="mt-2 flex items-center justify-between gap-2">
<img :src="customThumb || movie.thumb" :alt="movie.name"
class="h-20 w-14 rounded-lg object-cover ring-1 ring-white/10">
<button v-if="customThumb" type="button"
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
@click="clearField('customThumb')">
Xoá thumbnail
</button>
</div>
</div>
</div>
</div>
<!-- Nguồn API sẵn -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<!-- 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="grid size-9 place-items-center rounded-lg bg-cyan-400/10">
<AppIcon name="layers" class="size-4 text-cyan-400" />
<div class="admin-icon-square">
<AppIcon name="type" class="size-4" />
</div>
<div>
<h3 class="text-base font-black text-white">Nguồn API sẵn</h3>
<p class="text-xs text-slate-400">Dữ liệu tập phim từ các API</p>
<h2 class="text-sm font-bold text-white"> 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>
<div v-if="sourceData?.sources?.length" class="space-y-3">
<div class="mb-4 flex flex-col gap-2 rounded-lg border border-white/10 bg-white/5 p-3 sm:flex-row sm:items-center sm:justify-between">
<label class="text-xs font-semibold text-slate-400">Thêm vào server:</label>
<select v-model="selectedTargetServer"
class="h-8 rounded-md border border-white/10 bg-white/5 px-2.5 text-xs text-white outline-none focus:border-yellow-400/50">
<option v-for="(server, index) in customServers" :key="index" :value="index"
class="bg-slate-900 text-white">
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập)
</option>
</select>
<button v-if="!customServers.length" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/10 px-3 text-xs font-semibold text-white transition hover:bg-white/20"
@click="addServer">
<AppIcon name="plus" class="size-3.5" />
Tạo server
</button>
</div>
<div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source"
class="rounded-lg border border-white/10 bg-white/5">
<button type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/5"
@click="toggleSource(source.source)">
<span class="text-sm font-black text-white">
{{ source.source.toUpperCase() }} · {{ source.name }}
</span>
<AppIcon name="chevron-down"
class="size-4 text-slate-400 transition"
:class="expandedSources[source.source] ? 'rotate-180' : ''" />
</button>
<div v-if="expandedSources[source.source]" class="border-t border-white/10 p-4">
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0">
<div class="mb-2 flex items-center justify-between gap-2">
<h4 class="text-xs font-black uppercase text-slate-400">{{ server.name }}</h4>
<button type="button"
class="inline-flex h-7 items-center gap-1 rounded-lg bg-yellow-400/10 px-2 text-xs font-semibold text-yellow-400 transition hover:bg-yellow-400/20"
@click="quickAddAllEpisodes(sourceIndex, serverIndex)">
<AppIcon name="plus" class="size-3" />
Thêm tất cả
</button>
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div v-for="(ep, epIndex) in server.episodes" :key="epIndex"
class="group relative rounded-md border border-white/10 bg-white/5 px-2.5 py-1.5">
<p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}</p>
<p v-if="ep.linkM3u8" class="truncate text-[10px] text-slate-500">{{ ep.linkM3u8 }}</p>
<p v-else-if="ep.linkEmbed" class="truncate text-[10px] text-slate-500">{{ ep.linkEmbed }}</p>
<button type="button"
class="absolute right-1 top-1 grid size-6 place-items-center rounded bg-yellow-400/10 text-yellow-400 opacity-0 transition hover:bg-yellow-400/20 group-hover:opacity-100"
:title="`Thêm ${ep.name || `Tập ${epIndex + 1}`} vào server`"
@click="quickAddEpisode(sourceIndex, serverIndex, epIndex)">
<AppIcon name="plus" class="size-3" />
</button>
<!-- API Sources Panel -->
<div class="xl:col-span-5">
<div class="admin-card p-5 xl:sticky xl:top-5">
<div class="mb-4 flex items-center gap-3">
<div class="admin-icon-square">
<AppIcon name="layers" class="size-4" />
</div>
<div>
<h3 class="text-sm font-bold text-white">Nguồn API 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 v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
<p class="text-sm text-slate-500">Chưa nguồn API nào hoặc chưa đồng bộ tập phim.</p>
</div>
</div>
</div>
<!-- Right Sidebar: Navigation + Save -->
<aside class="space-y-5 lg:sticky lg:top-5 lg:col-span-3">
<!-- Nav -->
<div class="admin-card p-2">
<nav class="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible no-scrollbar">
<button v-for="s in sections" :key="s.key"
class="relative flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-semibold transition lg:w-full"
:class="activeSection === s.key ? 'bg-yellow-400/10 text-yellow-400' : 'text-zinc-400 hover:bg-white/[0.03] hover:text-white'"
@click="activeSection = s.key">
<span v-if="activeSection === s.key"
class="absolute left-0 top-1/2 hidden h-5 w-1 -translate-y-1/2 rounded-r-full bg-yellow-400 lg:block" />
<AppIcon :name="s.icon" class="size-4" />
<span class="flex-1 whitespace-nowrap">{{ s.label }}</span>
<span v-if="s.key === 'actors' && customActors.length"
class="rounded-full bg-white/[0.06] px-1.5 py-0.5 text-[10px] font-bold text-zinc-300">{{
customActors.length }}</span>
<span v-else-if="s.key === 'episodes' && customServers.length"
class="rounded-full bg-white/[0.06] px-1.5 py-0.5 text-[10px] font-bold text-zinc-300">{{
customServers.length }}</span>
</button>
</nav>
</div>
<!-- Save button sticky -->
<div class="admin-card p-4">
<button type="button" class="w-full admin-btn-primary justify-center" :disabled="saving"
@click="handleSave">
<AppIcon name="save" class="size-4" />
{{ saving ? 'Đang lưu...' : 'Lưu thay đổi' }}
</button>
<Transition name="fade">
<div v-if="saved"
class="mt-2 flex items-center gap-2 rounded-lg border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold text-emerald-400">
<AppIcon name="check" class="size-3" />
Đã lưu thành công
</div>
</Transition>
</div>
<!-- Quick info -->
<div class="admin-card p-4">
<h4 class="mb-3 text-xs font-bold uppercase tracking-wider text-zinc-500">Thông tin nhanh</h4>
<dl class="grid grid-cols-2 gap-x-2 gap-y-2 text-sm">
<dt class="text-zinc-500">Nguồn</dt>
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
<dt class="text-zinc-500">Năm</dt>
<dd class="text-zinc-200">{{ movie.year || '—' }}</dd>
<dt class="text-zinc-500">Tập</dt>
<dd class="text-zinc-200">{{ movie.episode || '—' }}</dd>
<dt class="text-zinc-500">Trạng thái</dt>
<dd class="font-semibold" :class="movie.active ? 'text-emerald-400' : 'text-zinc-400'">
{{ movie.active ? 'Hiển thị' : 'Ẩn' }}
</dd>
</dl>
</div>
</aside>
</div>
</div>
</div>
+112 -152
View File
@@ -22,6 +22,8 @@ const deleteConfirmOpen = ref(false)
const syncSources = ref({ ophim: true, nguonc: true, kkphim: true })
const syncResult = ref<{ total: number, created: number, updated: number, sourceStats: Record<string, { fetched: number, error?: string }> } | null>(null)
const { bust } = useCacheBust()
let searchTimeout: ReturnType<typeof setTimeout> | undefined
watch(searchInput, (val) => {
@@ -72,6 +74,7 @@ async function toggleActive(movie: any) {
method: 'PATCH',
body: { active: movie.active },
})
bust()
await refresh()
}
@@ -90,6 +93,7 @@ async function handleSync() {
body: { sources },
})
syncResult.value = result
bust() // Invalidate cached data across all pages
await refresh()
} catch (err) {
console.error('Sync failed:', err)
@@ -103,6 +107,7 @@ async function handleDeleteAll() {
try {
await $fetch('/api/admin/movies', { method: 'DELETE' })
deleteConfirmOpen.value = false
bust() // Invalidate cached data across all pages
await refresh()
} catch (err) {
console.error('Delete failed:', err)
@@ -192,151 +197,143 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
}
return 'Vừa xong'
}
function sortIcon(column: string): string {
if (sortBy.value !== column) return 'chevron-down'
return sortOrder.value === 'asc' ? 'chevron-up' : 'chevron-down'
}
const syncSourceOptions = [
{ key: 'ophim', label: 'OPhim', domain: 'ophim1.com' },
{ key: 'nguonc', label: 'NguonC', domain: 'phim.nguonc.com' },
{ key: 'kkphim', label: 'KKPhim', domain: 'phimapi.com' },
] as const
</script>
<template>
<div>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-black text-white">Quản phim</h1>
<p class="mt-1 text-sm text-slate-400">Quản phim hiển thị trên CineK ({{ data?.total || 0 }} phim)</p>
<h1 class="admin-section-title">Quản phim</h1>
<p class="admin-section-subtitle mt-1">Quản phim hiển thị trên CineK ({{ data?.total || 0 }} phim)</p>
</div>
<div class="flex items-center gap-2">
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-black text-red-400 transition hover:bg-red-400/20"
@click="deleteConfirmOpen = true">
<button type="button" class="admin-btn-danger" @click="deleteConfirmOpen = true">
<AppIcon name="trash" class="size-4" />
Xoá tất cả
</button>
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
@click="syncOpen = true">
<button type="button" class="admin-btn-primary" @click="syncOpen = true">
<AppIcon name="refresh" class="size-4" />
Đồng bộ từ API
</button>
</div>
</div>
<div class="rounded-xl border border-white/10 bg-slate-900/50">
<div class="flex flex-col gap-3 border-b border-white/10 p-4 sm:flex-row sm:items-center">
<div class="admin-card overflow-hidden">
<div class="flex flex-col gap-3 border-b border-white/[0.06] p-4 sm:flex-row sm:items-center">
<div class="relative flex-1">
<AppIcon name="search" class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..."
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-400 outline-none focus:border-yellow-400/50">
<AppIcon name="search" class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="searchInput" type="search" placeholder="Tìm kiếm phim..." class="admin-input pl-10">
</div>
<select v-model="statusFilter"
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
<option value="" class="bg-slate-900">Tất cả trạng thái</option>
<option value="active" class="bg-slate-900">Đang hiển thị</option>
<option value="inactive" class="bg-slate-900">Chưa hiển thị</option>
<select v-model="statusFilter" class="admin-input w-full sm:w-auto">
<option value="" class="bg-[#131418]">Tất cả trạng thái</option>
<option value="active" class="bg-[#131418]">Đang hiển th</option>
<option value="inactive" class="bg-[#131418]">Chưa hiển thị</option>
</select>
<select v-model="sourceFilter"
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
<option value="" class="bg-slate-900">Tất cả nguồn</option>
<option value="ophim" class="bg-slate-900">OPhim</option>
<option value="nguonc" class="bg-slate-900">NguonC</option>
<option value="kkphim" class="bg-slate-900">KKPhim</option>
<select v-model="sourceFilter" class="admin-input w-full sm:w-auto">
<option value="" class="bg-[#131418]">Tất cả nguồn</option>
<option value="ophim" class="bg-[#131418]">OPhim</option>
<option value="nguonc" class="bg-[#131418]">NguonC</option>
<option value="kkphim" class="bg-[#131418]">KKPhim</option>
</select>
<select v-model="typeFilter"
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
<option value="" class="bg-slate-900">Tất cả loại</option>
<option value="series" class="bg-slate-900">Phim bộ</option>
<option value="single" class="bg-slate-900">Phim lẻ</option>
<select v-model="typeFilter" class="admin-input w-full sm:w-auto">
<option value="" class="bg-[#131418]">Tất cả loại</option>
<option value="series" class="bg-[#131418]">Phim bộ</option>
<option value="single" class="bg-[#131418]">Phim lẻ</option>
</select>
</div>
<div class="overflow-x-auto">
<div class="overflow-x-auto admin-scrollbar">
<table class="w-full">
<thead>
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
<th class="w-12 px-4 py-3 text-center">STT</th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('name')">
<div class="flex items-center justify-center gap-1">
<tr class="border-b border-white/[0.06] text-left text-xs font-bold uppercase tracking-wide text-slate-400">
<th class="w-14 px-5 py-3">STT</th>
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('name')">
<div class="flex items-center gap-1">
Phim
<AppIcon v-if="sortBy === 'name'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
<AppIcon :name="sortIcon('name')" class="size-3" :class="sortBy === 'name' ? 'text-yellow-400' : 'opacity-50'" />
</div>
</th>
<th class="px-4 py-3 text-center">Nguồn trùng</th>
<th class="px-4 py-3 text-center">Tập</th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('views')">
<div class="flex items-center justify-center gap-1">
<th class="px-5 py-3">Nguồn</th>
<th class="px-5 py-3">Tập</th>
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('views')">
<div class="flex items-center gap-1">
Lượt xem
<AppIcon v-if="sortBy === 'views'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
<AppIcon :name="sortIcon('views')" class="size-3" :class="sortBy === 'views' ? 'text-yellow-400' : 'opacity-50'" />
</div>
</th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('apiUpdatedAt')">
<div class="flex items-center justify-center gap-1">
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('apiUpdatedAt')">
<div class="flex items-center gap-1">
Cập nhật API
<AppIcon v-if="sortBy === 'apiUpdatedAt'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
<AppIcon :name="sortIcon('apiUpdatedAt')" class="size-3" :class="sortBy === 'apiUpdatedAt' ? 'text-yellow-400' : 'opacity-50'" />
</div>
</th>
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('active')">
<div class="flex items-center justify-center gap-1">
<th class="cursor-pointer select-none px-5 py-3 transition hover:text-white" @click="toggleSort('active')">
<div class="flex items-center gap-1">
Trạng thái
<AppIcon v-if="sortBy === 'active'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
<AppIcon :name="sortIcon('active')" class="size-3" :class="sortBy === 'active' ? 'text-yellow-400' : 'opacity-50'" />
</div>
</th>
<th class="px-4 py-3 text-center">Chỉnh sửa</th>
<th class="px-4 py-3 text-center">Thao tác</th>
<th class="px-5 py-3">Tuỳ chỉnh</th>
<th class="px-5 py-3 text-right">Thao tác</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<tr v-for="movie in movies" :key="movie.id" class="transition hover:bg-white/5">
<td class="px-4 py-3 text-center text-sm text-slate-400">
<tbody class="divide-y divide-white/[0.04]">
<tr v-for="movie in movies" :key="movie.id" class="transition hover:bg-white/[0.02]">
<td class="px-5 py-3.5 text-sm font-semibold text-slate-500">
{{ (currentPage - 1) * 20 + movies.indexOf(movie) + 1 }}
</td>
<td class="px-4 py-3 text-center">
<td class="px-5 py-3.5">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ movie.name }}</p>
<p class="truncate text-sm font-bold text-white">{{ movie.name }}</p>
<p class="truncate text-xs text-slate-400">{{ movie.originName || movie.slug }}</p>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="rounded-full bg-white/10 px-2.5 py-1 text-xs font-semibold text-white">
{{ (movie.sources || []).length }} nguồn
</span>
<td class="px-5 py-3.5">
<span class="admin-badge bg-blue-400/10 text-blue-400">{{ (movie.sources || []).length }} nguồn</span>
</td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
<span
class="font-semibold text-yellow-300"
<td class="px-5 py-3.5 text-sm">
<span class="font-bold text-yellow-300"
:title="(movie.sources || []).map((s: any) => `${s.source.toUpperCase()}: ${s.episode || s.episodeTotal || '?'}`).join('\n')">
{{ formatEpisode(movie) }}
</span>
</td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
{{ (movie.views || 0).toLocaleString() }}
</td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
<td class="px-5 py-3.5 text-sm text-slate-300">{{ (movie.views || 0).toLocaleString() }}</td>
<td class="px-5 py-3.5 text-sm text-slate-300">
<span :title="formatDate(movie.apiUpdatedAt || movie.syncedAt)">
{{ formatRelativeTime(movie.apiUpdatedAt || movie.syncedAt) }}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold"
:class="movie.active ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'">
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
<td class="px-5 py-3.5">
<span class="admin-badge" :class="movie.active ? 'bg-emerald-400/10 text-emerald-400' : 'bg-slate-400/10 text-slate-400'">
{{ movie.active ? 'Hiển thị' : 'Ẩn' }}
</span>
</td>
<td class="px-4 py-3 text-center">
<div class="flex flex-wrap items-center justify-center gap-1">
<span v-if="movie.customContent"
class="rounded bg-blue-400/10 px-2 py-0.5 text-[10px] font-semibold text-blue-400"> tả</span>
<span v-if="movie.customPoster"
class="rounded bg-purple-400/10 px-2 py-0.5 text-[10px] font-semibold text-purple-400">Poster</span>
<span v-if="movie.customThumb"
class="rounded bg-pink-400/10 px-2 py-0.5 text-[10px] font-semibold text-pink-400">Thumb</span>
<span v-if="movie.customServers?.length"
class="rounded bg-emerald-400/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
<td class="px-5 py-3.5">
<div class="flex flex-wrap items-center gap-1">
<span v-if="movie.customContent" class="admin-badge bg-blue-400/10 text-blue-400"> tả</span>
<span v-if="movie.customPoster" class="admin-badge bg-purple-400/10 text-purple-400">Poster</span>
<span v-if="movie.customThumb" class="admin-badge bg-pink-400/10 text-pink-400">Thumb</span>
<span v-if="movie.customServers?.length" class="admin-badge bg-emerald-400/10 text-emerald-400">
{{ movie.customServers.length }} server · {{ movie.customServers.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0) }} tập
</span>
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length"
class="text-xs text-slate-500">Mặc định</span>
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length" class="text-xs text-slate-500">Mặc định</span>
</div>
</td>
<td class="px-4 py-3 text-center">
<div class="flex items-center justify-center gap-2">
<td class="px-5 py-3.5">
<div class="flex items-center justify-end gap-2">
<NuxtLink :to="`/admin/phim/${movie.id}`"
class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/10 hover:text-white"
class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/[0.05] hover:text-white"
title="Chỉnh sửa">
<AppIcon name="pencil" class="size-4" />
</NuxtLink>
@@ -346,7 +343,7 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</td>
</tr>
<tr v-if="!movies.length">
<td colspan="9" class="px-4 py-12 text-center text-sm text-slate-400">
<td colspan="9" class="px-5 py-12 text-center text-sm text-slate-400">
{{ debouncedKeyword ? 'Không tìm thấy phim nào.' : 'Chưa có phim nào. Bấm "Đồng bộ từ API" để lấy phim.' }}
</td>
</tr>
@@ -354,32 +351,29 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</table>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between border-t border-white/10 p-4">
<div v-if="totalPages > 1" class="flex items-center justify-between border-t border-white/[0.06] p-4">
<p class="text-sm text-slate-400">Trang {{ currentPage }} / {{ totalPages }}</p>
<div class="flex items-center gap-2">
<button type="button"
class="grid size-8 place-items-center rounded-lg border border-white/10 text-slate-400 transition hover:bg-white/10 hover:text-white disabled:opacity-30"
class="grid size-9 place-items-center rounded-lg border border-white/[0.08] text-slate-400 transition hover:bg-white/[0.05] hover:text-white disabled:opacity-30"
:disabled="currentPage <= 1" @click="currentPage--">
<AppIcon name="chevron-left" class="size-4" />
</button>
<template v-for="(page, index) in visiblePages" :key="index">
<span v-if="page === 'ellipsis'"
class="grid size-8 place-items-center text-sm text-slate-400">
...
</span>
<span v-if="page === 'ellipsis'" class="grid size-9 place-items-center text-sm text-slate-500">...</span>
<button v-else type="button"
class="grid size-8 place-items-center rounded-lg border text-sm transition"
class="grid size-9 place-items-center rounded-lg border text-sm font-semibold transition"
:class="page === currentPage
? 'border-yellow-400 bg-yellow-400 text-slate-950'
: 'border-white/10 text-slate-400 hover:bg-white/10 hover:text-white'"
: 'border-white/[0.08] text-slate-400 hover:bg-white/[0.05] hover:text-white'"
@click="currentPage = page">
{{ page }}
</button>
</template>
<button type="button"
class="grid size-8 place-items-center rounded-lg border border-white/10 text-slate-400 transition hover:bg-white/10 hover:text-white disabled:opacity-30"
class="grid size-9 place-items-center rounded-lg border border-white/[0.08] text-slate-400 transition hover:bg-white/[0.05] hover:text-white disabled:opacity-30"
:disabled="currentPage >= totalPages" @click="currentPage++">
<AppIcon name="chevron-right" class="size-4" />
</button>
</div>
</div>
@@ -389,50 +383,34 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
<Transition name="modal-fade">
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="syncOpen = false" />
<div class="relative w-full max-w-md rounded-xl border border-white/10 bg-slate-900 p-6 shadow-2xl">
<div class="relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#131418] p-6 shadow-2xl">
<button type="button"
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10"
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-slate-400 transition hover:bg-white/[0.05] hover:text-white"
@click="syncOpen = false">
<AppIcon name="x" class="size-5" />
</button>
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
<p class="mt-1 text-sm text-slate-400">Chọn nguồn để đồng bộ phim vào hệ thống</p>
<div class="mb-5">
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
<p class="mt-1 text-sm text-slate-400">Chọn nguồn để đồng bộ phim vào hệ thống</p>
</div>
<div class="mt-5 space-y-3">
<label
class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div class="space-y-3">
<label v-for="source in syncSourceOptions" :key="source.key"
class="flex cursor-pointer items-center justify-between rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 transition hover:border-white/[0.1] hover:bg-white/[0.05]">
<div>
<p class="text-sm font-semibold text-white">OPhim</p>
<p class="text-xs text-slate-400">ophim1.com</p>
<p class="text-sm font-bold text-white">{{ source.label }}</p>
<p class="text-xs text-slate-400">{{ source.domain }}</p>
</div>
<AdminToggle v-model="syncSources.ophim" />
</label>
<label
class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div>
<p class="text-sm font-semibold text-white">NguonC</p>
<p class="text-xs text-slate-400">phim.nguonc.com</p>
</div>
<AdminToggle v-model="syncSources.nguonc" />
</label>
<label
class="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4 transition hover:border-white/20">
<div>
<p class="text-sm font-semibold text-white">KKPhim</p>
<p class="text-xs text-slate-400">phimapi.com</p>
</div>
<AdminToggle v-model="syncSources.kkphim" />
<AdminToggle v-model="syncSources[source.key]" />
</label>
</div>
<div v-if="syncResult" class="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
<p class="text-sm font-semibold text-white">Kết quả đồng bộ</p>
<div v-if="syncResult" class="mt-4 rounded-xl border border-white/[0.06] bg-white/[0.03] p-4">
<p class="text-sm font-bold text-white">Kết quả đồng bộ</p>
<div class="mt-2 space-y-1 text-xs text-slate-400">
<p>Tổng phim lấy được: <span class="font-bold text-white">{{ syncResult.total }}</span></p>
<p>Đã tạo: <span class="font-bold text-green-400">{{ syncResult.created }}</span></p>
<p>Đã tạo: <span class="font-bold text-emerald-400">{{ syncResult.created }}</span></p>
<p>Đã cập nhật: <span class="font-bold text-yellow-400">{{ syncResult.updated }}</span></p>
<div v-if="syncResult.sourceStats" class="mt-2">
<p class="font-semibold text-white">Theo nguồn:</p>
@@ -447,13 +425,8 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</div>
<div class="mt-6 flex justify-end gap-3">
<button type="button"
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10"
@click="syncOpen = false">
Huỷ
</button>
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
<button type="button" class="admin-btn-secondary" @click="syncOpen = false">Huỷ</button>
<button type="button" class="admin-btn-primary"
:disabled="syncing || !Object.values(syncSources).some(Boolean)" @click="handleSync">
<AppIcon name="refresh" class="size-4" :class="syncing ? 'animate-spin' : ''" />
{{ syncing ? 'Đang đồng bộ...' : 'Đồng bộ' }}
@@ -466,19 +439,18 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
<Transition name="modal-fade">
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="deleteConfirmOpen = false" />
<div class="relative w-full max-w-sm rounded-xl border border-white/10 bg-slate-900 p-6 shadow-2xl">
<div class="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#131418] p-6 shadow-2xl">
<div class="mb-4 grid size-12 place-items-center rounded-full bg-red-500/10">
<AppIcon name="trash" class="size-6 text-red-400" />
</div>
<h2 class="text-xl font-black text-white">Xoá tất cả phim?</h2>
<p class="mt-2 text-sm leading-6 text-slate-400">
Hành động này sẽ xoá toàn bộ {{ data?.total || 0 }} phim trong hệ thống, bao gồm cả các tuỳ chỉnh. Không thể hoàn tác.
</p>
<div class="mt-6 flex justify-end gap-3">
<button type="button" class="admin-btn-secondary" @click="deleteConfirmOpen = false">Huỷ</button>
<button type="button"
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10"
@click="deleteConfirmOpen = false">
Huỷ
</button>
<button type="button"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-red-500 px-5 text-sm font-black text-white transition hover:bg-red-400 disabled:opacity-50"
class="admin-btn bg-red-500 text-white hover:bg-red-400 disabled:opacity-50"
:disabled="deleting" @click="handleDeleteAll">
<AppIcon name="trash" class="size-4" />
{{ deleting ? 'Đang xoá...' : 'Xoá tất cả' }}
@@ -490,15 +462,3 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
</Teleport>
</div>
</template>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.18s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>