Spaces:
Sleeping
Sleeping
| --- | |
| import AdminLayout from '../../layouts/AdminLayout.astro'; | |
| import { getGenres } from '../../lib/localDb.js'; | |
| const genres = await getGenres(); | |
| --- | |
| <AdminLayout title="Quản lý Thể loại"> | |
| <div class="mb-12 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> | |
| <div> | |
| <h1 class="text-4xl font-black tracking-tighter text-white">Quản lý Thể loại</h1> | |
| <p class="mt-2 text-gray-500">Danh mục thể loại phim dùng để phân loại và lọc tìm kiếm trên hệ thống.</p> | |
| </div> | |
| <div class="flex flex-wrap gap-3"> | |
| <button id="sync-genres-btn" class="flex items-center gap-2 rounded-2xl bg-emerald-500/10 hover:bg-emerald-500 text-emerald-400 hover:text-white border border-emerald-500/20 px-6 py-3.5 text-xs font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer"> | |
| <i class="fas fa-sync-alt"></i> | |
| Đồng bộ từ Phim | |
| </button> | |
| <button id="create-genre-btn" class="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3.5 text-xs font-black uppercase tracking-widest text-dark hover:scale-105 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer"> | |
| <i class="fas fa-plus"></i> | |
| Thêm Thể loại mới | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Bulk Action Bar --> | |
| <div id="bulk-bar" class="hidden glass rounded-3xl p-6 border border-primary/20 mb-8 items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <span class="text-xs font-black uppercase tracking-widest text-primary"><i class="fas fa-check-square"></i> Đã chọn <span id="selected-count">0</span> thể loại</span> | |
| </div> | |
| <div class="flex gap-2"> | |
| <button id="bulk-delete-btn" class="flex items-center gap-2 rounded-xl bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white border border-red-500/20 px-4 py-2.5 text-xs font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer"> | |
| <i class="fas fa-trash"></i> Xóa hàng loạt | |
| </button> | |
| </div> | |
| </div> | |
| <div class="glass rounded-[40px] p-8 md:p-10 border border-white/5 space-y-6"> | |
| <!-- Filter section --> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-6"> | |
| <div class="relative w-full max-w-md"> | |
| <i class="fas fa-search absolute left-5 top-1/2 -translate-y-1/2 text-gray-500 text-sm"></i> | |
| <input type="text" id="search-input" placeholder="Tìm kiếm tên hoặc slug..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl pl-12 pr-5 py-3.5 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full text-left"> | |
| <thead> | |
| <tr class="border-b border-white/5"> | |
| <th class="pb-6 w-12"> | |
| <label class="relative flex items-center justify-center cursor-pointer"> | |
| <input type="checkbox" id="chk-all" class="peer sr-only" /> | |
| <div class="h-5 w-5 rounded-lg border border-white/10 bg-[#12141d] flex items-center justify-center transition-all peer-checked:bg-primary peer-checked:border-primary"> | |
| <i class="fas fa-check text-[10px] text-dark opacity-0 peer-checked:opacity-100 transition-opacity"></i> | |
| </div> | |
| </label> | |
| </th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Tên Thể loại</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Slug</th> | |
| <th class="pb-6 text-right text-[10px] font-black uppercase tracking-widest text-gray-500">Thao tác</th> | |
| </tr> | |
| </thead> | |
| <tbody id="table-body" class="divide-y divide-white/5"> | |
| <!-- Rendered via Javascript --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </AdminLayout> | |
| <script is:inline define:vars={{ initialGenres: genres }}> | |
| let genres = initialGenres || []; | |
| let searchQuery = ''; | |
| // Hàm chuyển tiếng Việt có dấu thành slug | |
| function toSlug(str) { | |
| str = str.toLowerCase().trim(); | |
| str = str.replace(/à|á|ạ|ả|ã|â|ầ|ấ|ậ|ẩ|ẫ|ă|ằ|ắ|ặ|ẳ|ẵ/g, "a"); | |
| str = str.replace(/è|é|ẹ|ẻ|ẽ|ê|ề|ế|ệ|ể|ễ/g, "e"); | |
| str = str.replace(/ì|í|ị|ỉ|ĩ/g, "i"); | |
| str = str.replace(/ò|á|ọ|ỏ|õ|ô|ồ|ố|ộ|ổ|ỗ|ơ|ờ|ớ|ợ|ở|ỡ/g, "o"); | |
| str = str.replace(/ù|ú|ụ|ủ|ũ|ư|ừ|ứ|ự|ử|ữ/g, "u"); | |
| str = str.replace(/ỳ|ý|ỵ|ỷ|ỹ/g, "y"); | |
| str = str.replace(/đ/g, "d"); | |
| str = str.replace(/[^a-z0-9 -]/g, ""); // Xóa ký tự đặc biệt | |
| str = str.replace(/\s+/g, "-"); // Thay khoảng trắng bằng - | |
| str = str.replace(/-+/g, "-"); // Xóa nhiều - liên tiếp | |
| return str; | |
| } | |
| function renderTable() { | |
| const tbody = document.getElementById('table-body'); | |
| if (!tbody) return; | |
| const filtered = genres.filter(g => | |
| g.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| g.slug.toLowerCase().includes(searchQuery.toLowerCase()) | |
| ); | |
| if (filtered.length === 0) { | |
| tbody.innerHTML = ` | |
| <tr> | |
| <td colspan="4" class="py-12 text-center text-gray-500 text-sm"> | |
| <i class="fas fa-tags text-3xl mb-3 block opacity-20"></i> | |
| Không tìm thấy thể loại nào | |
| </td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| tbody.innerHTML = filtered.map(g => ` | |
| <tr class="hover:bg-white/[0.01] transition-colors group"> | |
| <td class="py-5"> | |
| <label class="relative flex items-center justify-center cursor-pointer"> | |
| <input type="checkbox" class="chk-item peer sr-only" data-slug="${g.slug}" /> | |
| <div class="h-5 w-5 rounded-lg border border-white/10 bg-[#12141d] flex items-center justify-center transition-all peer-checked:bg-primary peer-checked:border-primary"> | |
| <i class="fas fa-check text-[10px] text-dark opacity-0 peer-checked:opacity-100 transition-opacity"></i> | |
| </div> | |
| </label> | |
| </td> | |
| <td class="py-5 text-sm font-bold text-white">${g.name}</td> | |
| <td class="py-5 text-xs text-gray-400 font-mono">${g.slug}</td> | |
| <td class="py-5 text-right"> | |
| <div class="flex justify-end gap-2"> | |
| <button class="edit-btn h-9 w-9 rounded-xl bg-white/5 hover:bg-primary/20 text-gray-400 hover:text-primary transition-all border border-white/5 flex items-center justify-center" data-slug="${g.slug}" data-name="${g.name}"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </button> | |
| <button class="delete-btn h-9 w-9 rounded-xl bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-500 transition-all border border-white/5 flex items-center justify-center" data-slug="${g.slug}" data-name="${g.name}"> | |
| <i class="fas fa-trash text-xs"></i> | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| `).join(''); | |
| bindEvents(); | |
| updateBulkBar(); | |
| } | |
| function bindEvents() { | |
| // Edit Genre | |
| document.querySelectorAll('.edit-btn').forEach(btn => { | |
| btn.onclick = () => { | |
| const slug = btn.getAttribute('data-slug'); | |
| const name = btn.getAttribute('data-name'); | |
| window.txamodal.show({ | |
| title: 'Sửa Thể loại', | |
| message: ` | |
| <div class="space-y-4 text-left"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Tên thể loại</label> | |
| <input type="text" id="edit-genre-name" value="${name}" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Slug (Đường dẫn tĩnh)</label> | |
| <input type="text" id="edit-genre-slug" value="${slug}" readonly class="w-full bg-white/5 border border-white/5 rounded-xl px-4 py-2.5 text-sm text-gray-400 cursor-not-allowed focus:outline-none" /> | |
| </div> | |
| </div>`, | |
| type: 'warning', | |
| confirmText: 'Lưu thay đổi', | |
| onConfirm: async () => { | |
| const nameInput = document.getElementById('edit-genre-name') as HTMLInputElement; | |
| const nameVal = nameInput ? nameInput.value.trim() : ''; | |
| if (!nameVal) { | |
| window.txatoast.error('Tên không được để trống!'); | |
| return false; | |
| } | |
| try { | |
| const res = await fetch('/api/admin/genres', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ slug, name: nameVal }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| genres = data.list; | |
| renderTable(); | |
| window.txatoast.success(data.message); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi kết nối máy chủ!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| // Delete Genre | |
| document.querySelectorAll('.delete-btn').forEach(btn => { | |
| btn.onclick = () => { | |
| const slug = btn.getAttribute('data-slug'); | |
| const name = btn.getAttribute('data-name'); | |
| window.txamodal.show({ | |
| title: 'Xác nhận xóa thể loại', | |
| message: `Bạn có chắc chắn muốn xóa thể loại <b>${name}</b>? Việc này sẽ gỡ thể loại khỏi các phim liên quan.`, | |
| type: 'error', | |
| confirmText: 'Xóa ngay', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/genres', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ slug }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| genres = data.list; | |
| renderTable(); | |
| window.txatoast.success(data.message); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi kết nối máy chủ!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| document.querySelectorAll('.chk-item').forEach(chk => { | |
| chk.onchange = () => { | |
| updateBulkBar(); | |
| }; | |
| }); | |
| } | |
| function updateBulkBar() { | |
| const chkAll = document.getElementById('chk-all'); | |
| const checked = document.querySelectorAll('.chk-item:checked'); | |
| const totalCount = document.querySelectorAll('.chk-item').length; | |
| const bulkBar = document.getElementById('bulk-bar'); | |
| const selectedCount = document.getElementById('selected-count'); | |
| if (chkAll) { | |
| chkAll.checked = totalCount > 0 && checked.length === totalCount; | |
| } | |
| if (checked.length > 0) { | |
| if (bulkBar) { | |
| bulkBar.style.display = 'flex'; | |
| } | |
| if (selectedCount) selectedCount.textContent = checked.length; | |
| } else { | |
| if (bulkBar) { | |
| bulkBar.style.display = 'none'; | |
| } | |
| } | |
| } | |
| function init() { | |
| const searchInput = document.getElementById('search-input'); | |
| if (searchInput) { | |
| searchInput.oninput = (e) => { | |
| searchQuery = e.target.value.trim(); | |
| renderTable(); | |
| }; | |
| } | |
| const chkAll = document.getElementById('chk-all'); | |
| if (chkAll) { | |
| chkAll.onchange = () => { | |
| document.querySelectorAll('.chk-item').forEach(chk => { | |
| chk.checked = chkAll.checked; | |
| }); | |
| updateBulkBar(); | |
| }; | |
| } | |
| // Create Genre | |
| const createBtn = document.getElementById('create-genre-btn'); | |
| if (createBtn) { | |
| createBtn.onclick = () => { | |
| window.txamodal.show({ | |
| title: 'Thêm Thể loại mới', | |
| message: ` | |
| <div class="space-y-4 text-left"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Tên thể loại</label> | |
| <input type="text" id="new-genre-name" placeholder="Ví dụ: Cổ Trang" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Slug (Tự động sinh)</label> | |
| <input type="text" id="new-genre-slug" placeholder="co-trang" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| </div>`, | |
| type: 'success', | |
| confirmText: 'Tạo thể loại', | |
| onConfirm: async () => { | |
| const nameInput = document.getElementById('new-genre-name') as HTMLInputElement; | |
| const slugInput = document.getElementById('new-genre-slug') as HTMLInputElement; | |
| const name = nameInput ? nameInput.value.trim() : ''; | |
| const slug = slugInput ? slugInput.value.trim() : ''; | |
| if (!name || !slug) { | |
| window.txatoast.error('Vui lòng điền đủ Tên và Slug!'); | |
| return false; | |
| } | |
| try { | |
| const res = await fetch('/api/admin/genres', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ slug, name }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| genres = data.list; | |
| renderTable(); | |
| window.txatoast.success(data.message); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi kết nối máy chủ!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| // Auto-generate slug on typing name in modal | |
| setTimeout(() => { | |
| const nameInput = document.getElementById('new-genre-name'); | |
| const slugInput = document.getElementById('new-genre-slug'); | |
| if (nameInput && slugInput) { | |
| nameInput.addEventListener('input', (e) => { | |
| slugInput.value = toSlug(e.target.value); | |
| }); | |
| } | |
| }, 100); | |
| }; | |
| } | |
| // Sync Genres | |
| const syncBtn = document.getElementById('sync-genres-btn'); | |
| if (syncBtn) { | |
| syncBtn.onclick = async () => { | |
| window.txatoast.info('Đang đồng bộ thể loại từ kho phim...'); | |
| try { | |
| const res = await fetch('/api/admin/genres', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ sync: true }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| genres = data.list; | |
| renderTable(); | |
| window.txamodal.show({ | |
| title: 'Đồng bộ thể loại thành công', | |
| message: data.message, | |
| type: 'success' | |
| }); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi đồng bộ thể loại!'); | |
| } | |
| }; | |
| } | |
| // Bulk Delete | |
| const bulkDeleteBtn = document.getElementById('bulk-delete-btn'); | |
| if (bulkDeleteBtn) { | |
| bulkDeleteBtn.onclick = () => { | |
| const checked = document.querySelectorAll('.chk-item:checked'); | |
| const slugs = Array.from(checked).map(c => c.getAttribute('data-slug')); | |
| window.txamodal.show({ | |
| title: 'Xác nhận xóa hàng loạt', | |
| message: `Bạn có chắc chắn muốn xóa <b>${slugs.length}</b> thể loại đã chọn?`, | |
| type: 'error', | |
| confirmText: 'Xóa tất cả', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/genres', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ slugs }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| genres = data.list; | |
| renderTable(); | |
| window.txatoast.success(data.message); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi kết nối máy chủ!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| renderTable(); | |
| } | |
| document.addEventListener('astro:page-load', init); | |
| </script> | |
| <style> | |
| .glass { | |
| background: rgba(18, 20, 29, 0.4); | |
| backdrop-filter: blur(40px) saturate(200%); | |
| } | |
| </style> | |