Spaces:
Sleeping
Sleeping
| /** | |
| * TXA Comment Widget - TPHIMX Cinematic Platform | |
| * Tự động hóa khung bình luận cao cấp. | |
| */ | |
| class TxaComment { | |
| constructor(containerId) { | |
| this.container = document.getElementById(containerId); | |
| if (!this.container) return; | |
| this.movieSlug = this.container.getAttribute('data-movie-slug'); | |
| this.movieType = this.container.getAttribute('data-movie-type') || null; | |
| this.episodeName = this.container.getAttribute('data-episode-name') || null; | |
| this.serverName = this.container.getAttribute('data-server-name') || null; | |
| const userAttr = this.container.getAttribute('data-user'); | |
| this.user = userAttr ? JSON.parse(userAttr) : null; | |
| this.comments = []; | |
| this.bannedUsers = []; | |
| this.init(); | |
| } | |
| async init() { | |
| this.renderSkeleton(); | |
| await this.fetchComments(); | |
| this.renderCommentsList(); | |
| this.setupListeners(); | |
| } | |
| renderSkeleton() { | |
| const isLogged = !!this.user; | |
| this.container.innerHTML = ` | |
| <div class="space-y-6 text-left"> | |
| <!-- Heading --> | |
| <div class="flex items-center gap-3"> | |
| <i class="fas fa-comment-dots text-primary text-xl"></i> | |
| <h3 class="text-lg font-black text-white">Bình luận (<span id="txac-count">0</span>)</h3> | |
| </div> | |
| <!-- Input Area --> | |
| <div class="glass-sub rounded-3xl p-5 border border-white/5 space-y-4"> | |
| ${isLogged ? ` | |
| <div class="relative"> | |
| <span class="absolute right-3 top-3 text-[10px] text-gray-500 font-mono" id="txac-char-counter">0 / 1000</span> | |
| <textarea | |
| id="txac-textarea" | |
| maxlength="1000" | |
| placeholder="Viết bình luận..." | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 pt-10 text-sm text-white focus:outline-none focus:border-primary/50 transition-all resize-none min-h-[100px]" | |
| ></textarea> | |
| </div> | |
| <div class="flex items-center justify-between pt-2"> | |
| <!-- Spoiler Toggle --> | |
| <div class="flex items-center gap-3"> | |
| <label class="relative inline-flex items-center cursor-pointer select-none"> | |
| <input type="checkbox" id="txac-spoiler" class="sr-only peer" /> | |
| <div class="w-9 h-5 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-gray-300 after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div> | |
| </label> | |
| <span class="text-xs text-gray-400 font-bold select-none">Tiết lộ?</span> | |
| </div> | |
| <!-- Send button --> | |
| <button id="txac-submit-btn" class="flex items-center gap-2 rounded-xl bg-primary/10 hover:bg-primary text-primary hover:text-dark border border-primary/20 hover:border-primary px-5 py-2.5 text-xs font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer"> | |
| Gửi <i class="fas fa-paper-plane text-[10px]"></i> | |
| </button> | |
| </div> | |
| ` : ` | |
| <div class="py-8 text-center text-xs text-gray-500"> | |
| Vui lòng <a href="/dang-nhap" class="text-primary font-bold hover:underline">đăng nhập</a> để tham gia bình luận. | |
| </div> | |
| `} | |
| </div> | |
| <!-- Comments list --> | |
| <div id="txac-list" class="space-y-4 pt-4 divide-y divide-white/5"> | |
| <div class="text-center text-xs text-gray-500 py-6">Đang tải bình luận...</div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| async fetchComments() { | |
| try { | |
| const res = await fetch(`/api/movie/comments?slug=${this.movieSlug}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| this.comments = data.comments || []; | |
| this.bannedUsers = data.bannedUsers || []; | |
| this.userProfiles = data.userProfiles || {}; | |
| } | |
| } catch (e) { | |
| console.error('[TxaComment] Error fetching comments:', e); | |
| } | |
| } | |
| renderCommentsList() { | |
| const listContainer = document.getElementById('txac-list'); | |
| const countSpan = document.getElementById('txac-count'); | |
| if (countSpan) { | |
| const totalCount = this.comments.length; | |
| countSpan.textContent = window.txaformat ? window.txaformat.twoDigits(totalCount) : (totalCount < 10 ? '0' + totalCount : totalCount); | |
| } | |
| if (!listContainer) return; | |
| if (this.comments.length === 0) { | |
| listContainer.innerHTML = ` | |
| <div class="text-center text-xs text-gray-500 py-8"> | |
| Chưa có bình luận nào. Hãy là người đầu tiên chia sẻ cảm nghĩ! | |
| </div> | |
| `; | |
| return; | |
| } | |
| listContainer.innerHTML = this.comments.map(c => { | |
| 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]; | |
| // Spoiler Logic | |
| const isSpoiler = c.is_spoiler; | |
| const commentBody = isSpoiler | |
| ? `<div class="relative bg-red-500/5 border border-red-500/10 rounded-xl p-3 text-xs text-gray-400 mt-1 cursor-pointer spoiler-blur" onclick="this.classList.remove('spoiler-blur')"> | |
| <span class="block text-[9px] font-black uppercase text-red-400 tracking-wider mb-1"><i class="fas fa-exclamation-triangle"></i> Bình luận có chứa tình tiết phim (Click để xem)</span> | |
| <span>${c.content}</span> | |
| </div>` | |
| : `<p class="text-sm text-gray-300 leading-relaxed mt-1">${c.content}</p>`; | |
| // Episode Tag (If commented from watch page) | |
| const episodeTag = (c.episode_name && c.server_name) | |
| ? `<span class="ml-2 bg-primary/10 text-primary border border-primary/20 text-[9px] font-black uppercase tracking-wider px-2 py-0.5 rounded-lg">${c.episode_name} - ${c.server_name}</span>` | |
| : ''; | |
| // Likes/Dislikes Arrays | |
| const likes = c.likes || []; | |
| const dislikes = c.dislikes || []; | |
| const hasLiked = this.user && likes.includes(this.user.username); | |
| const hasDisliked = this.user && dislikes.includes(this.user.username); | |
| // Actions Check (Delete comment) | |
| const canDelete = this.user && (c.username === this.user.username || this.user.role === 'admin'); | |
| const deleteButton = canDelete | |
| ? `<button class="txac-delete-btn text-gray-500 hover:text-red-400 transition-all flex items-center justify-center h-6 w-6 rounded-lg bg-white/5 hover:bg-red-500/10 cursor-pointer select-none ml-2" data-id="${c.id}" data-txatooltip="Xóa bình luận"> | |
| <i class="fas fa-trash-alt text-[10px]"></i> | |
| </button>` | |
| : ''; | |
| // Ban Button (For Admin ONLY) | |
| const isBanned = this.bannedUsers.includes(c.username); | |
| const showBanBtn = this.user && this.user.role === 'admin' && c.username !== this.user.username; | |
| const banButton = showBanBtn | |
| ? (isBanned | |
| ? `<button class="txac-ban-btn text-emerald-400 hover:text-emerald-300 transition-all flex items-center justify-center h-6 w-6 rounded-lg bg-white/5 hover:bg-emerald-500/10 cursor-pointer select-none ml-auto animate-pulse" data-username="${c.username}" data-action="unban" data-txatooltip="Bỏ cấm bình luận @${c.username}"> | |
| <i class="fas fa-user-check text-[10px]"></i> | |
| </button>` | |
| : `<button class="txac-ban-btn text-red-500 hover:text-red-400 transition-all flex items-center justify-center h-6 w-6 rounded-lg bg-white/5 hover:bg-red-500/10 cursor-pointer select-none ml-auto" data-username="${c.username}" data-action="ban" data-txatooltip="Cấm bình luận @${c.username} (1 click)"> | |
| <i class="fas fa-user-slash text-[10px]"></i> | |
| </button>` | |
| ) | |
| : ''; | |
| // Lấy thông tin profile phục vụ cho hover popup | |
| const profile = (this.userProfiles && this.userProfiles[c.username]) || { | |
| commentsCount: 0, | |
| likesCount: 0, | |
| ward: '', | |
| province: '' | |
| }; | |
| 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(', '); | |
| locationStr = locationStr.charAt(0).toUpperCase() + locationStr.slice(1); | |
| } | |
| const formattedComments = window.txaformat ? window.txaformat.twoDigits(profile.commentsCount) : (profile.commentsCount < 10 ? '0' + profile.commentsCount : profile.commentsCount); | |
| const formattedLikes = window.txaformat ? window.txaformat.twoDigits(profile.likesCount) : (profile.likesCount < 10 ? '0' + profile.likesCount : profile.likesCount); | |
| // Check if user has custom avatar URL | |
| 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="pt-5 flex gap-4 items-start group"> | |
| <!-- Avatar --> | |
| <div class="h-9 w-9 rounded-full overflow-hidden flex-shrink-0 flex items-center justify-center font-black uppercase text-xs ${hasAvatar ? '' : avatarStyle}"> | |
| ${avatarInner} | |
| </div> | |
| <!-- Content --> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex items-center gap-2"> | |
| <!-- Username với hover popup info --> | |
| <div class="relative inline-block group/usercard"> | |
| <span class="text-xs font-bold text-white hover:text-primary transition-all cursor-pointer">@${c.username}</span> | |
| <!-- Hover Card Popup --> | |
| <div class="absolute bottom-full left-0 mb-3 w-64 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-3"> | |
| <div class="h-8 w-8 rounded-full overflow-hidden flex items-center justify-center font-black uppercase text-xs ${hasAvatar ? '' : avatarStyle}"> | |
| ${avatarInner} | |
| </div> | |
| <div> | |
| <div class="text-xs font-black text-white">@${c.username}</div> | |
| <div class="text-[9px] 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-2 text-[11px] text-gray-400"> | |
| <div class="flex items-center gap-2"> | |
| <i class="fas fa-comment text-primary w-4 text-center"></i> | |
| <span>Số bình luận: <strong class="text-white">${formattedComments}</strong></span> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <i class="fas fa-heart text-red-500 w-4 text-center"></i> | |
| <span>Đã thích: <strong class="text-white">${formattedLikes} phim</strong></span> | |
| </div> | |
| <div class="flex items-start gap-2"> | |
| <i class="fas fa-map-marker-alt text-emerald-400 w-4 text-center mt-0.5"></i> | |
| <span class="leading-relaxed">Nơi ở: <strong class="text-white">${locationStr}</strong></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ${episodeTag} | |
| <span class="text-[10px] text-gray-500 ml-1">${(window.txaformat && window.txaformat.relativeTime) ? window.txaformat.relativeTime(c.created_at) : this.formatRelativeTime(c.created_at)}</span> | |
| ${banButton} | |
| ${deleteButton} | |
| </div> | |
| ${commentBody} | |
| <!-- Likes & Dislikes Row --> | |
| <div class="flex items-center gap-4 mt-3 pt-1"> | |
| <!-- Like --> | |
| <button class="txac-reaction-btn flex items-center gap-1.5 transition-all text-xs font-bold select-none cursor-pointer ${hasLiked ? 'text-primary' : 'text-gray-500 hover:text-primary'}" data-id="${c.id}" data-action="like" data-txatooltip="Thích bình luận"> | |
| <i class="${hasLiked ? 'fas fa-thumbs-up' : 'far fa-thumbs-up'} text-[11px]"></i> | |
| <span class="font-mono text-[11px] font-bold">${likes.length}</span> | |
| </button> | |
| <!-- Dislike --> | |
| <button class="txac-reaction-btn flex items-center gap-1.5 transition-all text-xs font-bold select-none cursor-pointer ${hasDisliked ? 'text-red-500' : 'text-gray-500 hover:text-red-500'}" data-id="${c.id}" data-action="dislike" data-txatooltip="Không thích bình luận"> | |
| <i class="${hasDisliked ? 'fas fa-thumbs-down' : 'far fa-thumbs-down'} text-[11px]"></i> | |
| <span class="font-mono text-[11px] font-bold">${dislikes.length}</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| if (window.initTxaTooltips) { | |
| setTimeout(() => window.initTxaTooltips(), 50); | |
| } | |
| } | |
| setupListeners() { | |
| const textarea = document.getElementById('txac-textarea'); | |
| const counter = document.getElementById('txac-char-counter'); | |
| const submitBtn = document.getElementById('txac-submit-btn'); | |
| const spoiler = document.getElementById('txac-spoiler'); | |
| // Char count update | |
| if (textarea && counter) { | |
| textarea.addEventListener('input', () => { | |
| const len = textarea.value.length; | |
| counter.textContent = `${len} / 1000`; | |
| }); | |
| } | |
| // Submit new comment | |
| if (submitBtn && textarea) { | |
| submitBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| const content = textarea.value.trim(); | |
| if (!content) { | |
| if (window.txatoast) window.txatoast.warning('Nội dung bình luận không được để trống!', 3000, 'bottom-right'); | |
| return; | |
| } | |
| submitBtn.disabled = true; | |
| try { | |
| const res = await fetch('/api/movie/comments', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| movieSlug: this.movieSlug, | |
| content: content, | |
| isSpoiler: spoiler ? spoiler.checked : false, | |
| episodeName: this.episodeName, | |
| serverName: this.serverName, | |
| movieType: this.movieType | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| if (window.txatoast) window.txatoast.success(data.message, 3000, 'bottom-right'); | |
| textarea.value = ''; | |
| if (counter) counter.textContent = '0 / 1000'; | |
| if (spoiler) spoiler.checked = false; | |
| // Reload comments | |
| await this.fetchComments(); | |
| this.renderCommentsList(); | |
| } else { | |
| if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right'); | |
| } | |
| } catch (err) { | |
| if (window.txatoast) window.txatoast.error('Lỗi gửi bình luận!', 3000, 'bottom-right'); | |
| } finally { | |
| submitBtn.disabled = false; | |
| } | |
| }); | |
| } | |
| // Event delegation for Likes, Dislikes, Delete, Ban buttons | |
| const listContainer = document.getElementById('txac-list'); | |
| if (listContainer) { | |
| listContainer.addEventListener('click', async (e) => { | |
| // Find reaction button (Like / Dislike) | |
| const reactBtn = e.target.closest('.txac-reaction-btn'); | |
| if (reactBtn) { | |
| e.preventDefault(); | |
| const commentId = reactBtn.getAttribute('data-id'); | |
| const action = reactBtn.getAttribute('data-action'); | |
| await this.handleReaction(commentId, action); | |
| return; | |
| } | |
| // Find delete button | |
| const deleteBtn = e.target.closest('.txac-delete-btn'); | |
| if (deleteBtn) { | |
| e.preventDefault(); | |
| const commentId = deleteBtn.getAttribute('data-id'); | |
| this.handleDelete(commentId); | |
| return; | |
| } | |
| // Find ban button | |
| const banBtn = e.target.closest('.txac-ban-btn'); | |
| if (banBtn) { | |
| e.preventDefault(); | |
| const targetUsername = banBtn.getAttribute('data-username'); | |
| const action = banBtn.getAttribute('data-action'); | |
| this.handleBan(targetUsername, action); | |
| return; | |
| } | |
| }); | |
| } | |
| } | |
| async handleReaction(commentId, action) { | |
| if (!this.user) { | |
| if (window.txatoast) window.txatoast.warning('Vui lòng đăng nhập để bình chọn bình luận!', 3000, 'bottom-right'); | |
| return; | |
| } | |
| try { | |
| const res = await fetch('/api/movie/comments', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ commentId, action }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| // Cập nhật mảng trực tiếp cho comment hiện tại và re-render để hiển thị thay đổi lập tức | |
| const comment = this.comments.find(c => c.id === commentId); | |
| if (comment) { | |
| comment.likes = data.likes || []; | |
| comment.dislikes = data.dislikes || []; | |
| this.renderCommentsList(); | |
| } | |
| } else { | |
| if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right'); | |
| } | |
| } catch (err) { | |
| console.error('[TxaComment] Lỗi tương tác:', err); | |
| } | |
| } | |
| handleDelete(commentId) { | |
| if (!window.txamodal) { | |
| if (confirm('Bạn có chắc chắn muốn xóa bình luận này không?')) { | |
| this.executeDelete(commentId); | |
| } | |
| return; | |
| } | |
| 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ể hoàn tác.', | |
| type: 'danger', | |
| confirmText: 'Xóa ngay', | |
| onConfirm: async () => { | |
| await this.executeDelete(commentId); | |
| return true; | |
| } | |
| }); | |
| } | |
| async executeDelete(commentId) { | |
| 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) { | |
| if (window.txatoast) window.txatoast.success(data.message, 3000, 'bottom-right'); | |
| this.comments = this.comments.filter(c => c.id !== commentId); | |
| this.renderCommentsList(); | |
| } else { | |
| if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right'); | |
| } | |
| } catch (err) { | |
| if (window.txatoast) window.txatoast.error('Lỗi khi xóa bình luận!', 3000, 'bottom-right'); | |
| } | |
| } | |
| handleBan(targetUsername, action) { | |
| // Chặn 1-click siêu tốc không cần xác nhận | |
| this.executeBan(targetUsername, action); | |
| } | |
| async executeBan(targetUsername, action) { | |
| try { | |
| const res = await fetch('/api/movie/comments', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action, targetUsername }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| if (window.txatoast) window.txatoast.success(data.message, 3000, 'bottom-right'); | |
| this.bannedUsers = data.bannedUsers || []; | |
| this.renderCommentsList(); | |
| } else { | |
| if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right'); | |
| } | |
| } catch (err) { | |
| if (window.txatoast) window.txatoast.error('Lỗi khi thực thi yêu cầu chặn!', 3000, 'bottom-right'); | |
| } | |
| } | |
| 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`; | |
| // Vượt quá 1 ngày (24 giờ): Chuyển sang định dạng ngày cụ thể "ngày/tháng/năm" chuẩn Việt Nam | |
| const day = date.getDate(); | |
| const month = date.getMonth() + 1; | |
| const year = date.getFullYear(); | |
| return `${day}/${month}/${year}`; | |
| } | |
| } | |
| // Auto instantiate on load | |
| function initTxaCommentWidget() { | |
| if (document.getElementById('txa-comment-container')) { | |
| window.txacomment = new TxaComment('txa-comment-container'); | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', initTxaCommentWidget); | |
| document.addEventListener('astro:page-load', initTxaCommentWidget); | |