Spaces:
Sleeping
Sleeping
| --- | |
| import AdminLayout from '../../layouts/AdminLayout.astro'; | |
| import { txasupabase } from '../../lib/txasupabase.js'; | |
| // Kiểm tra quyền truy cập Admin trước khi render | |
| const token = Astro.cookies.get('auth_token')?.value; | |
| let user = null; | |
| if (token) { | |
| try { | |
| user = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); | |
| } catch (e) {} | |
| } | |
| if (!user || user.role !== 'admin') { | |
| return Astro.redirect('/dang-nhap'); | |
| } | |
| // Lấy danh sách bình luận ban đầu từ API | |
| const bannedUsers = await txasupabase.getSetting('banned_commenters') || []; | |
| // Để tối ưu hóa tốc độ, chúng ta load danh sách thông qua Client-side fetch trong script, | |
| // Nhưng chúng ta vẫn truyền danh sách bannedUsers từ Server-side. | |
| --- | |
| <AdminLayout title="Quản lý Bình luận Nhanh"> | |
| <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ý Bình luận</h1> | |
| <p class="mt-2 text-gray-500">Giám sát, kiểm duyệt nội dung phản hồi, gắn thẻ spoil và cấm tài khoản vi phạm chính sách chỉ với 1-click.</p> | |
| </div> | |
| <!-- Stats Cards --> | |
| <div class="flex gap-4"> | |
| <div class="glass border border-white/5 rounded-2xl px-5 py-3 text-left"> | |
| <span class="text-[9px] font-black uppercase tracking-wider text-gray-500">Người dùng bị cấm</span> | |
| <div class="text-lg font-black text-red-500 mt-0.5" id="banned-count">{bannedUsers.length}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="glass rounded-[40px] p-8 md:p-10 border border-white/5 space-y-6 relative"> | |
| <!-- Filters & Actions Area --> | |
| <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between border-b border-white/5 pb-6"> | |
| <!-- Search bar --> | |
| <div class="relative w-full md: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 bình luận theo nội dung, người dùng, slug phim..." | |
| 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> | |
| <!-- Filter pills --> | |
| <div class="flex flex-wrap gap-2"> | |
| <button class="filter-pill px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider bg-primary text-dark border border-primary/20 cursor-pointer" data-filter="all">Tất cả</button> | |
| <button class="filter-pill px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-400 hover:text-white border border-white/5 hover:bg-white/5 cursor-pointer" data-filter="spoiler">Tiết lộ (Spoiler)</button> | |
| <button class="filter-pill px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-400 hover:text-white border border-white/5 hover:bg-white/5 cursor-pointer" data-filter="banned">User bị cấm</button> | |
| </div> | |
| </div> | |
| <!-- Table Area --> | |
| <div class="overflow-x-auto min-h-[400px]"> | |
| <table class="w-full text-left table-auto"> | |
| <thead> | |
| <tr class="border-b border-white/5"> | |
| <th class="pb-6 w-12 text-center"> | |
| <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 w-48">Thành viên</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500 w-44">Phim / Tập</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500">Nội dung bình luận</th> | |
| <th class="pb-6 text-[10px] font-black uppercase tracking-widest text-gray-500 w-36">Thời gian</th> | |
| <th class="pb-6 text-right text-[10px] font-black uppercase tracking-widest text-gray-500 w-28">Thao tác</th> | |
| </tr> | |
| </thead> | |
| <tbody id="table-body" class="divide-y divide-white/5"> | |
| <tr> | |
| <td colspan="6" class="py-20 text-center text-gray-500 text-sm"> | |
| <i class="fas fa-spinner fa-spin text-3xl mb-3 text-primary"></i> | |
| <p class="font-bold">Đang nạp toàn bộ bình luận của hệ thống...</p> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Floating Bulk Action Pill (Viên Thuốc Hành Động Hàng Loạt) --> | |
| <div id="bulk-pill" class="fixed bottom-10 left-1/2 -translate-x-1/2 z-50 transform translate-y-28 opacity-0 pointer-events-none transition-all duration-500 ease-out bg-[#0d0e15]/95 border border-primary/30 rounded-full py-4 px-6 flex items-center gap-6 shadow-[0_0_50px_rgba(124,58,237,0.35)] backdrop-blur-xl"> | |
| <div class="flex items-center gap-2 border-r border-white/10 pr-4"> | |
| <span class="h-2.5 w-2.5 rounded-full bg-primary animate-ping"></span> | |
| <span class="text-xs font-black uppercase tracking-widest text-primary">Đã chọn: <span id="selected-count-pill" class="text-white font-mono font-black text-sm">0</span></span> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button id="pill-delete" class="flex items-center gap-2 rounded-full bg-red-500/10 hover:bg-red-500 border border-red-500/20 text-red-400 hover:text-white px-5 py-2.5 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer" data-txatooltip="Xóa toàn bộ các bình luận đã chọn"> | |
| <i class="fas fa-trash-alt"></i> Xóa | |
| </button> | |
| <button id="pill-spoiler" class="flex items-center gap-2 rounded-full bg-yellow-500/10 hover:bg-yellow-500 border border-yellow-500/20 text-yellow-400 hover:text-white px-5 py-2.5 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer" data-txatooltip="Bật/Tắt chế độ Spoiler hàng loạt"> | |
| <i class="fas fa-exclamation-triangle"></i> Spoiler | |
| </button> | |
| <button id="pill-ban" class="flex items-center gap-2 rounded-full bg-red-600/10 hover:bg-red-600 border border-red-600/20 text-red-500 hover:text-white px-5 py-2.5 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer" data-txatooltip="Cấm viết bình luận hàng loạt các user đã chọn"> | |
| <i class="fas fa-user-slash"></i> Cấm viết | |
| </button> | |
| </div> | |
| <button id="pill-cancel" class="text-gray-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all border border-transparent hover:border-white/10 px-4 py-2 rounded-full cursor-pointer" data-txatooltip="Bỏ chọn tất cả">Hủy</button> | |
| </div> | |
| </AdminLayout> | |
| <script is:inline define:vars={{ initialBanned: bannedUsers }}> | |
| let comments = []; | |
| let bannedUsers = initialBanned || []; | |
| let userProfiles = {}; | |
| let movieMap = {}; | |
| let searchQuery = ''; | |
| let activeFilter = 'all'; // 'all', 'spoiler', 'banned' | |
| // Định dạng thời gian tương đối | |
| function formatRelativeTime(isoString) { | |
| if (!isoString) return ''; | |
| const date = new Date(isoString); | |
| const now = new Date(); | |
| const diffMs = now.getTime() - date.getTime(); | |
| if (isNaN(date.getTime()) || diffMs < 0) return 'Vừa xong'; | |
| const diffSec = Math.floor(diffMs / 1000); | |
| if (diffSec < 60) return 'Vừa xong'; | |
| const diffMin = Math.floor(diffSec / 60); | |
| if (diffMin < 60) return `${diffMin} phút trước`; | |
| const diffHour = Math.floor(diffMin / 60); | |
| if (diffHour < 24) return `${diffHour} giờ trước`; | |
| const day = date.getDate(); | |
| const month = date.getMonth() + 1; | |
| const year = date.getFullYear(); | |
| return `${day}/${month}/${year}`; | |
| } | |
| // Tải danh sách bình luận qua API | |
| async function loadComments() { | |
| try { | |
| const res = await fetch('/api/movie/comments?slug=all'); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| comments = data.comments || []; | |
| bannedUsers = data.bannedUsers || []; | |
| userProfiles = data.userProfiles || {}; | |
| movieMap = data.movieMap || {}; | |
| // Cập nhật số lượng user bị cấm lên UI | |
| const bannedCountEl = document.getElementById('banned-count'); | |
| if (bannedCountEl) bannedCountEl.textContent = bannedUsers.length; | |
| renderTable(); | |
| if (window.txaPollNotifications) { | |
| window.txaPollNotifications(); | |
| } | |
| } else { | |
| const errData = await res.json(); | |
| window.txatoast.error(errData.error || 'Không tải được danh sách bình luận!'); | |
| } | |
| } catch (e) { | |
| window.txatoast.error('Lỗi kết nối API bình luận!'); | |
| } | |
| } | |
| // Render bảng danh sách bình luận | |
| function renderTable() { | |
| const tbody = document.getElementById('table-body'); | |
| if (!tbody) return; | |
| // Lọc theo search query và filter pill | |
| const filtered = comments.filter(c => { | |
| const matchesSearch = | |
| c.content.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| c.username.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| c.movie_slug.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| (movieMap[c.movie_slug] && movieMap[c.movie_slug].toLowerCase().includes(searchQuery.toLowerCase())); | |
| if (!matchesSearch) return false; | |
| if (activeFilter === 'spoiler') return c.is_spoiler; | |
| if (activeFilter === 'banned') return bannedUsers.includes(c.username); | |
| return true; | |
| }); | |
| if (filtered.length === 0) { | |
| tbody.innerHTML = ` | |
| <tr> | |
| <td colspan="6" class="py-20 text-center text-gray-500 text-sm"> | |
| <i class="fas fa-comment-slash text-3xl mb-3 block opacity-20"></i> | |
| <p class="font-bold">Không tìm thấy bình luận nào phù hợp</p> | |
| </td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| tbody.innerHTML = filtered.map(c => { | |
| const isSpoiler = c.is_spoiler; | |
| const isUserBanned = bannedUsers.includes(c.username); | |
| const charCode = c.username ? c.username.charCodeAt(0) : 65; | |
| const avatarColors = [ | |
| 'bg-primary text-dark', 'bg-emerald-500 text-white', 'bg-blue-500 text-white', | |
| 'bg-pink-500 text-white', 'bg-yellow-500 text-dark', 'bg-purple-500 text-white' | |
| ]; | |
| const avatarStyle = avatarColors[charCode % avatarColors.length]; | |
| // Tên phim | |
| const movieName = movieMap[c.movie_slug] || c.movie_slug; | |
| const episodeInfo = c.episode_name ? `${c.episode_name} (${c.server_name || 'Server'})` : 'Chi tiết phim'; | |
| // Nơi ở & Profile popup | |
| const profile = userProfiles[c.username] || { province: '', ward: '', commentsCount: 0, likesCount: 0 }; | |
| let locationStr = 'Chưa cập nhật'; | |
| if (profile.ward || profile.province) { | |
| const parts = []; | |
| if (profile.ward) parts.push(`Xã ${profile.ward}`); | |
| if (profile.province) parts.push(`Tỉnh ${profile.province}`); | |
| locationStr = parts.join(', '); | |
| } | |
| return ` | |
| <tr class="hover:bg-white/[0.01] transition-colors group"> | |
| <td class="py-5 text-center"> | |
| <label class="relative flex items-center justify-center cursor-pointer"> | |
| <input type="checkbox" class="chk-comment peer sr-only" data-id="${c.id}" data-username="${c.username}" /> | |
| <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"> | |
| <div class="flex items-center gap-3"> | |
| <!-- Avatar --> | |
| ${(() => { | |
| const hasAvatar = profile && profile.avatar_url; | |
| const avatarInner = hasAvatar | |
| ? `<img src="${profile.avatar_url}" class="h-full w-full object-cover" />` | |
| : (c.username ? c.username[0] : 'U'); | |
| return ` | |
| <div class="h-8 w-8 rounded-full overflow-hidden flex-shrink-0 flex items-center justify-center font-black uppercase text-xs ${hasAvatar ? '' : avatarStyle}"> | |
| ${avatarInner} | |
| </div> | |
| <!-- Username with hover preview --> | |
| <div class="relative group/usercard text-left"> | |
| <span class="text-xs font-bold text-white hover:text-primary transition-all cursor-pointer">@${c.username}</span> | |
| ${isUserBanned ? '<span class="ml-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-[7px] font-black uppercase tracking-wider text-red-500">Bị cấm</span>' : ''} | |
| <!-- Hover User Card --> | |
| <div class="absolute bottom-full left-0 mb-3 w-60 p-4 rounded-2xl bg-[#090b11]/98 border border-white/10 shadow-2xl backdrop-blur-md opacity-0 pointer-events-none group-hover/usercard:opacity-100 group-hover/usercard:pointer-events-auto transition-all duration-200 z-50 transform translate-y-1 group-hover/usercard:translate-y-0 text-left"> | |
| <div class="absolute -inset-px rounded-2xl bg-gradient-to-r from-primary/10 to-blue-500/10 opacity-40 blur-sm pointer-events-none"></div> | |
| <div class="relative space-y-3"> | |
| <div class="flex items-center gap-2"> | |
| <div class="h-7 w-7 rounded-full overflow-hidden flex items-center justify-center font-black uppercase text-xs ${hasAvatar ? '' : avatarStyle}"> | |
| ${avatarInner} | |
| </div> | |
| <div> | |
| <div class="text-[11px] font-black text-white">@${c.username}</div> | |
| <div class="text-[8px] text-gray-500 font-bold uppercase tracking-wider">Thành viên TPhimX</div> | |
| </div> | |
| </div> | |
| <div class="border-t border-white/5 my-1"></div> | |
| <div class="space-y-1.5 text-[10px] text-gray-400"> | |
| <div>Số bình luận: <strong class="text-white">${profile.commentsCount}</strong></div> | |
| <div>Thích: <strong class="text-white">${profile.likesCount} phim</strong></div> | |
| <div class="truncate">Nơi ở: <strong class="text-white">${locationStr}</strong></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| })()} | |
| </div> | |
| </td> | |
| <td class="py-5"> | |
| <div class="flex flex-col min-w-0"> | |
| <a href="/phim/${c.movie_slug}" target="_blank" class="text-xs font-bold text-gray-300 hover:text-primary transition-all truncate leading-snug" data-txatooltip="Xem chi tiết phim">${movieName}</a> | |
| <span class="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5">${episodeInfo}</span> | |
| </div> | |
| </td> | |
| <td class="py-5 pr-6"> | |
| <div class="flex flex-col gap-1 text-left"> | |
| <p class="text-xs text-gray-300 leading-relaxed font-medium break-all whitespace-pre-wrap">${c.content}</p> | |
| <div class="flex gap-2.5 mt-1 items-center"> | |
| ${isSpoiler ? '<span class="bg-red-500/10 border border-red-500/20 text-[8px] font-black uppercase tracking-wider px-2 py-0.5 rounded text-red-400"><i class="fas fa-exclamation-triangle"></i> Spoiler</span>' : ''} | |
| <span class="text-[10px] text-gray-500 font-semibold flex items-center gap-1"> | |
| <i class="far fa-thumbs-up"></i> ${c.likes?.length || 0} | |
| </span> | |
| <span class="text-[10px] text-gray-500 font-semibold flex items-center gap-1"> | |
| <i class="far fa-thumbs-down"></i> ${c.dislikes?.length || 0} | |
| </span> | |
| </div> | |
| </div> | |
| </td> | |
| <td class="py-5 text-xs text-gray-400 font-medium" data-txatooltip="Timestamp: ${new Date(c.created_at).toLocaleString('vi-VN')}"> | |
| ${formatRelativeTime(c.created_at)} | |
| </td> | |
| <td class="py-5 text-right"> | |
| <div class="flex justify-end gap-1.5"> | |
| <!-- Toggle Spoiler button --> | |
| <button | |
| class="btn-toggle-spoiler h-8 w-8 rounded-lg bg-white/5 hover:bg-yellow-500/20 text-gray-500 hover:text-yellow-400 transition-all border border-white/5 flex items-center justify-center cursor-pointer" | |
| data-id="${c.id}" | |
| data-spoiler="${c.is_spoiler}" | |
| data-txatooltip="${isSpoiler ? 'Gỡ nhãn Spoiler' : 'Đánh dấu Spoiler'}" | |
| > | |
| <i class="fas fa-mask text-[10px]"></i> | |
| </button> | |
| <!-- Ban User button --> | |
| <button | |
| class="btn-ban-user h-8 w-8 rounded-lg bg-white/5 hover:bg-red-500/20 text-gray-500 hover:text-red-500 transition-all border border-white/5 flex items-center justify-center cursor-pointer" | |
| data-username="${c.username}" | |
| data-action="${isUserBanned ? 'unban' : 'ban'}" | |
| data-txatooltip="${isUserBanned ? 'Bỏ cấm bình luận' : 'Chặn bình luận người dùng'}" | |
| > | |
| <i class="fas ${isUserBanned ? 'fa-user-check text-emerald-400' : 'fa-user-slash'} text-[10px]"></i> | |
| </button> | |
| <!-- Delete Comment button --> | |
| <button | |
| class="btn-delete h-8 w-8 rounded-lg bg-white/5 hover:bg-red-500/30 text-gray-500 hover:text-red-400 transition-all border border-white/5 flex items-center justify-center cursor-pointer" | |
| data-id="${c.id}" | |
| data-txatooltip="Xóa bình luận này" | |
| > | |
| <i class="fas fa-trash-alt text-[10px]"></i> | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| bindTableEvents(); | |
| updateBulkPill(); | |
| if (window.initTxaTooltips) { | |
| setTimeout(() => window.initTxaTooltips(), 50); | |
| } | |
| } | |
| // Gắn sự kiện cho các hàng & nút trong bảng | |
| function bindTableEvents() { | |
| // Checkbox items | |
| document.querySelectorAll('.chk-comment').forEach(chk => { | |
| chk.onchange = () => { | |
| updateBulkPill(); | |
| }; | |
| }); | |
| // 1. Toggle Spoiler | |
| document.querySelectorAll('.btn-toggle-spoiler').forEach(btn => { | |
| btn.onclick = async () => { | |
| const commentId = btn.getAttribute('data-id'); | |
| const isCurrentlySpoiler = btn.getAttribute('data-spoiler') === 'true'; | |
| try { | |
| const res = await fetch('/api/admin/movie/edit-comment', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ commentId, action: 'toggle_spoiler', isSpoiler: !isCurrentlySpoiler }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| window.txatoast.success(data.message); | |
| loadComments(); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi cập nhật bình luận!'); | |
| } | |
| }; | |
| }); | |
| // 2. Ban / Unban User | |
| document.querySelectorAll('.btn-ban-user').forEach(btn => { | |
| btn.onclick = () => { | |
| const username = btn.getAttribute('data-username'); | |
| const action = btn.getAttribute('data-action'); | |
| const actionText = action === 'ban' ? 'cấm' : 'mở cấm'; | |
| window.txamodal.show({ | |
| title: `${action === 'ban' ? 'Cấm' : 'Mở cấm'} bình luận`, | |
| message: `Bạn chắc chắn muốn <b>${actionText}</b> thành viên <b>@${username}</b> viết bình luận trên hệ thống?`, | |
| type: action === 'ban' ? 'danger' : 'success', | |
| confirmText: action === 'ban' ? 'Cấm ngay' : 'Mở cấm', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/movie/comments', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action, targetUsername: username }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| window.txatoast.success(data.message); | |
| loadComments(); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Gặp lỗi khi xử lý cấm!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| // 3. Delete Comment | |
| document.querySelectorAll('.btn-delete').forEach(btn => { | |
| btn.onclick = () => { | |
| const commentId = btn.getAttribute('data-id'); | |
| window.txamodal.show({ | |
| title: 'Xóa bình luận', | |
| message: 'Bạn có chắc chắn muốn xóa vĩnh viễn bình luận này? Hành động này không thể khôi phục lại.', | |
| type: 'danger', | |
| confirmText: 'Xóa ngay', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/movie/comments', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ commentId }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| window.txatoast.success(data.message); | |
| loadComments(); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi khi xóa bình luận!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| } | |
| // Cập nhật hiển thị Viên Thuốc (Bulk Action Pill) | |
| function updateBulkPill() { | |
| const checked = document.querySelectorAll('.chk-comment:checked'); | |
| const totalCount = document.querySelectorAll('.chk-comment').length; | |
| const chkAll = document.getElementById('chk-all'); | |
| const pill = document.getElementById('bulk-pill'); | |
| const countSpan = document.getElementById('selected-count-pill'); | |
| if (chkAll) { | |
| chkAll.checked = totalCount > 0 && checked.length === totalCount; | |
| } | |
| if (checked.length > 0) { | |
| if (pill) { | |
| pill.classList.remove('translate-y-28', 'opacity-0', 'pointer-events-none'); | |
| pill.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto'); | |
| } | |
| if (countSpan) countSpan.textContent = checked.length; | |
| } else { | |
| if (pill) { | |
| pill.classList.add('translate-y-28', 'opacity-0', 'pointer-events-none'); | |
| pill.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto'); | |
| } | |
| } | |
| } | |
| // Khởi tạo các sự kiện toàn trang | |
| function init() { | |
| loadComments(); | |
| // Tìm kiếm | |
| const searchInput = document.getElementById('search-input'); | |
| if (searchInput) { | |
| searchInput.oninput = (e) => { | |
| searchQuery = e.target.value.trim(); | |
| renderTable(); | |
| }; | |
| } | |
| // Filter pills | |
| document.querySelectorAll('.filter-pill').forEach(pill => { | |
| pill.onclick = () => { | |
| document.querySelectorAll('.filter-pill').forEach(p => { | |
| p.className = 'filter-pill px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-400 hover:text-white border border-white/5 hover:bg-white/5 cursor-pointer'; | |
| }); | |
| pill.className = 'filter-pill px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider bg-primary text-dark border border-primary/20 cursor-pointer'; | |
| activeFilter = pill.getAttribute('data-filter'); | |
| renderTable(); | |
| }; | |
| }); | |
| // Checkbox All | |
| const chkAll = document.getElementById('chk-all'); | |
| if (chkAll) { | |
| chkAll.onchange = () => { | |
| document.querySelectorAll('.chk-comment').forEach(chk => { | |
| chk.checked = chkAll.checked; | |
| }); | |
| updateBulkPill(); | |
| }; | |
| } | |
| // --- SỰ KIỆN VIÊN THUỐC HÀNG LOẠT (BULK PILL ACTIONS) --- | |
| // 1. Cancel Selection | |
| const pillCancel = document.getElementById('pill-cancel'); | |
| if (pillCancel) { | |
| pillCancel.onclick = () => { | |
| document.querySelectorAll('.chk-comment').forEach(chk => chk.checked = false); | |
| if (chkAll) chkAll.checked = false; | |
| updateBulkPill(); | |
| }; | |
| } | |
| // 2. Bulk Delete | |
| const pillDelete = document.getElementById('pill-delete'); | |
| if (pillDelete) { | |
| pillDelete.onclick = () => { | |
| const checked = document.querySelectorAll('.chk-comment:checked'); | |
| const commentIds = Array.from(checked).map(chk => chk.getAttribute('data-id')); | |
| window.txamodal.show({ | |
| title: 'Xóa hàng loạt bình luận', | |
| message: `Bạn chắc chắn muốn xóa vĩnh viễn <b>${commentIds.length}</b> bình luận đã chọn?`, | |
| type: 'danger', | |
| confirmText: 'Xóa tất cả', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/movie/edit-comment', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ commentIds, action: 'bulk_delete' }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| window.txatoast.success(data.message); | |
| loadComments(); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi khi xóa hàng loạt!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // 3. Bulk Spoiler | |
| const pillSpoiler = document.getElementById('pill-spoiler'); | |
| if (pillSpoiler) { | |
| pillSpoiler.onclick = () => { | |
| const checked = document.querySelectorAll('.chk-comment:checked'); | |
| const commentIds = Array.from(checked).map(chk => chk.getAttribute('data-id')); | |
| window.txamodal.show({ | |
| title: 'Bật/Tắt nhãn Spoiler hàng loạt', | |
| message: `Lựa chọn hành động áp dụng cho <b>${commentIds.length}</b> bình luận đã chọn:`, | |
| type: 'warning', | |
| confirmText: 'Bật Spoiler', | |
| cancelText: 'Tắt Spoiler', | |
| onConfirm: async () => { | |
| // Confirm -> Bật Spoiler | |
| await applyBulkSpoiler(commentIds, true); | |
| return true; | |
| }, | |
| onCancel: async () => { | |
| // Cancel -> Tắt Spoiler | |
| await applyBulkSpoiler(commentIds, false); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| async function applyBulkSpoiler(commentIds, isSpoiler) { | |
| try { | |
| const res = await fetch('/api/admin/movie/edit-comment', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ commentIds, action: 'bulk_spoiler', isSpoiler }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| window.txatoast.success(data.message); | |
| loadComments(); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi khi cập nhật spoiler hàng loạt!'); | |
| } | |
| } | |
| // 4. Bulk Ban User | |
| const pillBan = document.getElementById('pill-ban'); | |
| if (pillBan) { | |
| pillBan.onclick = () => { | |
| const checked = document.querySelectorAll('.chk-comment:checked'); | |
| const usernames = [...new Set(Array.from(checked).map(chk => chk.getAttribute('data-username')))]; | |
| window.txamodal.show({ | |
| title: 'Cấm bình luận hàng loạt', | |
| message: `Bạn chắc chắn muốn cấm toàn bộ <b>${usernames.length}</b> thành viên viết bình luận trên hệ thống?`, | |
| type: 'danger', | |
| confirmText: 'Chặn tất cả', | |
| onConfirm: async () => { | |
| try { | |
| const res = await fetch('/api/admin/movie/edit-comment', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ usernames, action: 'bulk_ban' }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| window.txatoast.success(data.message); | |
| loadComments(); | |
| } else { | |
| window.txatoast.error(data.error); | |
| } | |
| } catch(e) { | |
| window.txatoast.error('Lỗi chặn hàng loạt!'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| } | |
| document.addEventListener('astro:page-load', init); | |
| </script> | |
| <style> | |
| .glass { | |
| background: rgba(18, 20, 29, 0.4); | |
| backdrop-filter: blur(40px) saturate(200%); | |
| } | |
| .glass-sub { | |
| background: rgba(255, 255, 255, 0.01); | |
| } | |
| /* Bouncing ease out animation */ | |
| .ease-out-back { | |
| transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); | |
| } | |
| </style> | |