Files
3dtours/frontend/js/main_map.js
T

3157 lines
129 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 processingPollingInterval = null;
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 {
// 0. Kiểm tra tham số URL để truy cập trực tiếp
const urlParams = new URLSearchParams(window.location.search);
let urlSceneId = urlParams.get('sceneId');
const urlToken = urlParams.get('token');
// Hỗ trợ lấy Scene ID từ pathname (trường hợp truy cập qua /api/share/:id)
if (!urlSceneId && window.location.pathname.includes('/api/share/')) {
const pathParts = window.location.pathname.split('/').filter(Boolean);
urlSceneId = pathParts.pop(); // Lấy phần tử ID cuối cùng trong URL
}
// Ưu tiên nạp cấu hình hệ thống trước
fetchSystemSettings();
if (document.getElementById('map')) {
initMap();
}
// Chạy tuần tự để tránh xung đột luồng xử lý
checkAuthStatus(); // 2. Kiểm tra đăng nhập
// 2.1. Kiểm tra xem server đã có Admin chưa (dành cho cài đặt mới)
checkSystemInitialization();
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) {
// Nạp marker kèm theo token từ URL nếu có (dành cho Guest xem tour shared)
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get('token');
loadScenes(urlToken);
}
} 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í tour",
tabMedia: "Quản lí ảnh và media",
tabUsers: "Quản lí người dùng",
tabSystem: "Cài đặt hệ thống",
btnRecalculate: "Tính toán lại vị trí Tour"
},
en: {
brand: "Virtual 3D Tour Map",
login: "Login / Register",
profile: "Manage Profile",
logout: "Logout",
dashboardTitle: "User Dashboard",
tabProfile: "Profile",
tabScenes: "My Tours",
tabMedia: "Media Library",
tabUsers: "User Management",
tabSystem: "System Settings",
btnRecalculate: "Recalculate Tour Centers"
}
};
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;
const recalculateBtn = document.getElementById('btn-recalculate-tours');
if (recalculateBtn) recalculateBtn.innerText = t.btnRecalculate;
}
/**
* 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();
}
}
/**
* Kiểm tra xem người dùng có đang dùng thiết bị di động hay không
*/
function isMobileDevice() {
return (window.innerWidth <= 768) ||
(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
}
/**
* 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: !isMobileDevice(), // Ẩn nút +/- trên mobile để lấy thêm không gian hiển thị
attributionControl: false,
tap: true // Hỗ trợ click trên thiết bị cảm ứng tốt hơn
}).setView([startLat, startLng], startZoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
// Khởi tạo Marker Cluster Group CHỈ dành cho Scene (Ảnh mẹ)
markerClusterGroup = L.markerClusterGroup({
zoomToBoundsOnClick: false, // Tắt tự động zoom để xử lý logic tách Tour thủ công
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>' });
}
});
// Nếu các Tour được gộp lại cùng nhau (Cluster): Tách các Tour callout ra (Spiderfy)
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'); // Kiểm tra token để đảm bảo người dùng đã đăng nhập
if (!token) return;
const { lat, lng } = e.latlng;
openCreateTourModal(lat, lng); // Mở modal tạo Tour thay vì Scene
});
}
/**
* 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
}
}
/**
* Kiểm tra trạng thái khởi tạo của hệ thống
*/
async function checkSystemInitialization() {
const token = localStorage.getItem('jwt');
if (token) return; // Nếu đã đăng nhập thì bỏ qua
try {
const res = await fetch(`${API_BASE_URL}/auth/init-status`);
const data = await res.json();
if (data && data.initialized === false) {
showAdminSetupWizard();
}
} catch (e) {
console.error("Không thể kiểm tra trạng thái khởi tạo hệ thống:", e);
}
}
/**
* Hiển thị giao diện thiết lập Admin tối cao
*/
function showAdminSetupWizard() {
// Mở dropdown và chuyển sang tab đăng ký
const dropdown = document.getElementById('user-dropdown');
if (dropdown) dropdown.classList.add('show');
switchAuthMode('register');
// Tùy chỉnh giao diện cho chế độ thiết lập
const regBtn = document.querySelector('#register-section .auth-submit-btn');
if (regBtn) regBtn.innerText = 'Thiết lập Admin tối cao';
showNotification("Hệ thống mới: Vui lòng đăng ký tài khoản Admin đầu tiên để quản trị server.", "warning");
}
/**
* 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
}
/**
* Mở Modal để tạo Tour mới và điền sẵn tọa độ
*/
function openCreateTourModal(lat, lng) {
const token = localStorage.getItem('jwt');
if (!token) {
showNotification('Vui lòng đăng nhập trước để tạo Tour.', 'error');
return;
}
// Đặt marker tạm thời trên bản đồ
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng]).addTo(map);
const tourIdInput = document.getElementById('tour-id');
if (tourIdInput) tourIdInput.value = '';
document.getElementById('create-tour-modal').style.display = 'flex';
document.getElementById('tour-name').value = '';
document.getElementById('tour-description').value = '';
document.getElementById('tour-privacy').value = 'private';
document.getElementById('tour-lat').value = lat.toFixed(6);
document.getElementById('tour-lng').value = lng.toFixed(6);
const lang = systemSettings.language || 'vi';
const modalTitle = document.getElementById('create-tour-modal-title');
if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Tạo Tour 3D mới" : "Create New 3D Tour";
}
/**
* Đóng Modal tạo Tour
*/
function closeTourModal() {
const modal = document.getElementById('create-tour-modal');
if (modal) {
modal.style.display = 'none';
modal.removeAttribute('data-original-display');
}
if (tempMarker) {
map.removeLayer(tempMarker);
tempMarker = null;
}
document.getElementById('create-tour-form').reset();
if (returnToDashboardAfterEdit) {
const targetTab = dashboardReturnTab;
returnToDashboardAfterEdit = false;
openDashboard();
openDashboardTab(targetTab);
}
}
/**
* Mở Modal để chỉnh sửa thông tin Tour
*/
function openEditTourModal(tour) {
const token = localStorage.getItem('jwt');
if (!token) return;
const isDashboardOpen = document.getElementById('dashboard-overlay').style.display === 'flex';
if (isDashboardOpen) {
dashboardReturnTab = 'my-scenes';
returnToDashboardAfterEdit = true;
closeDashboard();
} else {
returnToDashboardAfterEdit = false;
}
const tourIdInput = document.getElementById('tour-id');
if (tourIdInput) tourIdInput.value = tour._id;
document.getElementById('create-tour-modal').style.display = 'flex';
document.getElementById('tour-name').value = tour.name || '';
document.getElementById('tour-description').value = tour.description || '';
document.getElementById('tour-privacy').value = tour.privacy || 'private';
document.getElementById('tour-lat').value = (tour.location?.lat || 0).toFixed(6);
document.getElementById('tour-lng').value = (tour.location?.lng || 0).toFixed(6);
const lang = systemSettings.language || 'vi';
const modalTitle = document.getElementById('create-tour-modal-title');
if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Chỉnh sửa Tour" : "Edit Tour";
}
/**
* Gửi dữ liệu tạo Tour lên Backend
*/
async function submitTour(e) {
e.preventDefault();
const token = localStorage.getItem('jwt');
const tourId = document.getElementById('tour-id')?.value;
const name = document.getElementById('tour-name').value.trim();
const description = document.getElementById('tour-description').value.trim();
const privacy = document.getElementById('tour-privacy').value;
const lat = document.getElementById('tour-lat').value;
const lng = document.getElementById('tour-lng').value;
const url = tourId ? `${API_BASE_URL}/tours/${tourId}` : `${API_BASE_URL}/tours`;
const method = tourId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ name, description, privacy, lat, lng })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
// 1. Đóng modal Tour ngay lập tức để giải phóng giao diện
closeTourModal();
// 2. Hiển thị thông báo
showNotification(tourId ? 'Tour đã được cập nhật thành công!' : 'Tour đã được tạo thành công!', 'success');
// 3. Làm mới bản đồ để cập nhật các marker (đặc biệt quan trọng khi di chuyển vị trí Tour)
loadScenes();
if (!tourId) {
// Nếu là Tour mới, tự động chuyển sang Modal tạo Scene để upload ảnh
openCreateSceneModal(Number(lat), Number(lng), data.tour._id); // Mở modal tạo Scene, truyền tourId
} else {
// Nếu đang trong Dashboard, cập nhật lại danh sách hiển thị
if (document.getElementById('dashboard-overlay').style.display === 'flex') {
loadMyTours();
}
}
} catch (err) {
showNotification("Lỗi: " + err.message, 'error');
}
}
/**
* Opens Modal for creating a Scene and sets lat/lng inputs
* @param {number} lat - Vĩ độ
* @param {number} lng - Kinh độ
* @param {string|null} tourId - ID của Tour cha (nếu có)
*/
function openCreateSceneModal(lat, lng, tourId = null) {
returnToDashboardAfterEdit = false;
const token = localStorage.getItem('jwt');
if (!token) {
showNotification('Please log in first to create a 3D scene.', 'error');
return;
}
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 tourIdInput = document.getElementById('modal-tour-id');
if (tourIdInput) tourIdInput.value = tourId || ''; // Điền tourId nếu có
if (tourId) localStorage.setItem('activeTourId', tourId); // Lưu tourId vào localStorage
else localStorage.removeItem('activeTourId'); // Xóa nếu không có tourId (tạo cảnh độc lập)
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() {
const tourModal = document.getElementById('create-tour-modal');
if (tourModal) {
tourModal.style.display = 'none';
tourModal.removeAttribute('data-original-display');
}
const sceneModal = document.getElementById('create-scene-modal');
if (sceneModal) {
sceneModal.style.display = 'none';
sceneModal.removeAttribute('data-original-display');
}
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';
formData.append('tourId', document.getElementById('modal-tour-id').value); // Đảm bảo tourId được gửi
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
* @param {string|null} urlToken - Token từ URL chia sẻ (nếu có)
*/
async function loadScenes(urlToken = null) {
try {
const token = localStorage.getItem('jwt');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let url = `${API_BASE_URL}/scenes?_=${new Date().getTime()}`;
if (urlToken) url += `&token=${urlToken}`;
const response = await fetch(url, {
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(`[Frontend] loadScenes received ${scenes.length} scenes. User role: ${localStorage.getItem('role') || 'Guest'}`);
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');
let foundProcessing = 0;
const seenTours = new Set(); // Sử dụng Set để lọc duy nhất một Marker cho mỗi Tour
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
scenes.forEach((scene) => {
// 0. Lọc theo Tour: Mỗi Tour chỉ hiển thị 1 Callout đại diện trên bản đồ
// Ưu tiên hiển thị Scene gốc của Tour hoặc Scene đầu tiên tìm thấy
const tourId = scene.tourId?._id || scene.tourId;
if (!tourId) return;
const tourIdStr = tourId.toString();
if (seenTours.has(tourIdStr)) return;
seenTours.add(tourIdStr);
// 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.warn(`[Frontend] Bỏ qua Scene "${scene.name || scene.title}" (ID: ${scene._id}) do tọa độ lỗi:`, scene);
return;
}
// 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";
const tourName = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.name : sceneName;
const tourDescription = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.description : scene.description;
const isProcessing = scene.status === 'processing';
if (isProcessing) foundProcessing++;
let thumbHtml = '';
if (isProcessing) {
thumbHtml = `<div class="processing-overlay">
<div class="spinner-icon">⏳</div>
<div style="font-size: 8px;">Đang nén 8K</div>
</div>`;
} else {
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
if (token) thumbUrl += `?token=${token}`;
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
thumbHtml = `<img src="${thumbUrl}" alt="${sceneName}" loading="lazy">`;
}
const calloutIcon = L.divIcon({
className: `custom-scene-marker ${isProcessing ? 'is-processing' : ''}`,
html: `
<div class="scene-callout">
<div class="scene-img-wrapper">
${thumbHtml}
</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: tourName
});
// 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>${tourName}</strong><br>
${tourDescription ? `<small>${tourDescription}</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'
});
// Trường hợp Tour không gộp lại (Marker đơn lẻ): Di chuyển thẳng vào Tour
marker.on('click', () => {
// Luôn di chuyển vào cảnh gốc của Tour
if (scene.tourId && scene.tourId.rootSceneId) {
openScene(scene.tourId.rootSceneId, scene.tourId.privacy, scene.tourId.shareToken || '');
} else {
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' || userRole === 'moderator';
const isOwner = currentUserId && ownerId && ownerId.toString() === currentUserId.toString();
if (isAdmin || isOwner) {
handleEditDeleteScene(scene);
} else {
// Kiểm tra xem Tour hoặc Scene có công khai (Public) không để khách có thể lấy link chia sẻ
const isPublic = (scene.privacy === 'public') || (scene.tourId && scene.tourId.privacy === 'public');
if (isPublic) {
showShareLink(scene);
} else {
showNotification("Bạn không có quyền lấy link chia sẻ của nội dung riêng tư này.", 'warning');
}
}
});
markersToAdd.push(marker);
});
// Thêm danh sách marker đã lọc vào group
markerClusterGroup.addLayers(markersToAdd);
// Quản lý việc tự động cập nhật bản đồ khi có scene đang xử lý
if (foundProcessing > 0) {
if (!processingPollingInterval) {
processingPollingInterval = setInterval(() => loadScenes(), 5000);
}
} else {
if (processingPollingInterval) {
clearInterval(processingPollingInterval);
processingPollingInterval = null;
}
}
} 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');
const desc = document.getElementById('action-modal-desc');
const tour = scene.tourId; // Tour đã được populate từ backend
title.innerText = tour ? `Tour: ${tour.name}` : `Scene: ${scene.title}`;
desc.innerText = tour ? "Bạn muốn thực hiện thao tác gì với Tour này?" : "Bạn muốn thực hiện thao tác gì với scene này?";
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();
if (tour) {
openEditTourModal(tour);
} else {
openEditMetadataModal(scene, scene.isChildScene);
}
};
// Cập nhật nhãn và sự kiện cho nút Sửa
editBtn.innerHTML = tour ? '<span class="icon">✏️</span> Sửa thông tin Tour' : '<span class="icon">✏️</span> Chế độ sửa scene';
editBtn.onclick = () => {
returnToDashboardAfterEdit = false;
closeActionModal();
if (tour) {
openEditTourModal(tour);
} else {
openEditMetadataModal(scene, scene.isChildScene);
}
};
// Cập nhật nhãn và sự kiện cho nút Xóa
deleteBtn.innerHTML = tour ? '<span class="icon">🗑️</span> Xóa vĩnh viễn Tour' : '<span class="icon">🗑️</span> Xóa vĩnh viễn';
deleteBtn.onclick = async () => {
returnToDashboardAfterEdit = false; // Đảm bảo không mở dashboard nếu xóa từ map
closeActionModal();
if (tour) {
// Tái sử dụng logic xóa tour từ dashboard
if (await window.showConfirmModal(`Bạn có chắc muốn xóa Tour "${tour.name}" và toàn bộ cảnh bên trong?`)) {
confirmDeleteTourFromMap(tour._id);
}
} else {
deleteScene(scene._id);
}
};
}
/**
* Closes the Action Choice Modal
*/
function closeActionModal() {
document.getElementById('action-choice-modal').style.display = 'none';
}
/**
* Xóa Tour trực tiếp từ Map
*/
async function confirmDeleteTourFromMap(tourId) {
const token = localStorage.getItem('jwt');
try {
const res = await fetch(`${API_BASE_URL}/tours/${tourId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
showNotification("Đã xóa Tour thành công", "success");
loadScenes(); // Tải lại bản đồ
}
} catch (e) { showNotification("Lỗi xóa tour", "error"); }
}
/**
* Mở modal xác nhận xóa scene
*/
window.deleteScene = async function(sceneId, sceneData = null) { // Thêm sceneData để tránh fetch lại
sceneIdToDelete = sceneId;
// Tạm thời đóng dashboard nếu nó đang mở
const isDashboardOpen = document.getElementById('dashboard-overlay').style.display === 'flex';
if (isDashboardOpen) {
dashboardReturnTab = 'my-scenes';
returnToDashboardAfterEdit = true;
closeDashboard();
} else {
returnToDashboardAfterEdit = false;
}
const confirmModal = document.getElementById('delete-scene-confirm-modal');
const confirmMessageElem = document.getElementById('delete-scene-confirm-message'); // Giả định có element này trong HTML
if (!confirmModal || !confirmMessageElem) {
console.error("Delete confirmation modal elements not found.");
return;
}
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;
}
let sceneToConfirm = sceneData;
if (!sceneToConfirm) {
try {
// Fetch scene details nếu chưa có sẵn (ví dụ: xóa từ bản đồ)
const response = await fetch(`${API_BASE_URL}/scenes/${sceneId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
sceneToConfirm = await response.json();
if (!response.ok) throw new Error(sceneToConfirm.message || 'Failed to fetch scene details for deletion.');
} catch (error) {
showNotification("Không thể chuẩn bị xóa scene: " + error.message, 'error');
return;
}
}
let message = '';
if (sceneToConfirm.isChildScene) {
message = `Bạn đang xóa cảnh "${sceneToConfirm.name || sceneToConfirm.title}". Cảnh này là một phần của tour khác. Việc xóa sẽ chỉ gỡ bỏ cảnh này và các liên kết đến nó. Các cảnh cha sẽ không bị ảnh hưởng. Bạn có chắc chắn muốn xóa?`;
} else {
message = `Bạn đang xóa cảnh "${sceneToConfirm.name || sceneToConfirm.title}". Cảnh này có thể là cảnh gốc hoặc cảnh không có liên kết đến. Việc xóa sẽ gỡ bỏ cảnh này VÀ TẤT CẢ CÁC CẢNH CON liên kết với nó trong tour. Thao tác này không thể hoàn tác. Bạn có chắc chắn muốn xóa?`;
}
confirmMessageElem.innerText = message;
confirmModal.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')) {
loadMyTours();
}
} 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(`[Frontend-Viewer] Đang nạp Scene: ${sceneId}. Token: ${shareToken || 'None'}`);
const authParam = shareToken ? `?token=${shareToken}` : '';
let url = `${API_BASE_URL}/scenes/${sceneId}${authParam}`;
let hotspotsUrl = `${API_BASE_URL}/hotspots/${sceneId}${authParam}`;
// 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(hotspotsUrl, { method: 'GET', headers })
]);
if (!sceneRes.ok) {
const errorData = await sceneRes.json();
console.error(`[Viewer API Error] Scene status: ${sceneRes.status}. Message: ${errorData.message}`);
throw new Error(errorData.message || 'Không thể tải thông tin cảnh');
}
console.log(`[Viewer API Success] Đã nạp xong Scene và Hotspots cho ${sceneId}`);
const scene = await sceneRes.json();
const hotspots = await hotspotsRes.json();
// Ngăn chặn mở Scene nếu ảnh chưa xử lý xong hoặc lỗi
// được gán đúng vào Tour gốc của cảnh đang xem, tránh sử dụng ID cũ/lỗi từ phiên trước.
const openedSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
localStorage.setItem('activeTourId', openedSceneTourId);
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
// [FIX CRITICAL] Kiểm tra bảo mật Client-side:
// Nếu scene là private, chỉ cho phép chủ sở hữu xem (ngay cả khi backend vô tình trả về dữ liệu qua token cũ)
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
const currentUserId = localStorage.getItem('userId');
const userRole = localStorage.getItem('role');
const isOwner = currentUserId && sceneOwnerId && currentUserId.toString() === sceneOwnerId.toString();
const isAdmin = userRole === 'admin' || userRole === 'Chủ sở hữu';
console.log("DEBUG: Hotspots raw data from API:", hotspots);
// 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);
// Hiển thị thông tin overlay góc màn hình
updateViewerInfoOverlay(scene);
// 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.location.pathname.includes('/api/share/')) {
window.history.replaceState({}, document.title, "/");
}
} catch (error) {
if (typeof closeViewer === 'function') closeViewer();
localStorage.removeItem('activeSceneId');
localStorage.removeItem('activeScenePrivacy');
localStorage.removeItem('activeSceneToken');
localStorage.removeItem('activeTourId');
localStorage.removeItem('activeTourId');
// 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') || window.location.pathname.includes('/api/share/')) {
// Sử dụng Error Modal để thông báo không bị biến mất đột ngột
showErrorModal("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.", "Lỗi truy cập", "🚫");
// Chỉ xóa tham số URL để làm sạch thanh địa chỉ, không cần reload trang gây văng marker
window.history.replaceState({}, document.title, "/");
return;
}
showNotification(error.message, 'error');
}
}
/**
* Cập nhật overlay thông tin cảnh đang xem (Góc dưới bên trái)
*/
async function updateViewerInfoOverlay(scene) {
if (!scene) {
console.warn("updateViewerInfoOverlay: Scene object is null or undefined. Cannot display info.");
return;
}
let overlay = document.getElementById('viewer-info-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'viewer-info-overlay';
// Append to body to allow independent positioning and animation
document.body.appendChild(overlay);
}
const lang = systemSettings.language || 'vi';
const name = scene.name || scene.title || (lang === 'vi' ? "Cảnh không tên" : "Untitled Scene");
const desc = scene.description || "";
const author = scene.createdBy?.username || (lang === 'vi' ? "Ẩn danh" : "Anonymous");
const date = formatSystemDate(scene.createdAt);
// Lấy tọa độ để truy vấn địa chỉ
const lat = scene.gps?.lat || scene.lat;
const lng = scene.gps?.lng || scene.lng;
let locationText = lang === 'vi' ? "Đang xác định vị trí..." : "Locating...";
overlay.innerHTML = `
<div class="info-content">
<h4>${name}</h4>
${desc ? `<p>${desc}</p>` : ''}
<div class="info-meta">
<span>👤 ${author}</span>
<span>📸 ${lang === 'vi' ? 'Ngày chụp' : 'Date taken'}: ${date}</span>
<span id="overlay-address">📍 ${locationText}</span>
</div>
</div>
`;
overlay.classList.add('show'); // Make it visible with transition
// Thực hiện Reverse Geocoding để lấy địa chỉ từ tọa độ
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`);
const data = await res.json();
const address = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
const addrElem = document.getElementById('overlay-address');
if (addrElem) addrElem.innerText = `📍 ${address}`;
} catch (e) {
const addrElem = document.getElementById('overlay-address');
if (addrElem) addrElem.innerText = `📍 ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
}
}
/**
* Ẩn overlay thông tin cảnh đang xem
*/
window.hideViewerInfoOverlay = function() {
const overlay = document.getElementById('viewer-info-overlay');
if (overlay) {
overlay.classList.remove('show'); // Hide it with transition
// Optionally remove from DOM after transition if not needed
// setTimeout(() => overlay.remove(), 300); // Match CSS transition duration
}
}
/**
* 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);
}
}
/**
* Cập nhật tọa độ Pitch/Yaw cho hotspot từ góc nhìn trung tâm hiện tại của Viewer
*/
window.updateHotspotCoordsFromView = function() {
// activeViewer được quản lý trong viewer360.js
if (typeof activeViewer !== 'undefined' && activeViewer) {
const pitch = activeViewer.getPitch();
const yaw = activeViewer.getYaw();
document.getElementById('hs-pitch').value = pitch.toFixed(2);
document.getElementById('hs-yaw').value = yaw.toFixed(2);
showNotification(`Đã ghi nhận vị trí mới: Pitch ${pitch.toFixed(2)}, Yaw ${yaw.toFixed(2)}`, 'success');
} else {
showNotification("Viewer không hoạt động, không thể lấy tọa độ.", "error");
}
};
/**
* 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 độ click ban đầu
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 = 'Thêm/sửa điểm điều hướng';
// Nạp danh sách hotspot hiện có trong Viewer vào dropdown chỉnh sửa
const editSelect = document.getElementById('hs-to-edit-id');
editSelect.innerHTML = '<option value="">-- Chọn điểm trong Viewer --</option>';
if (typeof currentHotspots !== 'undefined' && currentHotspots.length > 0) {
currentHotspots.forEach(h => {
editSelect.innerHTML += `<option value="${h._id}">${h.title || 'Không tiêu đề'} (ID: ...${h._id.slice(-4)})</option>`;
});
}
// Reset UI states
if (existingHotspot) {
document.querySelector('input[name="hsActionMode"][value="edit"]').checked = true;
toggleHSActionMode('edit');
editSelect.value = existingHotspot._id;
// Điền dữ liệu của hotspot được click vào form
onSelectHotspotToEdit(existingHotspot._id);
} else {
document.querySelector('input[name="hsActionMode"][value="create"]').checked = true;
toggleHSActionMode('create');
}
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>`;
}
});
// [Task 3.1] Lắng nghe thay đổi để nhận diện liên kết chéo
select.onchange = () => {
const selectedId = select.value;
const targetScene = scenes.find(s => s._id === selectedId);
const activeTourId = localStorage.getItem('activeTourId');
const targetTourId = targetScene?.tourId?._id || targetScene?.tourId;
const existingNotice = document.getElementById('hs-existing-notice');
let crossLinkNotice = document.getElementById('hs-crosslink-notice');
if (!crossLinkNotice) {
crossLinkNotice = document.createElement('div');
crossLinkNotice.id = 'hs-crosslink-notice';
crossLinkNotice.style = 'font-size: 11px; margin-top: 4px; color: #ffc107; font-weight: bold; display: none;';
select.parentNode.appendChild(crossLinkNotice);
}
if (targetTourId && activeTourId && targetTourId !== activeTourId) {
crossLinkNotice.innerText = "⚠️ Chú ý: Cảnh này thuộc về một Tour khác (Liên kết chéo).";
crossLinkNotice.style.display = 'block';
if (existingNotice) existingNotice.style.opacity = '0.6';
} else {
crossLinkNotice.style.display = 'none';
if (existingNotice) existingNotice.style.opacity = '1';
}
};
} 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');
const hotspotId = document.getElementById('hs-id').value;
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);
// [FIX] Kế thừa quyền riêng tư của scene hiện tại thay vì fix cứng public
const currentPrivacy = localStorage.getItem('activeScenePrivacy') || 'private';
sceneData.append('privacy', currentPrivacy);
// [FIX] Kế thừa tourId từ cảnh cha khi tạo cảnh mới qua hotspot upload
const activeTourId = localStorage.getItem('activeTourId');
if (activeTourId) sceneData.append('tourId', activeTourId);
uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => {
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, hotspotId);
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, hotspotId);
closeHotspotModal();
};
};
/**
* Chuyển đổi giữa chế độ Thêm mới và Sửa điểm có sẵn
*/
window.toggleHSActionMode = function(mode) {
const selectContainer = document.getElementById('hs-edit-select-container');
selectContainer.style.display = mode === 'edit' ? 'block' : 'none';
if (mode === 'create') {
document.getElementById('hs-id').value = '';
// Giữ nguyên pitch/yaw vừa click, chỉ reset text
document.getElementById('hs-title').value = '';
document.getElementById('hs-desc').value = '';
}
};
/**
* Điền thông tin khi người dùng chọn một hotspot từ danh sách để sửa
*/
window.onSelectHotspotToEdit = function(id) {
if (!id) return;
// currentHotspots được quản lý trong viewer360.js
const hs = currentHotspots.find(h => h._id === id);
if (hs) {
document.getElementById('hs-id').value = hs._id;
document.getElementById('hs-title').value = hs.title || '';
document.getElementById('hs-desc').value = hs.description || '';
// Giữ nguyên tọa độ pitch/yaw từ điểm vừa click chuột phải
// Không ghi đè bằng tọa độ cũ của hotspot để thực hiện việc di chuyển vị trí
if (hs.target_scene_id) {
const targetId = hs.target_scene_id._id || hs.target_scene_id;
document.getElementById('hs-target-id').value = targetId;
}
}
};
/**
* Đóng Modal biên tập Hotspot
*/
function closeHotspotModal() {
const modal = document.getElementById('hotspot-modal');
if (modal) {
modal.style.display = 'none';
modal.removeAttribute('data-original-display');
}
}
/**
* 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 (!await window.showConfirmModal("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 (await window.showConfirmModal('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 Tour của người dùng hoặc các Tour họ có quyền truy cập
*/
async function loadMyTours() {
const token = localStorage.getItem('jwt');
const listContainer = document.getElementById('my-scenes-list');
listContainer.className = 'dashboard-grid';
listContainer.innerHTML = '<p>Đang tải danh sách...</p>';
try {
const res = await fetch(`${API_BASE_URL}/tours`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const tours = await res.json();
if (!res.ok) throw new Error(tours.message);
listContainer.innerHTML = '';
if (tours.length === 0) {
listContainer.innerHTML = '<p>Bạn chưa có Tour nào. Hãy tạo một Tour mới từ bản đồ!</p>';
return;
}
tours.forEach(tour => {
const card = document.createElement('div');
card.className = 'scene-card tour-card';
// Hiển thị thumbnail dựa trên rootSceneId (đã được populate assetId từ backend)
const rootScene = tour.rootSceneId;
const assetId = rootScene ? (rootScene.assetId?._id || rootScene.assetId) : null;
if (assetId) {
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
if (token) thumbUrl += `?token=${token}`;
card.innerHTML = `<img class="tour-card-bg" src="${thumbUrl}" loading="lazy">`;
} else {
card.style.backgroundColor = '#1a1a1a';
}
card.style.borderLeft = `5px solid ${tour.privacy === 'public' ? '#28a745' : '#ffc107'}`;
const currentUserId = localStorage.getItem('userId');
const userRole = localStorage.getItem('role');
const isTourOwner = (tour.createdBy?._id === currentUserId || tour.createdBy === currentUserId || userRole === 'admin' || userRole === 'Chủ sở hữu');
card.innerHTML = `
<div class="scene-card-overlay">
<div class="scene-card-info">
<strong style="color:#00d4ff;"><i class="fas fa-route"></i> ${tour.name}</strong>
<p class="scene-desc">${tour.description || 'Không có mô tả cho tour này'}</p>
<div class="scene-card-meta">
<span>🔒 ${tour.privacy.toUpperCase()}</span>
<span>👤 ${tour.createdBy?.username || 'N/A'}</span>
<span>🖼️ ${tour.scenes?.length || 0} cảnh</span>
<span>📅 ${formatSystemDate(tour.createdAt)}</span>
</div>
</div>
<div class="media-actions" style="border: none; padding: 0;">
${isTourOwner ? `
<button class="edit-btn-small" id="edit-tour-${tour._id}" style="background:#007bff">Sửa</button>
<button class="delete-btn-small" id="delete-tour-${tour._id}">Xóa</button>
` : ''}
<button class="edit-btn-small" id="view-tour-${tour._id}" style="background:#28a745">Xem</button>
</div>
</div>
`;
listContainer.appendChild(card);
// Nút Sửa Tour
const editBtn = document.getElementById(`edit-tour-${tour._id}`);
if (editBtn) {
editBtn.onclick = () => {
openEditTourModal(tour);
};
}
// Nút Xóa Tour: Gọi API xóa Tour (bao gồm xóa cascade các scene bên trong)
const deleteBtn = document.getElementById(`delete-tour-${tour._id}`);
if (deleteBtn) {
deleteBtn.onclick = async () => {
if (await window.showConfirmModal(`Bạn có chắc muốn xóa Tour "${tour.name}" và toàn bộ ${tour.scenes?.length || 0} cảnh bên trong?`)) {
try {
const res = await fetch(`${API_BASE_URL}/tours/${tour._id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
showNotification("Đã xóa Tour thành công", "success");
loadMyTours();
loadScenes();
}
} catch (e) { showNotification("Lỗi xóa tour", "error"); }
}
};
}
// Nút Xem Tour: Bay tới vị trí và mở cảnh khởi đầu
document.getElementById(`view-tour-${tour._id}`).onclick = () => {
closeDashboard();
if (tour.location) {
map.flyTo([tour.location.lat, tour.location.lng], 16);
}
if (tour.rootSceneId) {
openScene(tour.rootSceneId, tour.privacy, tour.shareToken);
}
};
});
} 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 = `
<div class="admin-management-controls" style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; width: 100%;">
<div class="cleanup-row" style="display: flex; justify-content: flex-end; width: 100%;">
<button class="cleanup-btn" onclick="openManualCleanupConfirm()" style="background: #1a1a1a; color: #fff; padding: 6px 14px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer;">
<i class="fas fa-broom"></i> Dọn dẹp dữ liệu
</button>
</div>
<div class="admin-search-container" style="display: flex; width: 100%; gap: 8px;">
<input type="text" id="admin-user-search-input" placeholder="Tìm kiếm theo tên, email, username..." onkeydown="if(event.key === 'Enter') loadAdminUsers(1)" style="flex-grow: 1; background: #1a1a1a; color: #fff; padding: 8px 12px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; outline: none;">
<button class="admin-search-btn" onclick="loadAdminUsers(1)" style="background: #1a1a1a; color: #fff; padding: 8px 20px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; white-space: nowrap;">Tìm kiếm</button>
</div>
</div>
<div class="admin-users-box-list" style="width: 100%; display: flex; flex-direction: column; gap: 12px;">
<div class="admin-table-header" style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 16px; align-items: center; text-align: center; padding: 0 16px; color: #a3a3a3; font-size: 14px; font-weight: 600; margin-bottom: 4px;">
<div>Họ tên</div>
<div>Username</div>
<div>Email</div>
<div>Quyền hạn</div>
<div>Dung lượng</div>
<div>Reset Password</div>
<div>Thao tác</div>
</div>
`;
users.forEach(user => {
const isRootAdmin = user.role === 'admin' || user.role === 'Chủ sở hữu';
const quotaMB = user.storage?.quota ? Math.floor(user.storage.quota / (1024 * 1024)) : 0;
const usedMB = user.storage?.used ? (user.storage.used / (1024 * 1024)).toFixed(1) : 0;
html += `
<div class="admin-user-box" style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 16px; align-items: center; text-align: center; background: #1a1a1a; border: 1px solid #262626; padding: 16px; border-radius: 12px;">
<div class="card-field">
<input type="text" id="adm-fn-${user._id}" value="${user.fullName || ''}" style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; outline: none;">
</div>
<div class="card-field" style="font-weight: bold; font-size: 14px; color: #fff;">
${user.username}
</div>
<div class="card-field">
<input type="email" id="adm-em-${user._id}" value="${user.email || ''}" style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; outline: none;">
</div>
<div class="card-field">
<select id="adm-role-${user._id}" ${isRootAdmin ? 'disabled' : ''} style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; cursor: ${isRootAdmin ? 'not-allowed' : 'pointer'}; outline: none;">
<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>
</div>
<div class="card-field" style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<input type="number" id="adm-quota-${user._id}" value="${quotaMB}" min="0" style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; text-align: center; outline: none;">
<small style="color: #737373; font-size: 11px;">Đã dùng: ${usedMB} MB</small>
</div>
<div class="card-field">
<input type="text" id="adm-pw-${user._id}" placeholder="N/A" disabled style="width: 100%; background: #1a1a1a; color: #525252; padding: 8px 10px; border: 1px solid #262626; border-radius: 6px; font-size: 14px; text-align: center; cursor: not-allowed;">
</div>
<div class="card-field">
<button class="edit-btn-small" onclick="updateUserByAdmin('${user._id}')" style="width: 100%; background: #1a1a1a; color: #fff; padding: 8px 12px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s;">Lưu</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
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,
quota: document.getElementById(`adm-quota-${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 (!await window.showConfirmModal('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;
const isDashboardOpen = document.getElementById('dashboard-overlay').style.display === 'flex';
if (isDashboardOpen) {
dashboardReturnTab = 'media-library';
returnToDashboardAfterEdit = true;
closeDashboard();
} else {
returnToDashboardAfterEdit = false;
}
document.getElementById('delete-asset-confirm-modal').style.display = 'flex';
};
window.closeDeleteAssetModal = function() {
document.getElementById('delete-asset-confirm-modal').style.display = 'none';
assetIdToDelete = null;
if (returnToDashboardAfterEdit) {
const targetTab = dashboardReturnTab;
returnToDashboardAfterEdit = false;
openDashboard();
openDashboardTab(targetTab);
}
};
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');
}
};
window.hiddenModalsStack = window.hiddenModalsStack || [];
window.tempHideActiveModals = function(exceptModalIds = []) {
const modals = document.querySelectorAll('.modal, .modal-overlay');
const hiddenInThisTurn = [];
modals.forEach(modal => {
if (exceptModalIds.includes(modal.id)) return;
const style = window.getComputedStyle(modal);
if (style.display !== 'none' && modal.style.display !== 'none') {
const originalDisplay = modal.style.display || style.display;
modal.setAttribute('data-original-display', originalDisplay);
modal.style.display = 'none';
hiddenInThisTurn.push(modal);
}
});
if (hiddenInThisTurn.length > 0) {
window.hiddenModalsStack.push(hiddenInThisTurn);
}
};
window.restorePreviousModals = function() {
if (window.hiddenModalsStack && window.hiddenModalsStack.length > 0) {
const lastHiddenGroup = window.hiddenModalsStack.pop();
lastHiddenGroup.forEach(modal => {
if (modal.hasAttribute('data-original-display')) {
const originalDisplay = modal.getAttribute('data-original-display') || 'flex';
modal.style.display = originalDisplay;
modal.removeAttribute('data-original-display');
}
});
}
};
/**
* 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) {
window.tempHideActiveModals(['success-modal']);
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;
if (modal.style.display !== 'none') {
modal.style.display = 'none';
window.restorePreviousModals();
}
};
/**
* 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) {
window.tempHideActiveModals(['error-modal']);
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;
if (modal.style.display !== 'none') {
modal.style.display = 'none';
window.restorePreviousModals();
}
};
/**
* 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', '❌');
}
};
// Đè hàm alert mặc định của trình duyệt để gọi modal tương ứng
window.alert = function(message) {
window.showErrorModal(message, 'Thông báo', '⚠️');
};
// Hàm hiển thị confirm dạng modal bất đồng bộ
window.showConfirmModal = function(message) {
return new Promise((resolve) => {
// Tạm ẩn các modal đang mở
window.tempHideActiveModals(['generic-confirm-modal']);
let modal = document.getElementById('generic-confirm-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'generic-confirm-modal';
modal.className = 'modal-overlay';
modal.style.zIndex = '9999';
modal.innerHTML = `
<div class="modal-content action-modal-content logout-modal-dark" style="border-top: 4px solid #ffc107; max-width: 400px; text-align: center;">
<div style="font-size: 40px; color: #ffc107; margin-bottom: 10px;">⚠️</div>
<h2 style="color: #fff; margin-bottom: 10px;">Xác nhận</h2>
<p id="generic-confirm-message" style="color: #ccc; margin-bottom: 25px; line-height: 1.5; font-size: 14px; text-align: center;"></p>
<div class="action-buttons" style="display: flex; gap: 10px; justify-content: center;">
<button id="generic-confirm-ok-btn" class="delete-btn-large" style="background: #dc3545; flex: 1; padding: 10px 20px; font-size: 14px; cursor: pointer; border: none; border-radius: 4px; color: white;">Xác nhận</button>
<button id="generic-confirm-cancel-btn" class="edit-btn-large" style="background: #6c757d; flex: 1; padding: 10px 20px; font-size: 14px; cursor: pointer; border: none; border-radius: 4px; color: white;">Hủy bỏ</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
document.getElementById('generic-confirm-message').innerText = message;
modal.style.display = 'flex';
const handleResolve = (value) => {
modal.style.display = 'none';
window.restorePreviousModals();
resolve(value);
};
document.getElementById('generic-confirm-ok-btn').onclick = () => handleResolve(true);
document.getElementById('generic-confirm-cancel-btn').onclick = () => handleResolve(false);
});
};
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);
const modalTitle = document.getElementById('edit-metadata-modal-title');
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 || [];
// [TOUR ID] Cập nhật activeTourId khi mở modal sửa.
// Điều này đảm bảo ngữ cảnh tạo cảnh con mới (nếu có) luôn thuộc về Tour của cảnh đang sửa.
const editingSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
localStorage.setItem('activeTourId', editingSceneTourId);
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';
if (modalTitle) modalTitle.innerText = "Chi tiết Cảnh con (Kế thừa)";
// [Task 3.2] Nhận diện và hiển thị nhãn liên kết chéo
const activeTourId = localStorage.getItem('activeTourId');
const sceneTourId = scene.tourId?._id || scene.tourId;
let crossLabel = activeTourId && sceneTourId && activeTourId !== sceneTourId.toString()
? `<br><span style="color: #ffc107; font-weight: bold;">⚠️ Liên kết Tour khác:</span> Quyền riêng tư được quản lý bởi Tour gốc của cảnh này.`
: `️ Cảnh này thuộc một tour. Quyền riêng tư được quản lý bởi Cảnh gốc.`;
childInfo.innerHTML = crossLabel;
} else {
privacySelect.value = scene.privacy;
privacySelect.disabled = false;
childInfo.style.display = 'block';
if (modalTitle) modalTitle.innerText = "Sửa 3D Scene (Cảnh gốc)";
// [Task 3.2] Cảnh báo Privacy cho Cảnh gốc
childInfo.innerHTML = `<i style="color: #888;">️ Thay đổi sẽ áp dụng cho toàn bộ tour này. Các cảnh liên kết chéo từ tour khác sẽ KHÔNG bị ảnh hưởng.</i>`;
}
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() {
const modal = document.getElementById('edit-scene-metadata-modal');
if (modal) {
modal.style.display = 'none';
modal.removeAttribute('data-original-display');
}
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 = function() {
const modal = document.getElementById('share-link-modal');
if (modal) {
modal.style.display = 'none';
modal.removeAttribute('data-original-display');
}
};
/**
* 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(() => {
closeShareLinkModal();
showSuccessModal("Đã sao chép liên kết vào bộ nhớ!");
});
};
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);
// [FIX CRITICAL] Nếu chuyển sang Private, xóa token lưu cục bộ nếu là scene đang xem
const newPrivacy = document.getElementById('edit-modal-privacy').value;
if (newPrivacy === 'private' && localStorage.getItem('activeSceneId') === id) {
localStorage.removeItem('activeSceneToken');
localStorage.setItem('activeScenePrivacy', 'private');
}
showNotification("Cập nhật thành công! Các liên kết chia sẻ cũ đã bị vô hiệu hóa.", '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 (!await window.showConfirmModal("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");
}
}
/**
* Admin: Kích hoạt tính năng tính toán lại trung tâm cho toàn bộ Tour
*/
window.recalculateAllTourCenters = async function() {
const token = localStorage.getItem('jwt');
if (!await window.showConfirmModal("Bạn có chắc chắn muốn tính toán lại tọa độ trung tâm cho TOÀN BỘ Tour trong hệ thống? Việc này có thể mất một chút thời gian nếu dữ liệu lớn.")) return;
try {
showNotification("Đang xử lý tính toán lại...", "success");
const res = await fetch(`${API_BASE_URL}/tours/recalculate-all`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
showSuccessModal(data.message);
} catch (e) {
showNotification("Lỗi thực hiện: " + e.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') {
loadMyTours();
}
if (tabName === 'media-library') {
loadMediaStats();
loadMyAssets();
}
if (tabName === 'user-management') {
loadAdminUsers();
}
if (tabName === 'system-settings') {
// Cập nhật giá trị hiện tại vào form cấu hình hệ thống
const tzInput = document.getElementById('sys-timezone');
const langInput = document.getElementById('sys-language');
if (tzInput) tzInput.value = systemSettings.timezone || 'Asia/Ho_Chi_Minh';
if (langInput) langInput.value = systemSettings.language || 'vi';
}
}
// Đá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();
}
}