dptxa-proxy / public /lib /txa /txacomment.js
TXAVLOG
Deploy DPTXA to Hugging Face Spaces
4bea261
Raw
History Blame Contribute Delete
22.4 kB
/**
* 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);