Files
3dtours/frontend/js/main_map.js
T
2026-06-09 19:48:56 +07:00

2417 lines
93 KiB
JavaScript

const API_BASE_URL = '/api'; // Sử dụng đường dẫn tương đối để tránh lỗi CORS/Hostname
let map;
let tempMarker = null;
let markerClusterGroup;
let currentSceneId = null;
let previousSceneId = null;
let miniMap = null;
let miniMapMarker = null;
let systemSettings = { timezone: 'Asia/Ho_Chi_Minh', language: 'vi' };
let returnToDashboardAfterEdit = false;
let assetIdToDelete = null;
let sceneIdToDelete = null;
let dashboardReturnTab = 'media-library';
let editMiniMap = null;
let editMiniMapMarker = null;
let currentEditingScene = null; // Lưu object scene đang sửa để quản lý chia sẻ
let sharedUsersData = []; // [{id, username, email}]
let sharedEmailsData = []; // [email]
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
try {
console.log("--- Bắt đầu khởi tạo Frontend ---");
// 0. Kiểm tra tham số URL để truy cập trực tiếp
const urlParams = new URLSearchParams(window.location.search);
const urlSceneId = urlParams.get('sceneId');
const urlToken = urlParams.get('token');
// Ưu tiên nạp cấu hình hệ thống trước
fetchSystemSettings();
if (document.getElementById('map')) {
console.log("1. Đang khởi tạo bản đồ Leaflet...");
initMap();
}
// Chạy tuần tự để tránh xung đột luồng xử lý
checkAuthStatus(); // 2. Kiểm tra đăng nhập
// 3. Xử lý logic vào thẳng Scene hoặc khôi phục trang
if (urlSceneId) {
console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`);
openScene(urlSceneId, urlToken ? 'shared' : null, urlToken);
} else {
restoreActiveScene();
}
// Đảm bảo map đã sẵn sàng trước khi nạp data
if (map) {
// Chỉ nạp danh sách Scene để vẽ marker lên bản đồ
loadScenes();
}
} catch (error) {
console.error("Ứng dụng không thể khởi tạo:", error);
}
});
/**
* Lấy cấu hình hệ thống từ Backend
*/
async function fetchSystemSettings() {
try {
const res = await fetch(`${API_BASE_URL}/system/settings`);
if (res.ok) {
systemSettings = await res.json();
applySystemSettings();
}
} catch (e) {
console.warn("Không thể nạp cấu hình hệ thống, dùng mặc định.");
}
}
/**
* Áp dụng cấu hình Múi giờ và Ngôn ngữ vào UI
*/
function applySystemSettings() {
console.log(`[System] Applying settings: Language=${systemSettings.language}, Timezone=${systemSettings.timezone}`);
// 1. Cập nhật nhãn (Labels) dựa trên ngôn ngữ
const translations = {
vi: {
brand: "Bản đồ Tour 3D Ảo",
login: "Đăng nhập / Đăng ký",
profile: "Quản lý hồ sơ",
logout: "Đăng xuất",
dashboardTitle: "Bảng điều khiển người dùng",
tabProfile: "Hồ sơ",
tabScenes: "Quản lí scene",
tabMedia: "Quản lí ảnh và media",
tabUsers: "Quản lí người dùng",
tabSystem: "Cài đặt hệ thống"
},
en: {
brand: "Virtual 3D Tour Map",
login: "Login / Register",
profile: "Manage Profile",
logout: "Logout",
dashboardTitle: "User Dashboard",
tabProfile: "Profile",
tabScenes: "My Scenes",
tabMedia: "Media Library",
tabUsers: "User Management",
tabSystem: "System Settings"
}
};
const lang = systemSettings.language || 'vi';
const t = translations[lang];
// Cập nhật các phần tử cố định
const brandH1 = document.querySelector('.app-brand h1');
if (brandH1) brandH1.innerText = t.brand;
const dashboardH2 = document.querySelector('.dashboard-content h2');
if (dashboardH2) dashboardH2.innerText = t.dashboardTitle;
// Cập nhật các nút Tab
const tabButtons = document.querySelectorAll('.dashboard-tabs .tab-btn');
if (tabButtons.length >= 3) {
tabButtons[0].innerText = t.tabProfile;
tabButtons[1].innerText = t.tabScenes;
tabButtons[2].innerText = t.tabMedia;
if (tabButtons[3]) tabButtons[3].innerText = t.tabUsers;
if (tabButtons[4]) tabButtons[4].innerText = t.tabSystem;
}
const profileBtn = document.querySelector('button[onclick="openDashboard()"]');
if (profileBtn) profileBtn.innerText = t.profile;
const logoutBtn = document.querySelector('button[onclick="handleLogout()"]');
if (logoutBtn) logoutBtn.innerText = t.logout;
}
/**
* Hàm định dạng dung lượng file cho Frontend
*/
function formatBytes(bytes, decimals = 2) {
if (!bytes || bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Tải và hiển thị thống kê các tệp tin lớn nhất
*/
async function loadMediaStats() {
const token = localStorage.getItem('jwt');
const statsContainer = document.getElementById('media-library-stats');
if (!statsContainer) return;
try {
const res = await fetch(`${API_BASE_URL}/me/assets/top-large`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const topFiles = await res.json();
if (topFiles && topFiles.length > 0) {
let html = `
<div class="top-files-section">
<h4><i class="fas fa-database"></i> Tệp tin chiếm dụng lớn nhất</h4>
<div class="top-files-list">
`;
topFiles.forEach(file => {
const fileName = file.scene?.name || file.scene?.title || 'Ảnh chưa gắn Scene';
html += `
<div class="top-file-item">
<span class="top-file-name">● ${fileName}</span>
<span class="top-file-size">${formatBytes(file.fileSize)}</span>
</div>
`;
});
html += `</div></div>`;
statsContainer.innerHTML = html;
} else {
statsContainer.innerHTML = '';
}
} catch (e) {
console.warn("Không thể nạp thống kê media:", e);
}
}
/**
* Cập nhật nội dung tab Hồ sơ với thông tin người dùng
*/
async function updateProfileTabContent() {
const token = localStorage.getItem('jwt');
if (!token) return;
// Các phần tử hiển thị chung
const topAvatar = document.getElementById('avatar-initials');
const sidebarAvatar = document.getElementById('sidebar-avatar');
const userDisplay = document.getElementById('profile-username-display');
const statusDisplay = document.getElementById('profile-status-display');
const sidebarUser = document.getElementById('sidebar-username');
const sidebarStatus = document.getElementById('sidebar-status');
// Các phần tử trong Form
const fullNameInput = document.getElementById('profile-fullname');
const emailInput = document.getElementById('profile-email');
const userInput = document.getElementById('profile-username');
const avatarPreview = document.getElementById('profile-avatar-preview');
const avatarPlaceholder = document.getElementById('profile-avatar-placeholder');
try {
const res = await fetch(`${API_BASE_URL}/me/profile`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (data && res.ok) {
// 1. Cập nhật thông tin text an toàn
if (fullNameInput) fullNameInput.value = data.fullName || '';
if (emailInput) emailInput.value = data.email || '';
if (userInput) userInput.value = data.username || '';
if (userDisplay) userDisplay.innerText = data.username || 'N/A';
if (statusDisplay) statusDisplay.innerText = data.role || 'Thành viên';
if (sidebarUser) sidebarUser.innerText = data.username || 'N/A';
if (sidebarStatus) sidebarStatus.innerText = data.role || 'Thành viên';
// Cập nhật lại localStorage để đồng bộ trạng thái
if (data.username) localStorage.setItem('username', data.username);
if (data.role) localStorage.setItem('role', data.role);
// 2. Xử lý Ảnh đại diện (Avatar)
if (data.avatarUrl) {
const fullAvatarUrl = data.avatarUrl;
if (avatarPreview) {
avatarPreview.src = fullAvatarUrl;
avatarPreview.style.display = 'block';
}
if (avatarPlaceholder) avatarPlaceholder.style.display = 'none';
// Cập nhật ảnh đại diện ở sidebar nếu có
if (sidebarAvatar) {
sidebarAvatar.innerHTML = `<img src="${fullAvatarUrl}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`;
}
} else {
// Fallback về chữ cái đầu nếu không có ảnh
const initial = (data.username || "?").charAt(0).toUpperCase();
if (avatarPreview) avatarPreview.style.display = 'none';
if (avatarPlaceholder) {
avatarPlaceholder.style.display = 'flex';
avatarPlaceholder.innerText = initial;
}
if (topAvatar) topAvatar.innerText = initial;
if (sidebarAvatar) sidebarAvatar.innerText = initial;
}
// 3. Xử lý thông tin dung lượng
if (data.storage) {
const { used, quota } = data.storage;
const progress = document.getElementById('storage-progress-bar');
const text = document.getElementById('storage-text');
if (progress && text) {
const usedMB = (used / (1024 * 1024)).toFixed(1);
const quotaMB = quota === -1 ? '∞' : (quota / (1024 * 1024)).toFixed(0);
text.innerText = `${usedMB} MB / ${quotaMB} MB`;
if (quota !== -1 && quota > 0) {
const percent = Math.min((used / quota) * 100, 100);
progress.style.width = percent + '%';
if (percent > 90) progress.style.background = '#dc3545';
else if (percent > 75) progress.style.background = '#ffc107';
else progress.style.background = '#28a745';
} else {
progress.style.width = '100%';
progress.style.background = '#007bff';
}
}
}
}
} catch (e) {
console.warn("Không thể tải thông tin dung lượng:", e);
}
}
/**
* Xem trước ảnh đại diện khi chọn file
*/
window.previewAvatar = function(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('profile-avatar-preview');
const placeholder = document.getElementById('profile-avatar-placeholder');
preview.src = e.target.result;
preview.style.display = 'block';
placeholder.style.display = 'none';
};
reader.readAsDataURL(input.files[0]);
}
};
/**
* Cập nhật hồ sơ người dùng
*/
async function updateProfile(e) {
e.preventDefault();
const token = localStorage.getItem('jwt');
const form = document.getElementById('profile-form');
const formData = new FormData(form);
// Xóa các trường không cần thiết khi cập nhật hồ sơ cá nhân
formData.delete('agreedToRules'); // Không cần khi cập nhật
formData.delete('role'); // Role chỉ được thay đổi bởi Admin
try {
const res = await fetch(`${API_BASE_URL}/me/profile`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
showNotification("Hồ sơ đã được cập nhật thành công!", 'success');
// Cập nhật lại localStorage nếu username thay đổi
if (data.user && data.user.username) {
localStorage.setItem('username', data.user.username);
}
updateProfileTabContent(); // Tải lại thông tin mới
} catch (err) {
showNotification("Lỗi cập nhật: " + err.message, 'error');
}
}
/**
* Hàm bổ trợ định dạng ngày tháng theo múi giờ hệ thống
*/
function formatSystemDate(dateString) {
if (!dateString) return 'N/A';
const date = new Date(dateString);
try {
return new Intl.DateTimeFormat(systemSettings.language === 'vi' ? 'vi-VN' : 'en-US', {
timeZone: systemSettings.timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date);
} catch (e) {
// Fallback nếu timezone không hợp lệ
return date.toLocaleDateString();
}
}
/**
* Initializes the full-screen Leaflet Map
*/
function initMap() {
// Đọc vị trí và zoom đã lưu từ localStorage
const savedLat = localStorage.getItem('map-lat');
const savedLng = localStorage.getItem('map-lng');
const savedZoom = localStorage.getItem('map-zoom');
// Đảm bảo tọa độ khởi tạo luôn hợp lệ
let startLat = parseFloat(savedLat);
let startLng = parseFloat(savedLng);
let startZoom = parseInt(savedZoom);
if (isNaN(startLat)) startLat = 21.0285;
if (isNaN(startLng)) startLng = 105.8542;
if (isNaN(startZoom)) startZoom = 13;
// Khởi tạo bản đồ với zoomControl và tắt attribution mặc định của Leaflet
map = L.map('map', { zoomControl: true, attributionControl: false }).setView([startLat, startLng], startZoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
// Attribution sẽ được thêm thủ công bên dưới để chỉ hiển thị OpenStreetMap
// attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Khởi tạo Marker Cluster Group CHỈ dành cho Scene (Ảnh mẹ)
markerClusterGroup = L.markerClusterGroup({
zoomToBoundsOnClick: false,
spiderfyOnMaxZoom: true,
maxClusterRadius: 50,
spiderfyDistanceMultiplier: 3.5, // Tăng thêm khoảng cách để callout ảnh mẹ tách rõ ràng khi tỏa ra
showCoverageOnHover: false,
iconCreateFunction: function(cluster) {
const childMarkers = cluster.getAllChildMarkers();
try {
if (childMarkers.length > 0 && childMarkers[0].options.icon) {
const childIcon = childMarkers[0].options.icon;
// Trả về một DivIcon MỚI dựa trên cấu hình của con, tránh dùng chung instance gây crash render
return L.divIcon({
html: childIcon.options.html,
className: childIcon.options.className,
iconSize: childIcon.options.iconSize,
iconAnchor: childIcon.options.iconAnchor
});
}
} catch (e) {}
return L.divIcon({ className: 'cluster-fallback', html: '<div style="background:#007bff;width:10px;height:10px;border-radius:50%;"></div>' });
}
});
// Khi click chuột trái vào một cụm callout, thực hiện tách chúng ra
markerClusterGroup.on('clusterclick', (a) => {
a.layer.spiderfy();
});
// Thêm attribution chỉ với OpenStreetMap, không có Leaflet
L.control.attribution({ prefix: false }).addAttribution('OpenStreetMap contributors').addTo(map);
map.addLayer(markerClusterGroup);
// Lưu vị trí bản đồ mỗi khi người dùng di chuyển hoặc zoom xong
map.on('moveend', () => {
const center = map.getCenter();
localStorage.setItem('map-lat', center.lat);
localStorage.setItem('map-lng', center.lng);
localStorage.setItem('map-zoom', map.getZoom());
});
// Event listener for right-click on map to open modal
map.on('contextmenu', (e) => {
// Nếu viewer 3D đang hiển thị, không thực hiện tạo scene trên bản đồ
const viewerContainer = document.getElementById('viewer-container');
// Kiểm tra z-index và display để chắc chắn viewer đang ẩn
if (viewerContainer && viewerContainer.style.display !== 'none') {
return;
}
// Cho phép bất kỳ người dùng nào đã đăng nhập tạo Scene mới trên bản đồ
const token = localStorage.getItem('jwt');
if (!token) return;
const { lat, lng } = e.latlng;
openCreateSceneModal(lat, lng);
});
}
/**
* Checks if user is logged in (via localStorage JWT) and updates UI
*/
function checkAuthStatus() {
const token = localStorage.getItem('jwt');
const username = localStorage.getItem('username');
const role = localStorage.getItem('role');
const authGuest = document.getElementById('auth-guest');
const authLoggedIn = document.getElementById('auth-logged-in');
const avatarInitials = document.getElementById('avatar-initials');
if (token && username) {
if (authGuest) authGuest.style.display = 'none'; // Hide login form
authLoggedIn.style.display = 'block'; // Show welcome message and buttons
avatarInitials.innerText = username.charAt(0).toUpperCase();
// Hiển thị các nút dành cho admin (Chủ sở hữu/Admin tối cao)
const adminButtons = document.querySelectorAll('.dashboard-tabs .admin-only');
if (role === 'admin' || role === 'Chủ sở hữu') {
adminButtons.forEach(btn => btn.style.display = 'block');
} else {
adminButtons.forEach(btn => btn.style.display = 'none');
}
} else {
authGuest.style.display = 'block'; // Show login form
authLoggedIn.style.display = 'none'; // Hide welcome message
avatarInitials.innerText = '?';
document.getElementById('user-dropdown').classList.remove('show'); // Đóng dropdown nếu không đăng nhập
}
}
/**
* Handles user login
*/
async function handleLogin() {
const errorMsg = document.getElementById('login-error-msg');
if (errorMsg) errorMsg.style.display = 'none';
const username = document.getElementById('username-input').value.trim();
const password = document.getElementById('password-input').value.trim();
if (!username || !password) {
if (errorMsg) {
errorMsg.innerText = 'Vui lòng nhập đầy đủ thông tin';
errorMsg.style.display = 'block';
}
return;
}
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Đăng nhập thất bại');
localStorage.setItem('jwt', data.token);
localStorage.setItem('username', data.user.username);
localStorage.setItem('role', data.user.role);
localStorage.setItem('userId', data.user.id);
checkAuthStatus();
toggleDropdown(); // Đóng dropdown sau khi đăng nhập
loadScenes(); // Reload scenes to show member/private scenes
// Làm sạch form
document.getElementById('username-input').value = '';
document.getElementById('password-input').value = '';
} catch (error) {
if (errorMsg) {
errorMsg.innerText = error.message;
errorMsg.style.display = 'block';
}
}
}
/**
* Chuyển đổi giữa chế độ Đăng nhập và Đăng ký trong dropdown
*/
window.switchAuthMode = function(mode) {
const loginSection = document.getElementById('login-section');
const registerSection = document.getElementById('register-section');
const loginBtn = document.getElementById('tab-login-btn');
const registerBtn = document.getElementById('tab-register-btn');
if (mode === 'login') {
loginSection.style.display = 'block';
registerSection.style.display = 'none';
loginBtn.classList.add('active');
registerBtn.classList.remove('active');
} else {
loginSection.style.display = 'none';
registerSection.style.display = 'block';
loginBtn.classList.remove('active');
registerBtn.classList.add('active');
}
};
/**
* Handles user registration
*/
async function handleRegister() {
const errorMsg = document.getElementById('reg-error-msg');
if (errorMsg) errorMsg.style.display = 'none';
const fullName = document.getElementById('reg-fullname').value.trim();
const email = document.getElementById('reg-email').value.trim();
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
const agree = document.getElementById('reg-agree').checked;
// Kiểm tra các trường trống
if (!fullName || !email || !username || !password || !confirm) {
if (errorMsg) {
errorMsg.innerText = 'Vui lòng điền đầy đủ thông tin';
errorMsg.style.display = 'block';
}
return;
}
// Kiểm tra mật khẩu khớp nhau
if (password !== confirm) {
if (errorMsg) {
errorMsg.innerText = 'Mật khẩu xác nhận không khớp';
errorMsg.style.display = 'block';
}
return;
}
// Kiểm tra đồng ý quy định
if (!agree) {
if (errorMsg) {
errorMsg.innerText = 'Bạn phải đồng ý với quy định của trang';
errorMsg.style.display = 'block';
}
return;
}
try {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullName,
email,
username,
password,
agreedToRules: agree,
role: 'Thành viên'
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Đăng ký thất bại');
showNotification('Đăng ký thành công! Bạn có thể đăng nhập ngay bây giờ.', 'success');
switchAuthMode('login'); // Tự động chuyển về tab đăng nhập sau khi thành công
} catch (error) {
if (errorMsg) {
errorMsg.innerText = error.message;
errorMsg.style.display = 'block';
}
}
}
/**
* Hiển thị hộp thoại xác nhận đăng xuất
*/
function showLogoutConfirm() {
const modal = document.getElementById('logout-confirm-modal');
if (modal) {
closeDashboard(); // Đảm bảo đóng dashboard nếu đang mở
modal.style.display = 'flex';
}
}
/**
* Đóng hộp thoại xác nhận đăng xuất
*/
function closeLogoutConfirm() {
const modal = document.getElementById('logout-confirm-modal');
if (modal) {
modal.style.display = 'none';
openDashboard(); // Mở lại dashboard sau khi đóng confirm
}
}
/**
* Handles user logout
*/
function handleLogout() {
localStorage.removeItem('jwt');
localStorage.removeItem('username');
localStorage.removeItem('role');
localStorage.removeItem('activeSceneId');
localStorage.removeItem('activeScenePrivacy');
localStorage.removeItem('activeSceneToken');
localStorage.removeItem('userId');
// Đảm bảo đóng viewer nếu đang mở
if (typeof closeViewer === 'function') {
closeViewer();
}
checkAuthStatus();
if (document.getElementById('user-dropdown').classList.contains('show')) {
toggleDropdown();
}
closeLogoutConfirm();
closeDashboard();
loadScenes(); // Reload scenes to filter out private ones
}
/**
* Opens Modal for creating a Scene and sets lat/lng inputs
*/
function openCreateSceneModal(lat, lng) {
returnToDashboardAfterEdit = false;
const token = localStorage.getItem('jwt');
if (!token) {
showNotification('Please log in first to create a 3D scene.', 'error');
return;
}
// Place a temporary marker on the map
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng]).addTo(map);
document.getElementById('create-scene-modal').style.display = 'flex';
document.getElementById('modal-scene-id').value = '';
document.getElementById('modal-lat').value = lat.toFixed(6);
document.getElementById('modal-lng').value = lng.toFixed(6);
const lang = systemSettings.language || 'vi';
const modalTitle = document.getElementById('create-scene-modal-title');
if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Tạo 3D scene mới" : "Create New 3D Scene";
}
/**
* Closes the Create Scene Modal and removes temporary marker
*/
function closeModal() {
document.getElementById('create-scene-modal').style.display = 'none';
if (tempMarker) {
map.removeLayer(tempMarker);
tempMarker = null;
}
document.getElementById('create-scene-form').reset();
document.getElementById('shared-with-group').style.display = 'none';
if (returnToDashboardAfterEdit) {
const targetTab = dashboardReturnTab;
returnToDashboardAfterEdit = false;
openDashboard();
openDashboardTab(targetTab);
}
}
/**
* Toggles visibility of the shared users input based on privacy selection
*/
function toggleSharedUsers() {
const privacy = document.getElementById('modal-privacy').value;
const group = document.getElementById('shared-with-group');
if (privacy === 'shared') {
group.style.display = 'block';
} else {
group.style.display = 'none';
}
}
/**
* Form submission for Scene creation (multipart/form-data)
*/
async function submitScene(e) {
e.preventDefault();
const form = document.getElementById('create-scene-form');
const formData = new FormData(form);
const sceneId = document.getElementById('modal-scene-id').value;
const token = localStorage.getItem('jwt');
const url = sceneId ? `${API_BASE_URL}/scenes/${sceneId}` : `${API_BASE_URL}/scenes`;
const method = sceneId ? 'PUT' : 'POST';
uploadWithProgress(url, method, formData, token, 'create', () => {
showNotification(sceneId ? 'Scene đang được cập nhật ngầm!' : 'Scene đã được tạo! Ảnh đang được xử lý 8K...', 'success');
closeModal();
loadScenes();
});
}
/**
* Helper function to handle uploads with progress bars
*/
function uploadWithProgress(url, method, formData, token, prefix, callback) {
const xhr = new XMLHttpRequest();
const container = document.getElementById(`${prefix}-progress-container`);
const bar = document.getElementById(`${prefix}-progress-bar`);
const percentText = document.getElementById(`${prefix}-progress-percent`);
const statusText = document.getElementById(`${prefix}-progress-status`);
if (container) container.style.display = 'block';
if (statusText) statusText.innerText = "Đang tải ảnh lên...";
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
if (bar) bar.style.width = percent + '%';
if (percentText) percentText.innerText = percent + '%';
if (percent === 100 && statusText) statusText.innerText = "Tải lên xong! Đang khởi tạo trên server...";
}
});
xhr.addEventListener('load', () => {
if (container) container.style.display = 'none';
if (xhr.status >= 200 && xhr.status < 300) {
callback(JSON.parse(xhr.responseText));
} else {
let errorMsg = 'Không thể tải lên';
try {
const err = JSON.parse(xhr.responseText);
errorMsg = err.message || errorMsg;
} catch (e) {}
showNotification('Lỗi: ' + errorMsg, 'error');
}
});
xhr.addEventListener('error', () => {
if (container) container.style.display = 'none';
showNotification('Lỗi kết nối mạng.', 'error');
});
xhr.open(method, url);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
}
/**
* Loads and displays visible Scenes on the map
*/
async function loadScenes() {
try {
const token = localStorage.getItem('jwt');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Thêm timestamp để tránh lỗi 304 hang do cache trình duyệt
const timestamp = new Date().getTime();
console.log(`3.1 Đang gửi yêu cầu lấy danh sách Scene (ts: ${timestamp})...`);
const response = await fetch(`${API_BASE_URL}/scenes?_=${timestamp}`, {
method: 'GET',
headers
});
console.log(`[API Response] /scenes status: ${response.status}`);
if (!response.ok) throw new Error('Failed to load scenes');
const scenes = await response.json();
console.log(`[Data] Nhận được ${scenes.length} scenes từ server`);
if (!Array.isArray(scenes)) return;
// Xóa sạch các layers cũ trước khi nạp mới
markerClusterGroup.clearLayers();
const markersToAdd = [];
const activeSceneId = localStorage.getItem('activeSceneId');
const seenCoordinates = new Set(); // Dùng để lọc "Ảnh mẹ" (1 marker per location)
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
scenes.forEach((scene) => {
// 1. Kiểm tra tọa độ an toàn - Ngăn chặn treo map do NaN
const latNum = Number(scene.gps?.lat ?? scene.lat);
const lngNum = Number(scene.gps?.lng ?? scene.lng);
if (isNaN(latNum) || isNaN(lngNum)) {
console.error(`Bỏ qua Scene "${scene.name || scene.title}" do tọa độ lỗi:`, scene);
return;
}
// 2. Logic lọc Ảnh mẹ: Sửa lỗi typo coordKey (dùng latNum 2 lần)
const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`;
if (seenCoordinates.has(coordKey)) return;
seenCoordinates.add(coordKey);
// 3. Truy cập Asset an toàn
const assetId = scene.assetId?._id || scene.assetId;
if (!assetId) return; // Bỏ qua nếu không có ảnh liên kết
const sceneName = scene.name || scene.title || "Untitled Scene";
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
if (token) thumbUrl += `?token=${token}`;
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
const calloutIcon = L.divIcon({
className: 'custom-scene-marker',
html: `
<div class="scene-callout">
<div class="scene-img-wrapper">
<img src="${thumbUrl}" alt="${sceneName}">
</div>
</div>`,
iconSize: [64, 64],
iconAnchor: [32, 76] // Căn giữa ngang, đáy mũi tên tại tọa độ lat/lng
});
const marker = L.marker([latNum, lngNum], {
icon: calloutIcon,
title: sceneName
});
// Tạo nội dung thông tin khi Hover (Tooltip)
const createdDate = formatSystemDate(scene.assetId?.createdAt);
const tooltipContent = `
<div class="scene-hover-info">
<strong>${sceneName}</strong><br>
${scene.description ? `<small>${scene.description}</small><br>` : ''}
<span>Người tạo: ${scene.createdBy ? scene.createdBy.username : 'Ẩn danh'}</span><br>
<span>Ngày tạo: ${createdDate}</span>
</div>
`;
// Gán Tooltip cho sự kiện Hover
marker.bindTooltip(tooltipContent, {
direction: 'top',
offset: [0, -70],
className: 'custom-scene-tooltip'
});
// Sự kiện Click chuột trái: Vào thẳng trình xem 360
marker.on('click', () => {
openScene(scene._id, scene.privacy, scene.shareToken || '');
});
marker.on('contextmenu', (e) => {
if (e.originalEvent) {
L.DomEvent.stop(e.originalEvent);
}
const currentUserId = localStorage.getItem('userId');
const userRole = localStorage.getItem('role');
// Hỗ trợ cả schema cũ (owner) và mới (createdBy)
const ownerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
// Phân quyền: Admin hoặc Chủ sở hữu Scene
const isAdmin = userRole === 'admin' || userRole === 'Chủ sở hữu';
const isOwner = currentUserId && ownerId && ownerId.toString() === currentUserId.toString();
if (isAdmin || isOwner) {
handleEditDeleteScene(scene);
} else if (scene.privacy === 'public') {
// Cho phép bất kỳ ai (kể cả khách) lấy link chia sẻ của scene công khai
showShareLink(scene);
} else {
showNotification("Bạn không có quyền chỉnh sửa scene này.", 'warning');
}
});
markersToAdd.push(marker);
});
// Thêm danh sách marker đã lọc vào group
markerClusterGroup.addLayers(markersToAdd);
} catch (error) {
console.error('Error loading scenes:', error);
}
}
/**
* Handles Edit/Delete options for a scene
*/
async function handleEditDeleteScene(scene) {
const modal = document.getElementById('action-choice-modal');
const title = document.getElementById('action-modal-title');
const editBtn = document.getElementById('btn-edit-action');
const editPrivacyBtn = document.getElementById('btn-edit-privacy-action');
const deleteBtn = document.getElementById('btn-delete-action');
const shareBtn = document.getElementById('btn-share-action');
title.innerText = `Scene: ${scene.title}`;
modal.style.display = 'flex';
// Hành động Lấy link chia sẻ trực tiếp
shareBtn.onclick = () => {
closeActionModal();
showShareLink(scene);
};
// Hành động Chỉnh sửa privacy
editPrivacyBtn.onclick = () => {
returnToDashboardAfterEdit = false;
closeActionModal();
// Sử dụng thuộc tính isChildScene từ backend để quyết định quyền chỉnh sửa
openEditMetadataModal(scene, scene.isChildScene);
};
// Gán sự kiện cho nút Sửa
editBtn.onclick = () => {
returnToDashboardAfterEdit = false;
closeActionModal();
openEditMetadataModal(scene, scene.isChildScene);
};
// Gán sự kiện cho nút Xóa
deleteBtn.onclick = () => {
returnToDashboardAfterEdit = false; // Đảm bảo không mở dashboard nếu xóa từ map
closeActionModal();
deleteScene(scene._id);
};
}
/**
* Closes the Action Choice Modal
*/
function closeActionModal() {
document.getElementById('action-choice-modal').style.display = 'none';
}
/**
* Mở modal xác nhận xóa scene
*/
window.deleteScene = function(sceneId) {
sceneIdToDelete = sceneId;
document.getElementById('delete-scene-confirm-modal').style.display = 'flex';
};
window.closeDeleteSceneModal = function() {
document.getElementById('delete-scene-confirm-modal').style.display = 'none';
sceneIdToDelete = null;
if (returnToDashboardAfterEdit) {
const targetTab = dashboardReturnTab;
returnToDashboardAfterEdit = false;
openDashboard();
openDashboardTab(targetTab);
}
};
window.confirmDeleteScene = async function() {
if (!sceneIdToDelete) return;
const token = localStorage.getItem('jwt');
try {
const response = await fetch(`${API_BASE_URL}/scenes/${sceneIdToDelete}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to delete scene');
closeDeleteSceneModal();
showSuccessModal(data.message || 'Scene đã được xóa vĩnh viễn');
loadScenes();
if (document.getElementById('tab-my-scenes').classList.contains('active')) {
loadMyScenes();
}
} catch (error) {
showNotification("Lỗi khi xóa: " + error.message, 'error');
}
};
/**
* Fetches secure scene details and triggers the Panorama viewer
*/
async function openScene(sceneId, privacy, shareToken, force = false, initialPitch = 0, initialYaw = 0) {
// Nếu đang xem chính scene này và không yêu cầu làm mới (force), không cần nạp lại
if (!force && currentSceneId === sceneId && document.getElementById('viewer-container').style.display === 'block') {
return;
}
try {
const token = localStorage.getItem('jwt');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
console.log(`[Viewer] Đang mở scene: ${sceneId}`);
let url = `${API_BASE_URL}/scenes/${sceneId}`;
if (privacy === 'shared' && shareToken) {
url += `?token=${shareToken}`;
}
// Lưu trạng thái Scene hiện tại để khôi phục sau khi reload trang
localStorage.setItem('activeSceneId', sceneId);
localStorage.setItem('activeScenePrivacy', privacy || '');
localStorage.setItem('activeSceneToken', shareToken || '');
// Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng
const [sceneRes, hotspotsRes] = await Promise.all([
fetch(url, { method: 'GET', headers }),
fetch(`${API_BASE_URL}/hotspots/${sceneId}`, { method: 'GET', headers })
]);
const scene = await sceneRes.json();
const hotspots = await hotspotsRes.json();
console.log("DEBUG: Hotspots raw data from API:", hotspots);
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
// Lấy ID người tạo (createdBy) để phân quyền chuột phải trong viewer
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
// Tự động focus bản đồ vào vị trí của Scene
if (map) {
map.flyTo([scene.gps?.lat || scene.lat, scene.gps?.lng || scene.lng], 16);
}
// Cập nhật tọa độ vào các input ẩn để hỗ trợ GPS inheritance cho hotspot khi tải ảnh mới
document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat;
document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng;
// Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động
if (currentSceneId && currentSceneId !== sceneId) {
previousSceneId = currentSceneId;
}
currentSceneId = sceneId;
// Kiểm tra an toàn assetId (hỗ trợ cả dạng Object và String ID)
const assetId = scene.assetId?._id || scene.assetId;
if (!assetId) throw new Error("Dữ liệu hình ảnh của cảnh này bị lỗi hoặc chưa xử lý xong.");
let secureImageUrl = `${API_BASE_URL}/assets/view/${assetId}`;
// Ưu tiên JWT token nếu đang đăng nhập, nếu không thì dùng shareToken
if (token) {
secureImageUrl += `?token=${token}`;
} else if (privacy === 'shared' && scene.shareToken) {
secureImageUrl += `?token=${scene.shareToken}`;
}
// Initialize 3D Viewer with secure, referer-protected image stream
initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId, initialPitch, initialYaw);
// Sau khi mở thành công từ URL trực tiếp, xóa tham số để làm sạch thanh địa chỉ (URL chuyên nghiệp)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('sceneId')) {
window.history.replaceState({}, document.title, "/");
}
} catch (error) {
if (typeof closeViewer === 'function') closeViewer();
localStorage.removeItem('activeSceneId');
localStorage.removeItem('activeScenePrivacy');
localStorage.removeItem('activeSceneToken');
// Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('sceneId')) {
showNotification("Bạn không có quyền truy cập hoặc liên kết chia sẻ đã hết hạn. Quay về bản đồ công cộng.", 'error');
// Xóa toàn bộ tham số URL và tải lại trang để làm mới trạng thái (về trang chủ dành cho khách)
window.history.replaceState({}, document.title, "/");
location.reload();
return;
}
showNotification(error.message, 'error');
}
}
/**
* Khôi phục Scene đang xem từ localStorage sau khi reload trang
*/
function restoreActiveScene() {
const savedSceneId = localStorage.getItem('activeSceneId');
if (savedSceneId) {
const savedPrivacy = localStorage.getItem('activeScenePrivacy');
const savedToken = localStorage.getItem('activeSceneToken');
const savedPitch = parseFloat(localStorage.getItem('activeScenePitch')) || 0;
const savedYaw = parseFloat(localStorage.getItem('activeSceneYaw')) || 0;
openScene(savedSceneId, savedPrivacy, savedToken, false, savedPitch, savedYaw);
}
}
/**
* Xử lý việc tạo hotspot sau khi click chuột phải trong trình xem 360
* @param {number} pitch - Tọa độ dọc (-90 đến 90)
* @param {number} yaw - Tọa độ ngang (-180 đến 180)
* @param {Object} existingHotspot - Thông tin hotspot cũ nếu có
*/
window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null) {
const token = localStorage.getItem('jwt');
if (!token) {
showNotification('Vui lòng đăng nhập để thực hiện thao tác này.', 'warning');
return;
}
const modal = document.getElementById('hotspot-modal');
const form = document.getElementById('hotspot-form');
// Hiển thị Modal TRƯỚC để các logic UI (như Mini Map) tính toán được kích thước
modal.style.display = 'flex';
// Reset form và gán tọa độ
form.reset();
document.getElementById('hs-pitch').value = pitch;
document.getElementById('hs-yaw').value = yaw;
document.getElementById('hs-id').value = existingHotspot ? existingHotspot._id : '';
document.getElementById('hotspot-modal-title').innerText = existingHotspot ? 'Cập nhật điểm điều hướng' : 'Thêm điểm điều hướng mới';
// Reset UI states
document.querySelector('input[name="hsLinkType"][value="existing"]').checked = true;
window.toggleHSLinkType('existing');
document.querySelector('input[name="hsGPSMode"][value="map"]').checked = true;
window.toggleHSGPSMode('map');
// Lấy danh sách Scene có sẵn để đổ vào dropdown
try {
const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } });
const scenes = await res.json();
const select = document.getElementById('hs-target-id');
select.innerHTML = '<option value="">-- Chọn một cảnh để liên kết --</option>';
scenes.forEach(s => {
if (s._id !== currentSceneId) { // Không liên kết tới chính nó
select.innerHTML += `<option value="${s._id}">${s.name || s.title}</option>`;
}
});
// QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options
if (existingHotspot) {
document.getElementById('hs-title').value = existingHotspot.title || '';
document.getElementById('hs-desc').value = existingHotspot.description || '';
if (existingHotspot.target_scene_id) {
select.value = existingHotspot.target_scene_id;
}
}
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
// Xử lý sự kiện submit form
form.onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(form);
const linkType = formData.get('hsLinkType');
if (linkType === 'upload') {
const file = document.getElementById('hs-panorama-file').files[0];
if (!file) {
showNotification('Vui lòng chọn ảnh panorama.', 'warning');
return;
}
const gpsMode = formData.get('hsGPSMode');
let lat = 0, lng = 0;
if (gpsMode === 'inherit') {
// Ép kiểu về Number khi lấy từ input
lat = Number(document.getElementById('modal-lat').value);
lng = Number(document.getElementById('modal-lng').value);
} else {
lat = Number(document.getElementById('hs-lat').value);
lng = Number(document.getElementById('hs-lng').value);
if (!lat || !lng) {
showNotification('Vui lòng chọn vị trí GPS.', 'warning');
return;
}
}
const sceneData = new FormData();
sceneData.append('panorama', file);
sceneData.append('title', formData.get('title'));
sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại
sceneData.append('lng', lng);
sceneData.append('privacy', 'public');
uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => {
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id);
closeHotspotModal();
});
return;
}
const finalTargetId = formData.get('targetSceneId');
if (!finalTargetId) {
showNotification('Vui lòng chọn cảnh để liên kết.', 'warning');
return;
}
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id);
modal.style.display = 'none';
};
};
/**
* Đóng Modal biên tập Hotspot
*/
function closeHotspotModal() {
document.getElementById('hotspot-modal').style.display = 'none';
}
/**
* Khởi tạo hoặc cập nhật Mini Map trong Hotspot Modal
*/
function initHSMiniMap() {
const pLat = parseFloat(document.getElementById('modal-lat').value) || 21.0285;
const pLng = parseFloat(document.getElementById('modal-lng').value) || 105.8542;
if (miniMap) {
miniMap.setView([pLat, pLng], 15);
updateHSMiniMapMarker(pLat, pLng);
// Fix lỗi vỡ tiles của Leaflet trong Modal
setTimeout(() => miniMap.invalidateSize(), 200);
return;
}
miniMap = L.map('hs-mini-map').setView([pLat, pLng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(miniMap);
miniMap.on('click', (e) => {
const { lat, lng } = e.latlng;
updateHSMiniMapMarker(lat, lng);
});
updateHSMiniMapMarker(pLat, pLng);
setTimeout(() => miniMap.invalidateSize(), 200);
}
function updateHSMiniMapMarker(lat, lng) {
if (miniMapMarker) miniMap.removeLayer(miniMapMarker);
miniMapMarker = L.marker([lat, lng]).addTo(miniMap);
document.getElementById('hs-lat').value = lat.toFixed(6);
document.getElementById('hs-lng').value = lng.toFixed(6);
}
window.toggleHSLinkType = function(type) {
document.getElementById('hs-section-existing').style.display = type === 'existing' ? 'block' : 'none';
document.getElementById('hs-section-upload').style.display = type === 'upload' ? 'block' : 'none';
if (type === 'upload') {
const gpsMode = document.querySelector('input[name="hsGPSMode"]:checked')?.value || 'map';
window.toggleHSGPSMode(gpsMode);
}
};
window.toggleHSGPSMode = function(mode) {
document.getElementById('hs-map-selector').style.display = mode === 'map' ? 'block' : 'none';
document.getElementById('hs-manual-gps').style.display = mode === 'manual' ? 'block' : 'none';
if (mode === 'map') initHSMiniMap();
};
/**
* Chuyển đổi tab cũ (giữ lại để tương thích nếu cần)
*/
function switchHSTab(tabName) {
if (tabName === 'select') {
window.toggleHSLinkType('existing');
} else {
window.toggleHSLinkType('upload');
}
}
/**
* Ẩn/hiện input nhập GPS thủ công
*/
function toggleManualGPS() {
const mode = document.getElementById('hs-gps-mode').value;
const manualDiv = document.getElementById('hs-manual-gps');
manualDiv.style.display = mode === 'manual' ? 'block' : 'none';
}
/**
* Gửi dữ liệu lưu Hotspot lên Backend
*/
async function saveHotspotToDB(pitch, yaw, title, description, targetSceneId, hotspotId) {
const token = localStorage.getItem('jwt');
// Gọi đúng API create hoặc update tùy vào trạng thái
const url = hotspotId ? `${API_BASE_URL}/hotspots/update/${hotspotId}` : `${API_BASE_URL}/hotspots/create`;
const method = hotspotId ? 'PUT' : 'POST';
try {
const body = {
title,
description,
target_scene_id: targetSceneId,
coordinates: {
pitch: Number(pitch),
yaw: Number(yaw)
}
};
// Nếu tạo mới, cần gửi kèm ID của scene hiện tại làm parent
if (!hotspotId) {
body.parent_scene_id = currentSceneId;
}
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Lỗi khi lưu hotspot');
showNotification('Lưu điểm điều hướng thành công!', 'success');
// Buộc nạp lại để cập nhật danh sách hotspot mới
openScene(currentSceneId, localStorage.getItem('activeScenePrivacy'), localStorage.getItem('activeSceneToken'), true);
} catch (error) {
console.error(error);
showNotification(error.message, 'error');
}
}
/**
* Công cụ dọn dẹp toàn bộ dữ liệu (Chỉ dùng cho nhà phát triển)
* Gọi lệnh: systemReset() từ trình duyệt
*/
window.systemReset = async function() {
if (!confirm("CẢNH BÁO: Thao tác này sẽ xóa sạch TOÀN BỘ scene và ảnh trên server. Bạn có chắc chắn?")) return;
const token = localStorage.getItem('jwt');
try {
const response = await fetch(`${API_BASE_URL}/maintenance/reset-all`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (response.ok) {
localStorage.clear(); // Xóa sạch token, vị trí map, active scene
showNotification(data.message, 'success');
location.reload();
} else {
throw new Error(data.message);
}
} catch (e) {
showNotification("Lỗi reset: " + e.message, 'error');
}
};
/**
* Mở Menu tùy chọn cho Hotspot (Sửa/Xóa)
*/
window.openHotspotMenu = function(hotspot) {
const modal = document.getElementById('hotspot-action-modal');
const editBtn = document.getElementById('btn-hs-edit');
const deleteBtn = document.getElementById('btn-hs-delete');
modal.style.display = 'flex';
// Hành động Sửa: Mở form biên tập với dữ liệu cũ
editBtn.onclick = () => {
closeHotspotActionModal();
window.handleHotspotCreation(hotspot.pitch, hotspot.yaw, hotspot);
};
// Hành động Xóa: Xác nhận và gọi API xóa
deleteBtn.onclick = async () => {
if (confirm('Bạn có chắc chắn muốn xóa điểm điều hướng này?')) {
closeHotspotActionModal();
await deleteHotspot(hotspot._id);
}
};
};
/**
* Đóng Modal tùy chọn Hotspot
*/
function closeHotspotActionModal() {
document.getElementById('hotspot-action-modal').style.display = 'none';
}
/**
* Xóa Hotspot thông qua API
*/
async function deleteHotspot(hotspotId) {
const token = localStorage.getItem('jwt');
try {
// Gọi đúng API delete hotspot độc lập
const response = await fetch(`${API_BASE_URL}/hotspots/delete/${hotspotId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Lỗi xóa hotspot');
}
showNotification('Đã xóa điểm điều hướng.', 'success');
// Refresh lại scene hiện tại để cập nhật viewer
openScene(currentSceneId, null, null, true);
} catch (e) {
showNotification(e.message, 'error');
}
}
/**
* Toggles the visibility of the user dropdown menu.
*/
function toggleDropdown() {
document.getElementById('user-dropdown').classList.toggle('show');
}
/**
* Opens the user dashboard overlay.
*/
function openDashboard() {
const username = localStorage.getItem('username');
const role = localStorage.getItem('role');
if (username) {
document.getElementById('sidebar-avatar').innerText = username.charAt(0).toUpperCase();
document.getElementById('sidebar-username').innerText = username;
document.getElementById('sidebar-status').innerText = role || 'Thành viên';
}
document.getElementById('dashboard-overlay').style.display = 'flex';
document.getElementById('user-dropdown').classList.remove('show'); // Close dropdown
// Mở tab profile mặc định khi mở dashboard
openDashboardTab('profile');
}
/**
* Closes the user dashboard overlay.
*/
function closeDashboard() {
document.getElementById('dashboard-overlay').style.display = 'none';
}
/**
* Tải danh sách scene của chính người dùng đăng nhập
*/
async function loadMyScenes() {
const token = localStorage.getItem('jwt');
const listContainer = document.getElementById('my-scenes-list');
// Chuyển sang grid để đồng bộ với media library
listContainer.className = 'dashboard-grid';
listContainer.innerHTML = '<p>Đang tải danh sách...</p>';
try {
const res = await fetch(`${API_BASE_URL}/me/scenes`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const scenes = await res.json();
if (!res.ok) throw new Error(scenes.message);
listContainer.innerHTML = '';
if (scenes.length === 0) {
listContainer.innerHTML = '<p>Bạn chưa tạo scene nào.</p>';
return;
}
scenes.forEach(scene => {
const assetId = scene.assetId?._id || scene.assetId;
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
if (token) thumbUrl += `?token=${token}`;
const card = document.createElement('div');
card.className = 'scene-card';
card.style.backgroundImage = `url('${thumbUrl}')`;
// Logic hiển thị badge trạng thái
let statusBadge = '';
if (scene.status === 'processing') {
statusBadge = '<span class="status-badge processing">⏳ Đang xử lý 8K...</span>';
} else if (scene.status === 'failed') {
statusBadge = '<span class="status-badge failed">❌ Lỗi xử lý</span>';
}
card.innerHTML = `
<div class="scene-card-overlay">
<div class="scene-card-info">
<strong>${scene.name || scene.title}</strong>
<p class="scene-desc">${scene.description || 'Không có mô tả'}</p>
<div class="scene-card-meta">
<span>🔒 ${scene.privacy}</span>
<span>👤 ${scene.createdBy?.username || 'Bạn'}</span>
<span>📅 ${formatSystemDate(scene.createdAt)}</span>
<span>👁️ ${scene.views || 0} lượt xem</span>
</div>
${statusBadge}
</div>
<div class="media-actions" style="border: none; padding: 0;">
<button class="edit-btn-small" id="edit-scene-${scene._id}" ${scene.status === 'processing' ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>Sửa</button>
<button class="delete-btn-small" id="delete-scene-${scene._id}">Xóa</button>
<button class="edit-btn-small" id="view-stats-${scene._id}" style="background: #6f42c1;">Thống kê</button>
</div>
</div>
`;
listContainer.appendChild(card);
// Xử lý nút Sửa: Logic đóng dashboard -> mở modal -> quay lại dashboard
document.getElementById(`edit-scene-${scene._id}`).onclick = () => {
dashboardReturnTab = 'my-scenes';
returnToDashboardAfterEdit = true;
closeDashboard();
openEditMetadataModal(scene, scene.isChildScene);
};
// Xử lý nút Xóa (Sẽ được hoàn thiện ở Bước 4)
document.getElementById(`delete-scene-${scene._id}`).onclick = () => {
dashboardReturnTab = 'my-scenes';
returnToDashboardAfterEdit = true;
closeDashboard();
deleteScene(scene._id);
};
// Xử lý nút Thống kê
document.getElementById(`view-stats-${scene._id}`).onclick = () => {
showViewStatsModal(scene._id, scene.name || scene.title);
};
});
} catch (e) {
listContainer.innerHTML = `<p style="color:#ff4d4d">Lỗi: ${e.message}</p>`;
}
}
/**
* Tải danh sách người dùng dành cho Admin tối cao
*/
async function loadAdminUsers(page = 1) {
const token = localStorage.getItem('jwt');
const container = document.getElementById('admin-users-list');
const paginationContainer = document.getElementById('admin-users-pagination');
if (!container) return;
container.innerHTML = '<p>Đang tải danh sách người dùng...</p>';
if (paginationContainer) paginationContainer.innerHTML = '';
try {
const res = await fetch(`${API_BASE_URL}/admin/users?page=${page}&limit=10`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
const { users, totalPages, currentPage, totalUsers } = data;
let html = `
<table class="admin-table">
<thead>
<tr>
<th>Họ tên</th>
<th>Username</th>
<th>Email</th>
<th>Quyền hạn</th>
<th>Reset Password</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
`;
users.forEach(user => {
const isRootAdmin = user.role === 'admin';
html += `
<tr>
<td><input type="text" id="adm-fn-${user._id}" value="${user.fullName || ''}"></td>
<td><strong>${user.username}</strong></td>
<td><input type="email" id="adm-em-${user._id}" value="${user.email || ''}"></td>
<td>
<select id="adm-role-${user._id}" ${isRootAdmin ? 'disabled' : ''}>
<option value="user" ${user.role === 'user' ? 'selected' : ''}>User</option>
<option value="editor" ${user.role === 'editor' ? 'selected' : ''}>Editor</option>
<option value="moderator" ${user.role === 'moderator' ? 'selected' : ''}>Moderator</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</td>
<td><input type="password" id="adm-pw-${user._id}" placeholder="${isRootAdmin ? 'N/A' : 'Mật khẩu mới'}" ${isRootAdmin ? 'disabled' : ''}></td>
<td>
<button class="edit-btn-small" onclick="updateUserByAdmin('${user._id}')">Lưu</button>
${isRootAdmin ? '' : `<button class="delete-btn-small" onclick="deleteUserByAdmin('${user._id}')">Xóa</button>`}
</td>
</tr>
`;
});
html += '</tbody></table>';
container.innerHTML = html;
// Render Pagination UI
if (paginationContainer && totalPages > 1) {
paginationContainer.innerHTML = `
<button class="pagination-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="loadAdminUsers(${currentPage - 1})">Trang trước</button>
<span class="pagination-info">Trang ${currentPage} / ${totalPages} (${totalUsers} người dùng)</span>
<button class="pagination-btn" ${currentPage === totalPages ? 'disabled' : ''} onclick="loadAdminUsers(${currentPage + 1})">Trang sau</button>
`;
}
} catch (e) {
container.innerHTML = `<p style="color:red">Lỗi: ${e.message}</p>`;
}
}
window.updateUserByAdmin = async function(userId) {
const token = localStorage.getItem('jwt');
const payload = {
fullName: document.getElementById(`adm-fn-${userId}`).value,
email: document.getElementById(`adm-em-${userId}`).value,
role: document.getElementById(`adm-role-${userId}`).value,
password: document.getElementById(`adm-pw-${userId}`).value
};
try {
const res = await fetch(`${API_BASE_URL}/admin/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
showNotification(data.message, 'success');
loadAdminUsers();
} catch (e) { showNotification(e.message, 'error'); }
};
window.deleteUserByAdmin = async function(userId) {
if (!confirm('Xóa vĩnh viễn người dùng này?')) return;
const token = localStorage.getItem('jwt');
try {
const res = await fetch(`${API_BASE_URL}/admin/users/${userId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
// Hiển thị thông báo thành công kèm báo cáo dọn dẹp nếu có
let successMsg = data.message;
if (data.report) {
successMsg += `\n(Đã dọn dẹp: ${data.report.scenesDeleted} Scenes, ${data.report.filesRemoved} Files)`;
}
showNotification(successMsg, 'success');
loadAdminUsers();
} catch (e) { showNotification(e.message, 'error'); }
};
/**
* Giai đoạn 2 & 3: Các hàm xử lý bảo trì cho Admin tối cao
*/
window.openManualCleanupConfirm = function() {
const modal = document.getElementById('maintenance-confirm-modal');
document.getElementById('maintenance-confirm-title').innerText = "Dọn dẹp hệ thống";
document.getElementById('maintenance-confirm-desc').innerText = "Hệ thống sẽ quét và xóa bỏ tất cả các Scene, Hotspot và Asset không còn liên kết hợp lệ. Thao tác này không thể hoàn tác.";
document.getElementById('maintenance-action-type').value = 'cleanup';
document.getElementById('maintenance-confirm-btn').onclick = runManualCleanup;
modal.style.display = 'flex';
};
window.closeMaintenanceConfirm = () => {
document.getElementById('maintenance-confirm-modal').style.display = 'none';
};
async function runManualCleanup() {
const token = localStorage.getItem('jwt');
closeMaintenanceConfirm();
try {
const res = await fetch(`${API_BASE_URL}/admin/maintenance/cleanup`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
const report = data.report;
showSuccessModal(`Dọn dẹp hoàn tất!\n- Scenes xóa: ${report.scenesDeleted}\n- Files xóa: ${report.filesRemoved}`);
} catch (e) { showNotification(e.message, 'error'); }
}
window.checkStrayFiles = async function() {
const token = localStorage.getItem('jwt');
try {
const res = await fetch(`${API_BASE_URL}/admin/maintenance/stray-files`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
if (data.count === 0) {
showSuccessModal("Tuyệt vời! Không tìm thấy tệp tin rác nào trong hệ thống.", '✨');
} else {
const modal = document.getElementById('maintenance-confirm-modal');
document.getElementById('maintenance-confirm-title').innerText = "Phát hiện tệp tin rác";
document.getElementById('maintenance-confirm-desc').innerText = `Tìm thấy ${data.count} file trong thư mục uploads không có bản ghi trong Database. Bạn có muốn xóa chúng để tiết kiệm dung lượng?`;
document.getElementById('maintenance-action-type').value = 'stray';
document.getElementById('maintenance-confirm-btn').onclick = deleteStrayFiles;
modal.style.display = 'flex';
}
} catch (e) { showNotification(e.message, 'error'); }
};
async function deleteStrayFiles() {
const token = localStorage.getItem('jwt');
closeMaintenanceConfirm();
try {
const res = await fetch(`${API_BASE_URL}/admin/maintenance/cleanup?deleteStray=true`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
showSuccessModal(`Đã dọn dẹp sạch sẽ ${data.report.filesRemoved} tệp tin rác khỏi máy chủ.`);
loadMediaStats(); // Cập nhật lại dung lượng hiển thị
} catch (e) { showNotification(e.message, 'error'); }
}
/**
* Tải và hiển thị kho ảnh/media của người dùng
*/
async function loadMyAssets() {
const token = localStorage.getItem('jwt');
const gridContainer = document.getElementById('media-library-list');
const currentUserId = localStorage.getItem('userId');
const userRole = localStorage.getItem('role');
gridContainer.innerHTML = '<p>Đang tải kho ảnh...</p>';
try {
const res = await fetch(`${API_BASE_URL}/me/assets`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const assets = await res.json();
if (!res.ok) throw new Error(assets.message);
gridContainer.innerHTML = '';
if (assets.length === 0) {
gridContainer.innerHTML = '<p>Kho ảnh trống.</p>';
return;
}
assets.forEach(asset => {
const scene = asset.linkedScene;
const isTrash = !scene;
const parentNames = asset.parentScenes?.map(p => p.name || p.title).join(', ');
const card = document.createElement('div');
card.className = `media-card ${isTrash ? 'trash-item' : ''}`;
let statusBadge = '';
if (scene?.status === 'processing') {
statusBadge = '<span class="status-badge processing" style="position:static; margin-top:5px; display:inline-block;">⏳ Đang nén 8K...</span>';
} else if (scene?.status === 'failed') {
statusBadge = '<span class="status-badge failed" style="position:static; margin-top:5px; display:inline-block;">❌ Lỗi</span>';
}
// Build inner HTML without the onclick for edit/delete buttons
let innerHtml = `
<div class="media-thumb">
<img src="${API_BASE_URL}/assets/view/${asset._id}?token=${token}" alt="Thumbnail">
${isTrash ? '<span class="badge-trash">Ảnh rác</span>' : ''}
</div>
<div class="media-info">
<strong>${scene ? (scene.name || scene.title) : 'Chưa gắn Scene'}</strong>
<p class="desc">${scene?.description || 'Không có mô tả'}</p>
${parentNames ? `<p class="parent-link">🔗 Liên kết từ: ${parentNames}</p>` : ''}
${statusBadge}
<span class="date">Tải lên: ${formatSystemDate(asset.createdAt)}</span>
</div>
<div class="media-actions">
</div>
`;
card.innerHTML = innerHtml;
// Add edit button and its event listener separately
if (scene && asset.uploadedBy === currentUserId) {
const editButton = document.createElement('button');
editButton.className = 'edit-btn-small';
editButton.innerText = 'Sửa Scene';
if (scene.status === 'processing') editButton.disabled = true;
dashboardReturnTab = 'media-library';
const isChild = asset.parentScenes && asset.parentScenes.length > 0;
editButton.addEventListener('click', () => openEditFromMedia(scene, isChild));
card.querySelector('.media-actions').appendChild(editButton);
}
// Add delete button and its event listener
if (asset.uploadedBy === currentUserId || (isTrash && (userRole === 'admin' || userRole === 'Chủ sở hữu'))) {
const deleteButton = document.createElement('button');
deleteButton.className = 'delete-btn-small';
deleteButton.innerText = 'Xóa';
deleteButton.addEventListener('click', () => deleteAsset(asset._id));
card.querySelector('.media-actions').appendChild(deleteButton);
}
gridContainer.appendChild(card);
});
} catch (e) {
gridContainer.innerHTML = `<p style="color:#ff4d4d">Lỗi nạp media: ${e.message}</p>`;
}
}
/**
* Xóa ảnh khỏi kho media
*/
window.deleteAsset = function(assetId) {
assetIdToDelete = assetId;
document.getElementById('delete-asset-confirm-modal').style.display = 'flex';
};
window.closeDeleteAssetModal = function() {
document.getElementById('delete-asset-confirm-modal').style.display = 'none';
assetIdToDelete = null;
};
window.confirmDeleteAsset = async function() {
if (!assetIdToDelete) return;
const token = localStorage.getItem('jwt');
try {
const res = await fetch(`${API_BASE_URL}/assets/${assetIdToDelete}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
let data;
const contentType = res.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
data = await res.json();
}
if (!res.ok) {
throw new Error(data?.message || `Lỗi máy chủ (${res.status})`);
}
closeDeleteAssetModal();
showNotification(data.message || "Đã xóa thành công", 'success');
loadMyAssets(); // Nạp lại kho ảnh
loadScenes(); // Nạp lại bản đồ nếu có scene bị xóa kèm theo
} catch (e) {
showNotification("Lỗi khi xóa: " + e.message, 'error');
}
};
/**
* Hiển thị Modal thông báo thành công
*/
window.showSuccessModal = function(message, icon = '✓') {
const modal = document.getElementById('success-modal');
const msgElem = document.getElementById('success-modal-message');
const iconElem = document.getElementById('success-modal-icon');
if (modal && msgElem && iconElem) {
msgElem.innerText = message;
iconElem.innerText = icon;
modal.style.display = 'flex';
// Tự động ẩn sau 3 giây
setTimeout(() => closeSuccessModal(), 3000);
}
};
/**
* Đóng Modal thành công (hỗ trợ click ra ngoài)
*/
window.closeSuccessModal = function(e) {
const modal = document.getElementById('success-modal');
if (!modal) return;
// Nếu nhấn từ code (không có e) hoặc click trúng overlay thì đóng
if (e && e.target !== modal) return;
modal.style.display = 'none';
};
/**
* Hiển thị Modal thông báo lỗi hoặc cảnh báo
*/
window.showErrorModal = function(message, title = "Thông báo", icon = '⚠️') {
const modal = document.getElementById('error-modal');
const msgElem = document.getElementById('error-modal-message');
const titleElem = document.getElementById('error-modal-title');
const iconElem = document.getElementById('error-modal-icon');
if (modal && msgElem && iconElem) {
msgElem.innerText = message;
iconElem.innerText = icon;
if (titleElem) titleElem.innerText = title;
modal.style.display = 'flex';
}
};
/**
* Đóng Modal lỗi (hỗ trợ click ra ngoài)
*/
window.closeErrorModal = function(e) {
const modal = document.getElementById('error-modal');
if (!modal) return;
// Nếu nhấn từ code (không có e) hoặc click trúng overlay thì đóng
if (e && e.target !== modal) return;
modal.style.display = 'none';
};
/**
* Hàm thông báo dùng chung thay thế alert()
*/
window.showNotification = function(message, type = 'success') {
if (type === 'success') {
showSuccessModal(message, '✓');
} else if (type === 'warning') {
showErrorModal(message, 'Cảnh báo', '⚠️');
} else {
showErrorModal(message, 'Lỗi', '❌');
}
};
window.openEditFromMedia = function(scene, isChild = false) {
if (!scene || !scene._id) {
showNotification("Không thể chỉnh sửa: Ảnh này không được gắn với một Scene hợp lệ.", 'error');
return;
}
dashboardReturnTab = 'media-library';
returnToDashboardAfterEdit = true;
closeDashboard();
openEditMetadataModal(scene, isChild);
};
/**
* Mở Modal sửa thông tin Metadata chuyên biệt
*/
window.openEditMetadataModal = function(scene, isChildArg = null) {
// Ưu tiên isChildScene từ object scene, hoặc giá trị truyền vào thủ công
const isChild = isChildArg !== null ? isChildArg : (!!scene.isChildScene);
currentEditingScene = scene; // Lưu lại để dùng cho chia sẻ
// Load dữ liệu chia sẻ hiện tại
sharedUsersData = scene.sharedWith || [];
sharedEmailsData = scene.sharedEmails || [];
document.getElementById('edit-modal-scene-id').value = scene._id;
document.getElementById('edit-modal-title').value = scene.name || scene.title || '';
document.getElementById('edit-modal-description').value = scene.description || '';
const lat = scene.gps?.lat || scene.lat;
const lng = scene.gps?.lng || scene.lng;
document.getElementById('edit-modal-lat').value = lat;
document.getElementById('edit-modal-lng').value = lng;
// Xử lý logic Privacy cho Cảnh con
const privacySelect = document.getElementById('edit-modal-privacy');
const childInfo = document.getElementById('edit-child-privacy-info');
if (isChild) {
privacySelect.value = scene.privacy;
privacySelect.disabled = true;
childInfo.style.display = 'block';
} else {
privacySelect.value = scene.privacy;
privacySelect.disabled = false;
childInfo.style.display = 'none';
}
handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng
document.getElementById('edit-scene-metadata-modal').style.display = 'flex';
// Khởi tạo Mini Map tại vị trí hiện tại của Scene
setTimeout(() => initEditSceneMiniMap(lat, lng), 100);
};
function closeEditMetadataModal() {
document.getElementById('edit-scene-metadata-modal').style.display = 'none';
if (returnToDashboardAfterEdit) {
const targetTab = dashboardReturnTab;
returnToDashboardAfterEdit = false;
openDashboard();
openDashboardTab(targetTab);
}
}
function initEditSceneMiniMap(lat, lng) {
if (editMiniMap) {
editMiniMap.setView([lat, lng], 16);
if (editMiniMapMarker) editMiniMapMarker.setLatLng([lat, lng]);
editMiniMap.invalidateSize();
return;
}
editMiniMap = L.map('edit-mini-map').setView([lat, lng], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(editMiniMap);
editMiniMapMarker = L.marker([lat, lng], { draggable: true }).addTo(editMiniMap);
editMiniMap.on('click', (e) => {
const { lat, lng } = e.latlng;
editMiniMapMarker.setLatLng([lat, lng]);
document.getElementById('edit-modal-lat').value = lat.toFixed(6);
document.getElementById('edit-modal-lng').value = lng.toFixed(6);
});
}
/**
* Xử lý khi thay đổi Dropdown Privacy trong Modal sửa
*/
window.handleEditPrivacyChange = function() {
const privacy = document.getElementById('edit-modal-privacy').value;
const settingsBtn = document.getElementById('btn-edit-privacy-settings');
const isChild = document.getElementById('edit-modal-privacy').disabled;
if (!isChild && (privacy === 'member' || privacy === 'shared' || privacy === 'public')) {
settingsBtn.style.display = 'block';
} else {
settingsBtn.style.display = 'none';
}
};
/**
* Mở modal cài đặt chi tiết dựa trên loại Privacy
*/
window.openPrivacySettingsModal = function() {
const privacy = document.getElementById('edit-modal-privacy').value;
if (privacy === 'member') {
renderSharedList();
document.getElementById('share-member-modal').style.display = 'flex';
} else if (privacy === 'shared' || privacy === 'public') {
showShareLink(currentEditingScene);
}
};
/**
* Hiển thị modal lấy link chia sẻ (dùng cho cả khách và chủ sở hữu)
* @param {Object} scene - Đối tượng Scene cần lấy link
*/
window.showShareLink = function(scene) {
if (!scene) return;
// Lưu lại scene đang tương tác để các logic phụ trợ hoạt động đồng bộ
currentEditingScene = scene;
// Trỏ link vào endpoint /api/share/ để hỗ trợ Open Graph (ảnh thumbnail Facebook/Zalo)
const baseUrl = window.location.origin + '/api/share/';
const token = scene.shareToken || '';
document.getElementById('shared-link-input').value = `${baseUrl}${scene._id}${token ? '?token=' + token : ''}`;
document.getElementById('share-link-modal').style.display = 'flex';
};
/**
* Tìm kiếm người dùng để chia sẻ
*/
window.searchUsersToShare = async function(query) {
const dropdown = document.getElementById('search-results-dropdown');
if (!query || query.length < 2) {
dropdown.style.display = 'none';
return;
}
const token = localStorage.getItem('jwt');
try {
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const users = await res.json();
dropdown.innerHTML = '';
if (users.length > 0) {
users.forEach(user => {
const item = document.createElement('div');
item.className = 'search-item';
item.innerText = `${user.username} (${user.email})`;
item.onclick = () => addMemberToShare(user);
dropdown.appendChild(item);
});
dropdown.style.display = 'block';
} else {
// Nếu không tìm thấy user, cho phép thêm email thủ công
if (query.includes('@')) {
dropdown.innerHTML = `<div class="search-item" onclick="addEmailToShare('${query}')">Thêm email: ${query}</div>`;
dropdown.style.display = 'block';
} else {
dropdown.style.display = 'none';
}
}
} catch (e) { console.error(e); }
};
function addMemberToShare(user) {
if (!sharedUsersData.some(u => (u._id || u) === user._id)) {
sharedUsersData.push(user);
renderSharedList();
}
document.getElementById('share-user-search').value = '';
document.getElementById('search-results-dropdown').style.display = 'none';
}
window.addEmailToShare = function(email) {
if (!sharedEmailsData.includes(email)) {
sharedEmailsData.push(email);
renderSharedList();
}
document.getElementById('share-user-search').value = '';
document.getElementById('search-results-dropdown').style.display = 'none';
};
function renderSharedList() {
const list = document.getElementById('current-shared-list');
list.innerHTML = '';
sharedUsersData.forEach(user => {
const name = user.username || 'User';
list.innerHTML += `<div class="share-list-item">👤 ${name} <span class="remove-share-btn" onclick="removeShared('user', '${user._id || user}')">&times;</span></div>`;
});
sharedEmailsData.forEach(email => {
list.innerHTML += `<div class="share-list-item">📧 ${email} <span class="remove-share-btn" onclick="removeShared('email', '${email}')">&times;</span></div>`;
});
}
window.removeShared = function(type, id) {
if (type === 'user') sharedUsersData = sharedUsersData.filter(u => (u._id || u) !== id);
else sharedEmailsData = sharedEmailsData.filter(e => e !== id);
renderSharedList();
};
window.closeShareMemberModal = () => document.getElementById('share-member-modal').style.display = 'none';
window.closeShareLinkModal = () => document.getElementById('share-link-modal').style.display = 'none';
/**
* Copy link chia sẻ và đóng modal
*/
window.copySharedLink = function() {
const linkInput = document.getElementById('shared-link-input');
linkInput.select();
navigator.clipboard.writeText(linkInput.value).then(() => {
showSuccessModal("Đã sao chép liên kết vào bộ nhớ!");
closeShareLinkModal();
});
};
async function submitEditScene(e) {
e.preventDefault();
const id = document.getElementById('edit-modal-scene-id').value;
const token = localStorage.getItem('jwt');
// Sử dụng FormData vì API Backend hiện tại đang dùng Multer
const formData = new FormData();
formData.append('title', document.getElementById('edit-modal-title').value);
formData.append('description', document.getElementById('edit-modal-description').value);
formData.append('lat', document.getElementById('edit-modal-lat').value);
formData.append('lng', document.getElementById('edit-modal-lng').value);
formData.append('privacy', document.getElementById('edit-modal-privacy').value);
formData.append('shareExpireDays', document.getElementById('share-link-expire').value);
// Đính kèm dữ liệu chia sẻ nâng cao
formData.append('sharedWithUsers', JSON.stringify(sharedUsersData.map(u => u._id || u)));
formData.append('sharedEmails', JSON.stringify(sharedEmailsData));
try {
const res = await fetch(`${API_BASE_URL}/scenes/${id}`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
showNotification("Đã cập nhật thông tin cảnh thành công!", 'success');
closeEditMetadataModal();
loadScenes();
} catch (err) {
showNotification("Lỗi cập nhật: " + err.message, 'error');
}
}
/**
* Xử lý tải xuống bản sao lưu
*/
async function handleBackup() {
const token = localStorage.getItem('jwt');
try {
showNotification("Đang chuẩn bị bản sao lưu...", "success");
const response = await fetch(`${API_BASE_URL}/admin/backup`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error("Lỗi khi tạo backup");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup_3dtour_${new Date().toISOString().slice(0,10)}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
showNotification("Đã tải xuống bản sao lưu!", "success");
} catch (e) { showNotification(e.message, "error"); }
}
/**
* Xử lý khôi phục dữ liệu từ file zip
*/
async function handleRestore(input) {
if (!input.files || !input.files[0]) return;
if (!confirm("CẢNH BÁO: Khôi phục dữ liệu sẽ xóa sạch dữ liệu hiện tại và thay thế bằng dữ liệu từ bản sao lưu. Bạn có chắc chắn?")) {
input.value = ''; return;
}
const token = localStorage.getItem('jwt');
const formData = new FormData();
formData.append('backupFile', input.files[0]);
try {
showNotification("Đang khôi phục... Vui lòng không đóng trình duyệt.", "success");
const response = await fetch(`${API_BASE_URL}/admin/restore`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);
showNotification("Khôi phục thành công! Hệ thống sẽ tải lại.", "success");
setTimeout(() => location.reload(), 2000);
} catch (e) {
showNotification(e.message, "error");
input.value = '';
}
}
/**
* Cập nhật cấu hình hệ thống
*/
async function updateSystemSettings(e) {
e.preventDefault();
const token = localStorage.getItem('jwt');
const timezone = document.getElementById('sys-timezone').value;
const language = document.getElementById('sys-language').value;
try {
const res = await fetch(`${API_BASE_URL}/system/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ timezone, language })
});
if (res.ok) {
showNotification("Cấu hình hệ thống đã được lưu!", "success");
// Tải lại cài đặt để áp dụng ngôn ngữ mới ngay lập tức
fetchSystemSettings();
}
} catch (err) {
showNotification("Lỗi lưu cấu hình: " + err.message, "error");
}
}
let viewStatsChartInstance = null; // Biến để lưu instance của Chart.js
/**
* Mở modal hiển thị biểu đồ thống kê lượt xem
*/
async function showViewStatsModal(sceneId, sceneTitle) {
const token = localStorage.getItem('jwt');
const modal = document.getElementById('view-stats-modal');
const titleElem = document.getElementById('view-stats-modal-title');
const chartCanvas = document.getElementById('view-stats-chart');
if (titleElem) titleElem.innerText = `Thống kê lượt xem: ${sceneTitle}`;
modal.style.display = 'flex';
try {
const res = await fetch(`${API_BASE_URL}/me/scenes/${sceneId}/view-stats`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const viewHistory = await res.json();
if (!res.ok) throw new Error(viewHistory.message);
// Chuẩn bị dữ liệu cho biểu đồ
const labels = [];
const data = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
// Lấy dữ liệu 30 ngày gần nhất
for (let i = 29; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
labels.push(d.toLocaleDateString(systemSettings.language === 'vi' ? 'vi-VN' : 'en-US', { day: '2-digit', month: '2-digit' }));
const entry = viewHistory.find(vh => new Date(vh.date).setHours(0,0,0,0) === d.getTime());
data.push(entry ? entry.count : 0);
}
// Nếu có instance cũ, hủy nó đi trước khi tạo mới
if (viewStatsChartInstance) {
viewStatsChartInstance.destroy();
}
viewStatsChartInstance = new Chart(chartCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Lượt xem',
data: data,
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.2)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
});
} catch (e) {
showNotification("Không thể tải thống kê lượt xem: " + e.message, 'error');
closeViewStatsModal();
}
}
/**
* Đóng modal thống kê lượt xem
*/
window.closeViewStatsModal = function() {
document.getElementById('view-stats-modal').style.display = 'none';
if (viewStatsChartInstance) {
viewStatsChartInstance.destroy(); // Hủy biểu đồ để giải phóng bộ nhớ
viewStatsChartInstance = null;
}
};
/**
* Opens a specific tab within the dashboard.
* @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').
*/
function openDashboardTab(tabName) {
// Ẩn tất cả các tab pane
document.querySelectorAll('.dashboard-tab-pane').forEach(pane => {
pane.classList.remove('active');
});
// Bỏ active khỏi tất cả các nút tab
document.querySelectorAll('.dashboard-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active');
});
// Hiển thị tab pane được chọn
const selectedPane = document.getElementById(`tab-${tabName}`);
if (selectedPane) {
selectedPane.classList.add('active');
// Nếu là tab profile, cập nhật nội dung
if (tabName === 'profile') {
updateProfileTabContent();
}
if (tabName === 'my-scenes') {
loadMyScenes();
}
if (tabName === 'media-library') {
loadMediaStats();
loadMyAssets();
}
if (tabName === 'user-management') {
loadAdminUsers();
}
}
// Đánh dấu nút tab được chọn là active
const selectedTabButton = document.querySelector(`.dashboard-tabs .tab-btn[onclick="openDashboardTab('${tabName}')"]`);
if (selectedTabButton) {
selectedTabButton.classList.add('active');
}
// Cập nhật avatar initials khi mở dashboard
const username = localStorage.getItem('username');
if (username) {
document.getElementById('avatar-initials').innerText = username.charAt(0).toUpperCase();
}
}