Spaces:
Sleeping
Sleeping
| --- | |
| import AdminLayout from '../../../layouts/AdminLayout.astro'; | |
| import { getNewestMovies, searchMovies, formatImageUrl } from '../../../lib/api.js'; | |
| import { getLocalMovies, getDeletedMovies } from '../../../lib/localDb.js'; | |
| // Kiểm tra phiên đăng nhập và quyền Admin được thực hiện tự động trong AdminLayout | |
| const page = parseInt(Astro.url.searchParams.get('page') || '1'); | |
| const keyword = Astro.url.searchParams.get('keyword') || ''; | |
| const tab = Astro.url.searchParams.get('tab') || 'all'; // all, local, deleted | |
| // Đọc database cục bộ | |
| const localMovies = await getLocalMovies(); | |
| const deletedSlugs = await getDeletedMovies(); | |
| let items = []; | |
| let pagination = { currentPage: page, totalPages: 1, totalItems: 0 }; | |
| if (tab === 'local') { | |
| // Chỉ lấy phim tự tạo hoặc đã chỉnh sửa lưu trong movies.json | |
| const allLocal = Object.values(localMovies); | |
| items = allLocal.map(m => ({ | |
| _id: m.movie.id || m.movie.slug, | |
| name: m.movie.name, | |
| slug: m.movie.slug, | |
| origin_name: m.movie.origin_name, | |
| thumb_url: m.movie.thumb_url, | |
| poster_url: m.movie.poster_url, | |
| year: m.movie.year, | |
| quality: m.movie.quality || 'FHD', | |
| lang: m.movie.lang || 'Vietsub', | |
| episode_current: m.movie.episode_current || 'Tập 1', | |
| isLocal: m.movie.isLocal, | |
| hasLocalEdits: !m.movie.isLocal | |
| })); | |
| // Lọc tìm kiếm trên tập local | |
| if (keyword) { | |
| const k = keyword.toLowerCase(); | |
| items = items.filter(item => | |
| item.name?.toLowerCase().includes(k) || | |
| item.origin_name?.toLowerCase().includes(k) || | |
| item.slug?.includes(k) | |
| ); | |
| } | |
| pagination = { | |
| currentPage: 1, | |
| totalPages: 1, | |
| totalItems: items.length | |
| }; | |
| } else if (tab === 'deleted') { | |
| // Chỉ lấy danh sách các phim đã bị ẩn | |
| items = deletedSlugs.map(slug => { | |
| const local = localMovies[slug]; | |
| return { | |
| _id: slug, | |
| slug, | |
| name: local?.movie?.name || `Phim từ API (${slug})`, | |
| origin_name: local?.movie?.origin_name || 'Đã ẩn', | |
| thumb_url: local?.movie?.thumb_url || '', | |
| year: local?.movie?.year || '', | |
| isDeleted: true | |
| }; | |
| }); | |
| if (keyword) { | |
| const k = keyword.toLowerCase(); | |
| items = items.filter(item => | |
| item.name?.toLowerCase().includes(k) || | |
| item.slug?.includes(k) | |
| ); | |
| } | |
| pagination = { | |
| currentPage: 1, | |
| totalPages: 1, | |
| totalItems: items.length | |
| }; | |
| } else { | |
| // Tab 'all': Lấy từ API và trộn dữ liệu cục bộ qua api.js | |
| if (keyword) { | |
| const result = await searchMovies(keyword, page, { grouped: false }); | |
| items = result.items || []; | |
| pagination = result.pagination || pagination; | |
| } else { | |
| const result = await getNewestMovies(page, { grouped: false }); | |
| items = result.items || []; | |
| pagination = result.pagination || pagination; | |
| } | |
| } | |
| // Thống kê số lượng | |
| const countLocalCustom = Object.values(localMovies).filter(m => m.movie.isLocal).length; | |
| const countLocalEdits = Object.values(localMovies).filter(m => !m.movie.isLocal).length; | |
| const countDeleted = deletedSlugs.length; | |
| const stats = [ | |
| { label: 'Phim tự tạo', value: countLocalCustom, icon: 'fas fa-plus-circle', color: 'text-emerald-500' }, | |
| { label: 'Phim đã sửa', value: countLocalEdits, icon: 'fas fa-edit', color: 'text-amber-500' }, | |
| { label: 'Phim đã ẩn', value: countDeleted, icon: 'fas fa-eye-slash', color: 'text-red-500' }, | |
| ]; | |
| --- | |
| <AdminLayout title="Quản lý phim"> | |
| <div class="mb-12 flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> | |
| <div> | |
| <h1 class="text-4xl font-black tracking-tighter text-white">Quản lý phim</h1> | |
| <p class="mt-2 text-gray-500">Chỉnh sửa, thêm bớt tập phim, ẩn hiện phim và cập nhật nguồn video hàng loạt.</p> | |
| </div> | |
| <div class="flex gap-4"> | |
| <a href="/admin/phim/them-moi" class="flex items-center gap-2 rounded-2xl bg-primary px-6 py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-105 transition-all shadow-[0_0_20px_var(--primary)]"> | |
| <i class="fas fa-plus"></i> | |
| Thêm phim mới | |
| </a> | |
| </div> | |
| </div> | |
| <!-- Thống kê nhanh --> | |
| <div class="grid grid-cols-1 gap-6 sm:grid-cols-3 mb-10"> | |
| {stats.map(stat => ( | |
| <div class="glass rounded-3xl p-6 border border-white/5 relative overflow-hidden group"> | |
| <div class="relative z-10 flex items-center gap-4"> | |
| <div class={`h-12 w-12 rounded-2xl bg-white/5 flex items-center justify-center ${stat.color} text-xl`}> | |
| <i class={stat.icon}></i> | |
| </div> | |
| <div> | |
| <p class="text-[10px] font-black uppercase tracking-widest text-gray-500">{stat.label}</p> | |
| <h3 class="text-2xl font-black text-white mt-1">{stat.value}</h3> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <!-- Search & Tab Filter --> | |
| <div class="mb-8 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> | |
| <!-- Tabs --> | |
| <div class="flex border-b border-white/5"> | |
| <a href="?tab=all" class={`px-6 py-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all ${tab === 'all' ? 'text-primary border-primary' : 'text-gray-500 border-transparent hover:text-white'}`}>Tất cả danh mục</a> | |
| <a href="?tab=local" class={`px-6 py-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all ${tab === 'local' ? 'text-primary border-primary' : 'text-gray-500 border-transparent hover:text-white'}`}>Phim Tự tạo / Đã sửa ({countLocalCustom + countLocalEdits})</a> | |
| <a href="?tab=deleted" class={`px-6 py-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all ${tab === 'deleted' ? 'text-primary border-primary' : 'text-gray-500 border-transparent hover:text-white'}`}>Phim đã ẩn ({countDeleted})</a> | |
| </div> | |
| <!-- Search --> | |
| <form method="GET" class="relative max-w-md w-full"> | |
| <input type="hidden" name="tab" value={tab} /> | |
| <input | |
| type="text" | |
| name="keyword" | |
| placeholder="Tìm phim theo tên, slug..." | |
| value={keyword} | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-primary/50 transition-all pl-12" | |
| /> | |
| <i class="fas fa-search absolute left-5 top-1/2 -translate-y-1/2 text-gray-500"></i> | |
| {keyword && ( | |
| <a href={`?tab=${tab}`} class="absolute right-5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"> | |
| <i class="fas fa-times"></i> | |
| </a> | |
| )} | |
| </form> | |
| </div> | |
| <!-- Movie Catalog Table --> | |
| <div class="glass rounded-[40px] p-6 md:p-10 border border-white/5 overflow-hidden"> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full text-left" id="movies-table"> | |
| <thead> | |
| <tr class="border-b border-white/5"> | |
| <th class="pb-6 text-center w-12"> | |
| <input type="checkbox" id="select-all" class="h-4 w-4 rounded border-white/10 bg-white/5 text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer" /> | |
| </th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Phim</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Năm / Loại</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Trạng thái</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Nguồn gốc</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Hành động</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-white/5"> | |
| {items.length === 0 ? ( | |
| <tr> | |
| <td colspan="6" class="py-12 text-center text-gray-500"> | |
| <i class="fas fa-film text-4xl mb-4 block opacity-30"></i> | |
| Không tìm thấy phim nào phù hợp! | |
| </td> | |
| </tr> | |
| ) : ( | |
| items.map(item => { | |
| const isLocal = item.isLocal; | |
| const hasLocalEdits = localMovies[item.slug] && !isLocal; | |
| const isDeleted = item.isDeleted; | |
| const thumb = item.thumb_url ? formatImageUrl(item.thumb_url) : '/no-cover.jpg'; | |
| const require_login = localMovies[item.slug]?.movie?.require_login === true || localMovies[item.slug]?.movie?.only_login === true; | |
| return ( | |
| <tr class="group hover:bg-white/[0.02] transition-colors" data-slug={item.slug}> | |
| <td class="py-6 text-center"> | |
| <input type="checkbox" class="movie-checkbox h-4 w-4 rounded border-white/10 bg-white/5 text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer" data-slug={item.slug} /> | |
| </td> | |
| <td class="py-6"> | |
| <div class="flex items-center gap-4"> | |
| <div class="h-16 w-12 overflow-hidden rounded-xl bg-white/5 flex-shrink-0 border border-white/5"> | |
| <img src={thumb} class="h-full w-full object-cover" alt="" onerror="this.src='/no-cover.jpg'" /> | |
| </div> | |
| <div class="flex flex-col"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-sm font-bold text-white group-hover:text-primary transition-colors">{item.name}</span> | |
| {require_login && ( | |
| <span class="inline-flex items-center gap-1 rounded bg-violet-500/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wider text-violet-400 border border-violet-500/20" data-txatooltip="Bắt buộc đăng nhập để xem"> | |
| <i class="fas fa-lock text-[8px]"></i> Login | |
| </span> | |
| )} | |
| </div> | |
| <span class="text-xs text-gray-500 line-clamp-1">{item.origin_name}</span> | |
| <span class="text-[9px] text-gray-600 font-mono select-all mt-0.5">{item.slug}</span> | |
| </div> | |
| </div> | |
| </td> | |
| <td class="py-6 text-xs text-gray-400"> | |
| <div class="flex flex-col"> | |
| <span>{item.year || 'N/A'}</span> | |
| <span class="text-[10px] text-gray-600 uppercase tracking-wider font-semibold">{item.type || 'N/A'}</span> | |
| </div> | |
| </td> | |
| <td class="py-6 text-xs font-bold text-gray-300"> | |
| <div class="flex flex-col"> | |
| <span>{item.episode_current || 'N/A'}</span> | |
| </div> | |
| </td> | |
| <td class="py-6"> | |
| {isLocal ? ( | |
| <span class="inline-flex rounded-xl bg-emerald-500/10 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-emerald-400 border border-emerald-500/20">Nội bộ</span> | |
| ) : hasLocalEdits ? ( | |
| <span class="inline-flex rounded-xl bg-amber-500/10 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-amber-400 border border-amber-500/20">Đã sửa</span> | |
| ) : isDeleted ? ( | |
| <span class="inline-flex rounded-xl bg-red-500/10 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-red-400 border border-red-500/20">Đã ẩn</span> | |
| ) : ( | |
| <span class="inline-flex rounded-xl bg-blue-500/10 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-blue-400 border border-blue-500/20">API Gốc</span> | |
| )} | |
| </td> | |
| <td class="py-6 text-right"> | |
| <div class="flex justify-end gap-2"> | |
| {!isDeleted && ( | |
| <a href={`/admin/phim/chinh-sua?slug=${item.slug}`} class="h-9 w-9 rounded-xl bg-white/5 flex items-center justify-center text-gray-400 hover:text-primary transition-colors border border-white/5 active:scale-95" data-txatooltip="Chỉnh sửa toàn bộ phim + tập"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </a> | |
| )} | |
| {isDeleted ? ( | |
| <button class="restore-single-btn h-9 w-9 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 flex items-center justify-center text-emerald-400 transition-colors border border-emerald-500/20 active:scale-95" data-slug={item.slug} data-txatooltip="Khôi phục hiển thị phim"> | |
| <i class="fas fa-eye text-xs"></i> | |
| </button> | |
| ) : ( | |
| <button class="delete-single-btn h-9 w-9 rounded-xl bg-red-500/10 hover:bg-red-500/20 flex items-center justify-center text-red-400 transition-colors border border-red-500/20 active:scale-95" data-slug={item.slug} data-txatooltip="Ẩn / Xóa phim"> | |
| <i class="fas fa-eye-slash text-xs"></i> | |
| </button> | |
| )} | |
| {hasLocalEdits && ( | |
| <button class="reset-single-btn h-9 w-9 rounded-xl bg-amber-500/10 hover:bg-amber-500/20 flex items-center justify-center text-amber-400 transition-colors border border-amber-500/20 active:scale-95" data-slug={item.slug} data-txatooltip="Đặt lại về dữ liệu API gốc"> | |
| <i class="fas fa-undo text-xs"></i> | |
| </button> | |
| )} | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| }) | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Pagination (Only for Tab 'all') --> | |
| {tab === 'all' && pagination.totalPages > 1 && ( | |
| <div class="mt-12 flex flex-wrap items-center justify-between gap-6 border-t border-white/5 pt-8"> | |
| <span class="text-xs text-gray-500"> | |
| Trang <strong>{pagination.currentPage}</strong> / {pagination.totalPages} | |
| </span> | |
| <div class="flex items-center gap-2"> | |
| {pagination.currentPage > 1 && ( | |
| <a href={`?tab=all&page=${pagination.currentPage - 1}&keyword=${keyword}`} class="h-10 px-4 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-xs font-bold text-white border border-white/5 transition-all">Trang trước</a> | |
| )} | |
| <!-- Simple dynamic range --> | |
| {[-2, -1, 0, 1, 2].map(offset => { | |
| const pageNum = pagination.currentPage + offset; | |
| if (pageNum > 0 && pagination.totalPages >= pageNum) { | |
| return ( | |
| <a | |
| href={`?tab=all&page=${pageNum}&keyword=${keyword}`} | |
| class={`h-10 w-10 rounded-xl flex items-center justify-center text-xs font-bold border transition-all ${pageNum === pagination.currentPage ? 'bg-primary border-primary text-dark font-black' : 'bg-white/5 border-white/5 text-gray-400 hover:bg-white/10 hover:text-white'}`} | |
| > | |
| {pageNum} | |
| </a> | |
| ); | |
| } | |
| return null; | |
| })} | |
| {pagination.currentPage < pagination.totalPages && ( | |
| <a href={`?tab=all&page=${pagination.currentPage + 1}&keyword=${keyword}`} class="h-10 px-4 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-xs font-bold text-white border border-white/5 transition-all">Trang tiếp</a> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <!-- FLOATING BULK ACTIONS BAR (PILL STYLE) --> | |
| <div id="bulk-bar" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] w-[95%] max-w-4xl bg-slate-950/90 border border-white/10 rounded-full shadow-[0_20px_60px_rgba(0,0,0,0.9)] backdrop-blur-2xl px-6 py-3 flex flex-wrap items-center justify-between gap-3 transform translate-y-32 opacity-0 transition-all duration-500 ease-out pointer-events-none"> | |
| <div class="flex items-center gap-3 pl-2"> | |
| <div class="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-black shadow-[0_0_15px_rgba(229,9,20,0.3)]" id="bulk-count">0</div> | |
| <span class="text-[11px] font-black uppercase tracking-widest text-gray-300 hidden sm:inline">Phim đã chọn</span> | |
| </div> | |
| <div class="flex flex-wrap items-center gap-2"> | |
| {/* Set nhanh trạng thái */} | |
| <div class="flex items-center gap-1 bg-white/5 rounded-full p-1 border border-white/5"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 px-2">Trạng thái:</span> | |
| <button data-status="ongoing" class="bulk-status-btn px-3 py-1.5 rounded-full text-[9px] font-black uppercase tracking-wider text-amber-400 hover:bg-white/5 transition-all">Đang chiếu</button> | |
| <button data-status="completed" class="bulk-status-btn px-3 py-1.5 rounded-full text-[9px] font-black uppercase tracking-wider text-emerald-400 hover:bg-white/5 transition-all">Hoàn thành</button> | |
| <button data-status="trailer" class="bulk-status-btn px-3 py-1.5 rounded-full text-[9px] font-black uppercase tracking-wider text-blue-400 hover:bg-white/5 transition-all">Sắp chiếu</button> | |
| </div> | |
| {/* Ẩn / Hiện */} | |
| <div class="flex items-center gap-1 bg-white/5 rounded-full p-1 border border-white/5"> | |
| <button id="bulk-hide-btn" class="flex items-center gap-1 px-3 py-1.5 rounded-full text-red-400 hover:bg-red-500/10 text-[9px] font-black uppercase tracking-widest transition-all"> | |
| <i class="fas fa-eye-slash text-[8px]"></i> Ẩn | |
| </button> | |
| <button id="bulk-show-btn" class="flex items-center gap-1 px-3 py-1.5 rounded-full text-emerald-400 hover:bg-emerald-500/10 text-[9px] font-black uppercase tracking-widest transition-all"> | |
| <i class="fas fa-eye text-[8px]"></i> Hiện | |
| </button> | |
| </div> | |
| {/* Yêu cầu đăng nhập */} | |
| <div class="flex items-center gap-1 bg-white/5 rounded-full p-1 border border-white/5"> | |
| <button id="bulk-lock-btn" class="flex items-center gap-1 px-3 py-1.5 rounded-full text-violet-400 hover:bg-violet-500/10 text-[9px] font-black uppercase tracking-widest transition-all" data-txatooltip="Bắt buộc đăng nhập để xem phim"> | |
| <i class="fas fa-lock text-[8px]"></i> Khóa Login | |
| </button> | |
| <button id="bulk-unlock-btn" class="flex items-center gap-1 px-3 py-1.5 rounded-full text-gray-400 hover:bg-white/10 text-[9px] font-black uppercase tracking-widest transition-all" data-txatooltip="Không yêu cầu đăng nhập khi xem phim"> | |
| <i class="fas fa-unlock text-[8px]"></i> Mở tự do | |
| </button> | |
| </div> | |
| {/* Xóa vĩnh viễn */} | |
| <button id="bulk-hard-delete-btn" class="flex items-center gap-1.5 px-4 py-2.5 rounded-full bg-red-600 hover:bg-red-700 text-white text-[9px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95"> | |
| <i class="fas fa-trash-alt"></i> Xóa Hẳn | |
| </button> | |
| {/* Đặt lại gốc (chỉ hiển thị ở tab tương thích) */} | |
| {tab === 'local' && ( | |
| <button id="bulk-reset-btn" class="flex items-center gap-1 px-4 py-2.5 rounded-full bg-amber-500/10 hover:bg-amber-500 text-amber-400 hover:text-white text-[9px] font-black uppercase tracking-widest transition-all border border-amber-500/20"> | |
| <i class="fas fa-undo"></i> Reset Gốc | |
| </button> | |
| )} | |
| <span class="h-4 w-px bg-white/10" /> | |
| <button id="bulk-cancel-btn" class="px-4 py-2.5 rounded-full bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white text-[9px] font-black uppercase tracking-widest transition-all border border-white/5">Hủy</button> | |
| </div> | |
| </div> | |
| </AdminLayout> | |
| <script> | |
| function init() { | |
| const { txamodal, txatoast } = (window as any); | |
| if (!txatoast) return; | |
| // 1. Single Action Buttons | |
| // 1.1 Delete Single | |
| document.querySelectorAll('.delete-single-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const slug = b.getAttribute('data-slug'); | |
| txamodal.show({ | |
| title: 'Xác nhận ẩn phim', | |
| message: `Bạn có chắc chắn muốn ẩn phim <strong>${slug}</strong> khỏi hệ thống? Người xem sẽ không thể tìm thấy hoặc truy cập phim này nữa.`, | |
| type: 'danger', | |
| confirmText: 'Ẩn phim', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/movie/delete', { | |
| method: 'POST', | |
| body: JSON.stringify({ slug }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| txatoast.success(data.message); | |
| setTimeout(() => window.location.reload(), 1000); | |
| return true; | |
| } else { | |
| txatoast.error(data.error); | |
| return false; | |
| } | |
| } catch (e) { | |
| txatoast.error('Gặp lỗi kết nối!'); | |
| return false; | |
| } | |
| } | |
| }); | |
| }; | |
| }); | |
| // 1.2 Restore Single | |
| document.querySelectorAll('.restore-single-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const slug = b.getAttribute('data-slug'); | |
| txamodal.show({ | |
| title: 'Khôi phục hiển thị phim', | |
| message: `Bạn muốn khôi phục hiển thị cho phim <strong>${slug}</strong>? Phim sẽ xuất hiện trở lại trên toàn trang web.`, | |
| type: 'success', | |
| confirmText: 'Khôi phục', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/movie/restore', { | |
| method: 'POST', | |
| body: JSON.stringify({ slug }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| txatoast.success(data.message); | |
| setTimeout(() => window.location.reload(), 1000); | |
| return true; | |
| } else { | |
| txatoast.error(data.error); | |
| return false; | |
| } | |
| } catch (e) { | |
| txatoast.error('Gặp lỗi kết nối!'); | |
| return false; | |
| } | |
| } | |
| }); | |
| }; | |
| }); | |
| // 1.3 Reset Single | |
| document.querySelectorAll('.reset-single-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const slug = b.getAttribute('data-slug'); | |
| txamodal.show({ | |
| title: 'Đặt lại dữ liệu gốc', | |
| message: `Bạn muốn xóa bỏ mọi chỉnh sửa nội bộ của phim <strong>${slug}</strong> và quay về sử dụng dữ liệu mặc định từ API? Việc này không thể hoàn tác.`, | |
| type: 'warning', | |
| confirmText: 'Đặt lại gốc', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/movie/reset', { | |
| method: 'POST', | |
| body: JSON.stringify({ slug }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| txatoast.success(data.message); | |
| setTimeout(() => window.location.reload(), 1000); | |
| return true; | |
| } else { | |
| txatoast.error(data.error); | |
| return false; | |
| } | |
| } catch (e) { | |
| txatoast.error('Gặp lỗi kết nối!'); | |
| return false; | |
| } | |
| } | |
| }); | |
| }; | |
| }); | |
| // 2. Checkbox & Bulk Actions Logic | |
| const selectAllCheckbox = document.getElementById('select-all') as HTMLInputElement; | |
| const checkboxes = document.querySelectorAll('.movie-checkbox') as NodeListOf<HTMLInputElement>; | |
| const bulkBar = document.getElementById('bulk-bar'); | |
| const bulkCount = document.getElementById('bulk-count'); | |
| const updateBulkBar = () => { | |
| const checkedBoxes = Array.from(checkboxes).filter(cb => cb.checked); | |
| const count = checkedBoxes.length; | |
| if (count > 0 && bulkBar && bulkCount) { | |
| bulkCount.innerText = String(count); | |
| bulkBar.classList.remove('translate-y-32', 'opacity-0', 'pointer-events-none'); | |
| bulkBar.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto'); | |
| } else if (bulkBar) { | |
| bulkBar.classList.add('translate-y-32', 'opacity-0', 'pointer-events-none'); | |
| bulkBar.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto'); | |
| } | |
| }; | |
| if (selectAllCheckbox) { | |
| selectAllCheckbox.onchange = () => { | |
| checkboxes.forEach(cb => { | |
| cb.checked = selectAllCheckbox.checked; | |
| }); | |
| updateBulkBar(); | |
| }; | |
| } | |
| checkboxes.forEach(cb => { | |
| cb.onchange = () => { | |
| const checkedBoxes = Array.from(checkboxes).filter(c => c.checked); | |
| if (selectAllCheckbox) selectAllCheckbox.checked = checkedBoxes.length === checkboxes.length; | |
| updateBulkBar(); | |
| }; | |
| }); | |
| const getSelectedSlugs = () => { | |
| return Array.from(checkboxes) | |
| .filter(cb => cb.checked) | |
| .map(cb => cb.getAttribute('data-slug') || ''); | |
| }; | |
| const executeBulkAction = async (action, value = null) => { | |
| const slugs = getSelectedSlugs(); | |
| try { | |
| const res = await fetch('/api/admin/movie/bulk', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ slugs, action, value }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| txatoast.success(data.message); | |
| setTimeout(() => window.location.reload(), 1000); | |
| } else { | |
| txatoast.error(data.error); | |
| } | |
| } catch (e) { | |
| txatoast.error('Gặp lỗi kết nối!'); | |
| } | |
| }; | |
| // 2.1 Bulk Hide (Ẩn) | |
| const bulkHideBtn = document.getElementById('bulk-hide-btn'); | |
| if (bulkHideBtn) { | |
| bulkHideBtn.onclick = () => { | |
| const count = getSelectedSlugs().length; | |
| txamodal.show({ | |
| title: 'Ẩn hàng loạt phim', | |
| message: `Bạn có chắc chắn muốn ẩn <strong>${count}</strong> phim đã chọn khỏi trang chủ?`, | |
| type: 'danger', | |
| confirmText: `Ẩn ${count} phim`, | |
| onConfirm: async () => { | |
| await executeBulkAction('delete'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 2.2 Bulk Show (Khôi phục hiển thị) | |
| const bulkShowBtn = document.getElementById('bulk-show-btn'); | |
| if (bulkShowBtn) { | |
| bulkShowBtn.onclick = () => { | |
| const count = getSelectedSlugs().length; | |
| txamodal.show({ | |
| title: 'Hiện hàng loạt phim', | |
| message: `Bạn muốn khôi phục hiển thị cho <strong>${count}</strong> phim đã chọn?`, | |
| type: 'success', | |
| confirmText: `Khôi phục ${count} phim`, | |
| onConfirm: async () => { | |
| await executeBulkAction('restore'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 2.3 Bulk Hard Delete (Xóa hẳn) | |
| const bulkHardDeleteBtn = document.getElementById('bulk-hard-delete-btn'); | |
| if (bulkHardDeleteBtn) { | |
| bulkHardDeleteBtn.onclick = () => { | |
| const count = getSelectedSlugs().length; | |
| txamodal.show({ | |
| title: 'Xóa vĩnh viễn phim hàng loạt', | |
| message: `Bạn có chắc chắn muốn xóa vĩnh viễn <strong>${count}</strong> phim đã chọn khỏi cơ sở dữ liệu? Hành động này sẽ xóa hoàn toàn và KHÔNG THỂ khôi phục!`, | |
| type: 'danger', | |
| confirmText: `Xóa vĩnh viễn`, | |
| onConfirm: async () => { | |
| await executeBulkAction('hard-delete'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 2.4 Quick Status Set | |
| document.querySelectorAll('.bulk-status-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const status = b.getAttribute('data-status'); | |
| const count = getSelectedSlugs().length; | |
| const statusNames = { completed: 'Hoàn thành', ongoing: 'Đang chiếu', trailer: 'Sắp chiếu' }; | |
| txamodal.show({ | |
| title: 'Cập nhật trạng thái hàng loạt', | |
| message: `Bạn muốn cập nhật trạng thái của <strong>${count}</strong> phim đã chọn thành <strong>"${statusNames[status] || status}"</strong>?`, | |
| type: 'warning', | |
| confirmText: `Cập nhật`, | |
| onConfirm: async () => { | |
| await executeBulkAction('status', status); | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| // 2.5 Bulk Reset (nếu có) | |
| const bulkResetBtn = document.getElementById('bulk-reset-btn'); | |
| if (bulkResetBtn) { | |
| bulkResetBtn.onclick = () => { | |
| const count = getSelectedSlugs().length; | |
| txamodal.show({ | |
| title: 'Đặt lại gốc hàng loạt', | |
| message: `Bạn chắc chắn muốn đặt lại dữ liệu gốc cho <strong>${count}</strong> phim đã chọn? Mọi tùy chỉnh nội bộ của các phim này sẽ bị xóa bỏ.`, | |
| type: 'warning', | |
| confirmText: `Đặt lại gốc`, | |
| onConfirm: async () => { | |
| await executeBulkAction('reset'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 2.7 Bulk Lock (Khóa Login) | |
| const bulkLockBtn = document.getElementById('bulk-lock-btn'); | |
| if (bulkLockBtn) { | |
| bulkLockBtn.onclick = () => { | |
| const count = getSelectedSlugs().length; | |
| txamodal.show({ | |
| title: 'Yêu cầu đăng nhập hàng loạt', | |
| message: `Bạn muốn thiết lập bắt buộc đăng nhập để xem đối với <strong>${count}</strong> phim đã chọn?`, | |
| type: 'warning', | |
| confirmText: `Khóa Login`, | |
| onConfirm: async () => { | |
| await executeBulkAction('require-login'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 2.8 Bulk Unlock (Mở tự do) | |
| const bulkUnlockBtn = document.getElementById('bulk-unlock-btn'); | |
| if (bulkUnlockBtn) { | |
| bulkUnlockBtn.onclick = () => { | |
| const count = getSelectedSlugs().length; | |
| txamodal.show({ | |
| title: 'Mở khóa xem tự do hàng loạt', | |
| message: `Bạn muốn bỏ yêu cầu đăng nhập đối với <strong>${count}</strong> phim đã chọn?`, | |
| type: 'success', | |
| confirmText: `Mở tự do`, | |
| onConfirm: async () => { | |
| await executeBulkAction('free-login'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 2.6 Cancel Bulk | |
| const bulkCancelBtn = document.getElementById('bulk-cancel-btn'); | |
| if (bulkCancelBtn) { | |
| bulkCancelBtn.onclick = () => { | |
| checkboxes.forEach(cb => { cb.checked = false; }); | |
| if (selectAllCheckbox) selectAllCheckbox.checked = false; | |
| updateBulkBar(); | |
| }; | |
| } | |
| } | |
| document.addEventListener('astro:page-load', init); | |
| </script> | |
| <style> | |
| .glass { | |
| background: rgba(18, 20, 29, 0.4); | |
| backdrop-filter: blur(40px) saturate(200%); | |
| } | |
| </style> | |