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 = `
`;
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 = ``;
}
} 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: '' });
}
});
// 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 = `
`,
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 = `
${tourName}
${tourDescription ? `${tourDescription} ` : ''}
Người tạo: ${scene.createdBy ? scene.createdBy.username : 'Ẩn danh'} Ngày tạo: ${createdDate}
`;
// 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 ? '✏️ Sửa thông tin Tour' : '✏️ 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 ? '🗑️ Xóa vĩnh viễn Tour' : '🗑️ 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 = `