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' }; // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { try { console.log("--- Bắt đầu khởi tạo Frontend ---"); // Ưu tiên nạp cấu hình hệ thống trước fetchSystemSettings(); if (document.getElementById('map')) { console.log("1. Đang khởi tạo bản đồ Leaflet..."); initMap(); } // Chạy tuần tự để tránh xung đột luồng xử lý checkAuthStatus(); // 2. Kiểm tra đăng nhập // Đảm bảo map đã sẵn sàng trước khi nạp data if (map) { // Chỉ nạp danh sách Scene để vẽ marker lên bản đồ loadScenes(); } } catch (error) { console.error("Ứng dụng không thể khởi tạo:", error); } }); /** * Lấy cấu hình hệ thống từ Backend */ async function fetchSystemSettings() { try { const res = await fetch(`${API_BASE_URL}/system/settings`); if (res.ok) { systemSettings = await res.json(); applySystemSettings(); } } catch (e) { console.warn("Không thể nạp cấu hình hệ thống, dùng mặc định."); } } /** * Áp dụng cấu hình Múi giờ và Ngôn ngữ vào UI */ function applySystemSettings() { console.log(`[System] Applying settings: Language=${systemSettings.language}, Timezone=${systemSettings.timezone}`); // 1. Cập nhật nhãn (Labels) dựa trên ngôn ngữ const translations = { vi: { brand: "Bản đồ Tour 3D Ảo", login: "Đăng nhập / Đăng ký", profile: "Quản lý hồ sơ", logout: "Đăng xuất", dashboardTitle: "Bảng điều khiển người dùng", tabProfile: "Hồ sơ", tabScenes: "Quản lí scene", tabMedia: "Quản lí ảnh và media", tabUsers: "Quản lí users", tabSystem: "Cài đặt hệ thống" }, en: { brand: "Virtual 3D Tour Map", login: "Login / Register", profile: "Manage Profile", logout: "Logout", dashboardTitle: "User Dashboard", tabProfile: "Profile", tabScenes: "My Scenes", tabMedia: "Media Library", tabUsers: "User Management", tabSystem: "System Settings" } }; const lang = systemSettings.language || 'vi'; const t = translations[lang]; // Cập nhật các phần tử cố định const brandH1 = document.querySelector('.app-brand h1'); if (brandH1) brandH1.innerText = t.brand; const dashboardH2 = document.querySelector('.dashboard-content h2'); if (dashboardH2) dashboardH2.innerText = t.dashboardTitle; // Cập nhật các nút Tab const tabButtons = document.querySelectorAll('.dashboard-tabs .tab-btn'); if (tabButtons.length >= 3) { tabButtons[0].innerText = t.tabProfile; tabButtons[1].innerText = t.tabScenes; tabButtons[2].innerText = t.tabMedia; if (tabButtons[3]) tabButtons[3].innerText = t.tabUsers; if (tabButtons[4]) tabButtons[4].innerText = t.tabSystem; } const profileBtn = document.querySelector('button[onclick="openDashboard()"]'); if (profileBtn) profileBtn.innerText = t.profile; const logoutBtn = document.querySelector('button[onclick="handleLogout()"]'); if (logoutBtn) logoutBtn.innerText = t.logout; } /** * Cập nhật nội dung tab Hồ sơ với thông tin người dùng */ function updateProfileTabContent() { const username = localStorage.getItem('username'); const role = localStorage.getItem('role'); if (username) { document.getElementById('profile-avatar-initials').innerText = username.charAt(0).toUpperCase(); document.getElementById('profile-username-display').innerText = username; document.getElementById('profile-status-display').innerText = role || 'Thành viên'; // Hiển thị vai trò làm trạng thái } } /** * Cập nhật nội dung tab Hồ sơ với thông tin người dùng */ function updateProfileTabContent() { const username = localStorage.getItem('username'); const role = localStorage.getItem('role'); if (username) { document.getElementById('profile-avatar-initials').innerText = username.charAt(0).toUpperCase(); document.getElementById('profile-username-display').innerText = username; document.getElementById('profile-status-display').innerText = role || 'Thành viên'; // Hiển thị vai trò làm trạng thái } } /** * Hàm bổ trợ định dạng ngày tháng theo múi giờ hệ thống */ function formatSystemDate(dateString) { if (!dateString) return 'N/A'; const date = new Date(dateString); try { return new Intl.DateTimeFormat(systemSettings.language === 'vi' ? 'vi-VN' : 'en-US', { timeZone: systemSettings.timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date); } catch (e) { // Fallback nếu timezone không hợp lệ return date.toLocaleDateString(); } } /** * Initializes the full-screen Leaflet Map */ function initMap() { // Đọc vị trí và zoom đã lưu từ localStorage const savedLat = localStorage.getItem('map-lat'); const savedLng = localStorage.getItem('map-lng'); const savedZoom = localStorage.getItem('map-zoom'); // Đảm bảo tọa độ khởi tạo luôn hợp lệ let startLat = parseFloat(savedLat); let startLng = parseFloat(savedLng); let startZoom = parseInt(savedZoom); if (isNaN(startLat)) startLat = 21.0285; if (isNaN(startLng)) startLng = 105.8542; if (isNaN(startZoom)) startZoom = 13; // Khởi tạo bản đồ với zoomControl và tắt attribution mặc định của Leaflet map = L.map('map', { zoomControl: true, attributionControl: false }).setView([startLat, startLng], startZoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, // Attribution sẽ được thêm thủ công bên dưới để chỉ hiển thị OpenStreetMap // attribution: '© OpenStreetMap contributors' }).addTo(map); // Khởi tạo Marker Cluster Group CHỈ dành cho Scene (Ảnh mẹ) markerClusterGroup = L.markerClusterGroup({ zoomToBoundsOnClick: false, spiderfyOnMaxZoom: true, maxClusterRadius: 50, spiderfyDistanceMultiplier: 3.5, // Tăng thêm khoảng cách để callout ảnh mẹ tách rõ ràng khi tỏa ra showCoverageOnHover: false, iconCreateFunction: function(cluster) { const childMarkers = cluster.getAllChildMarkers(); try { if (childMarkers.length > 0 && childMarkers[0].options.icon) { const childIcon = childMarkers[0].options.icon; // Trả về một DivIcon MỚI dựa trên cấu hình của con, tránh dùng chung instance gây crash render return L.divIcon({ html: childIcon.options.html, className: childIcon.options.className, iconSize: childIcon.options.iconSize, iconAnchor: childIcon.options.iconAnchor }); } } catch (e) {} return L.divIcon({ className: 'cluster-fallback', html: '
' }); } }); // Khi click chuột trái vào một cụm callout, thực hiện tách chúng ra markerClusterGroup.on('clusterclick', (a) => { a.layer.spiderfy(); }); // Thêm attribution chỉ với OpenStreetMap, không có Leaflet L.control.attribution({ prefix: false }).addAttribution('OpenStreetMap contributors').addTo(map); map.addLayer(markerClusterGroup); // Lưu vị trí bản đồ mỗi khi người dùng di chuyển hoặc zoom xong map.on('moveend', () => { const center = map.getCenter(); localStorage.setItem('map-lat', center.lat); localStorage.setItem('map-lng', center.lng); localStorage.setItem('map-zoom', map.getZoom()); }); // Event listener for right-click on map to open modal map.on('contextmenu', (e) => { // Nếu viewer 3D đang hiển thị, không thực hiện tạo scene trên bản đồ const viewerContainer = document.getElementById('viewer-container'); // Kiểm tra z-index và display để chắc chắn viewer đang ẩn if (viewerContainer && viewerContainer.style.display !== 'none') { return; } // Cho phép bất kỳ người dùng nào đã đăng nhập tạo Scene mới trên bản đồ const token = localStorage.getItem('jwt'); if (!token) return; const { lat, lng } = e.latlng; openCreateSceneModal(lat, lng); }); } /** * Checks if user is logged in (via localStorage JWT) and updates UI */ function checkAuthStatus() { const token = localStorage.getItem('jwt'); const username = localStorage.getItem('username'); const role = localStorage.getItem('role'); const authGuest = document.getElementById('auth-guest'); const authLoggedIn = document.getElementById('auth-logged-in'); const avatarInitials = document.getElementById('avatar-initials'); if (token && username) { authGuest.style.display = 'none'; // Hide login form authLoggedIn.style.display = 'block'; // Show welcome message and buttons avatarInitials.innerText = username.charAt(0).toUpperCase(); // Chỉ hiển thị các NÚT BẤM menu admin trong sidebar const adminButtons = document.querySelectorAll('.dashboard-tabs .admin-only'); if (role === 'Chủ sở hữu' || role === 'admin') { adminButtons.forEach(btn => btn.style.display = 'block'); } else { adminButtons.forEach(btn => btn.style.display = 'none'); } } else { authGuest.style.display = 'block'; // Show login form authLoggedIn.style.display = 'none'; // Hide welcome message avatarInitials.innerText = '?'; document.getElementById('user-dropdown').classList.remove('show'); // Đóng dropdown nếu không đăng nhập } } /** * Handles user login */ async function handleLogin() { const errorMsg = document.getElementById('login-error-msg'); if (errorMsg) errorMsg.style.display = 'none'; const username = document.getElementById('username-input').value.trim(); const password = document.getElementById('password-input').value.trim(); if (!username || !password) { if (errorMsg) { errorMsg.innerText = 'Vui lòng nhập đầy đủ thông tin'; errorMsg.style.display = 'block'; } return; } try { const response = await fetch(`${API_BASE_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Đăng nhập thất bại'); localStorage.setItem('jwt', data.token); localStorage.setItem('username', data.user.username); localStorage.setItem('role', data.user.role); localStorage.setItem('userId', data.user.id); checkAuthStatus(); toggleDropdown(); // Đóng dropdown sau khi đăng nhập loadScenes(); // Reload scenes to show member/private scenes // Làm sạch form document.getElementById('username-input').value = ''; document.getElementById('password-input').value = ''; } catch (error) { if (errorMsg) { errorMsg.innerText = error.message; errorMsg.style.display = 'block'; } } } /** * Handles user registration */ async function handleRegister() { const username = document.getElementById('username-input').value.trim(); const password = document.getElementById('password-input').value.trim(); if (!username || !password) { alert('Please fill in both fields'); return; } try { const response = await fetch(`${API_BASE_URL}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, role: 'Thành viên' }) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Registration failed'); alert('Registration successful! You can now log in.'); } catch (error) { alert(error.message); } } /** * 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) 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'; } /** * Handles user logout */ function handleLogout() { localStorage.removeItem('jwt'); localStorage.removeItem('username'); localStorage.removeItem('role'); localStorage.removeItem('activeSceneId'); localStorage.removeItem('activeScenePrivacy'); localStorage.removeItem('activeSceneToken'); localStorage.removeItem('userId'); // Đảm bảo đóng viewer nếu đang mở if (typeof closeViewer === 'function') { closeViewer(); } checkAuthStatus(); if (document.getElementById('user-dropdown').classList.contains('show')) { toggleDropdown(); } closeLogoutConfirm(); closeDashboard(); loadScenes(); // Reload scenes to filter out private ones } /** * Opens Modal for creating a Scene and sets lat/lng inputs */ function openCreateSceneModal(lat, lng) { const token = localStorage.getItem('jwt'); if (!token) { alert('Please log in first to create a 3D scene.'); return; } // Place a temporary marker on the map if (tempMarker) map.removeLayer(tempMarker); tempMarker = L.marker([lat, lng]).addTo(map); document.getElementById('create-scene-modal').style.display = 'flex'; document.getElementById('modal-scene-id').value = ''; document.getElementById('modal-lat').value = lat.toFixed(6); document.getElementById('modal-lng').value = lng.toFixed(6); } /** * Closes the Create Scene Modal and removes temporary marker */ function closeModal() { document.getElementById('create-scene-modal').style.display = 'none'; if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } document.getElementById('create-scene-form').reset(); document.getElementById('shared-with-group').style.display = 'none'; } /** * Toggles visibility of the shared users input based on privacy selection */ function toggleSharedUsers() { const privacy = document.getElementById('modal-privacy').value; const group = document.getElementById('shared-with-group'); if (privacy === 'shared') { group.style.display = 'block'; } else { group.style.display = 'none'; } } /** * Form submission for Scene creation (multipart/form-data) */ async function submitScene(e) { e.preventDefault(); const form = document.getElementById('create-scene-form'); const formData = new FormData(form); const sceneId = document.getElementById('modal-scene-id').value; const token = localStorage.getItem('jwt'); const url = sceneId ? `${API_BASE_URL}/scenes/${sceneId}` : `${API_BASE_URL}/scenes`; const method = sceneId ? 'PUT' : 'POST'; uploadWithProgress(url, method, formData, token, 'create', () => { alert(sceneId ? 'Scene đang được cập nhật ngầm!' : 'Scene đã được tạo! Ảnh đang được xử lý 8K...'); 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) {} alert('Lỗi: ' + errorMsg); } }); xhr.addEventListener('error', () => { if (container) container.style.display = 'none'; alert('Lỗi kết nối mạng.'); }); xhr.open(method, url); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.send(formData); } /** * Loads and displays visible Scenes on the map */ async function loadScenes() { try { const token = localStorage.getItem('jwt'); const headers = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } // Thêm timestamp để tránh lỗi 304 hang do cache trình duyệt const timestamp = new Date().getTime(); console.log(`3.1 Đang gửi yêu cầu lấy danh sách Scene (ts: ${timestamp})...`); const response = await fetch(`${API_BASE_URL}/scenes?_=${timestamp}`, { method: 'GET', headers }); console.log(`[API Response] /scenes status: ${response.status}`); if (!response.ok) throw new Error('Failed to load scenes'); const scenes = await response.json(); console.log(`[Data] Nhận được ${scenes.length} scenes từ server`); if (!Array.isArray(scenes)) return; // Xóa sạch các layers cũ trước khi nạp mới markerClusterGroup.clearLayers(); const markersToAdd = []; const activeSceneId = localStorage.getItem('activeSceneId'); const seenCoordinates = new Set(); // Dùng để lọc "Ảnh mẹ" (1 marker per location) // Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ scenes.forEach((scene) => { // 1. Kiểm tra tọa độ an toàn - Ngăn chặn treo map do NaN const latNum = Number(scene.gps?.lat ?? scene.lat); const lngNum = Number(scene.gps?.lng ?? scene.lng); if (isNaN(latNum) || isNaN(lngNum)) { console.error(`Bỏ qua Scene "${scene.name || scene.title}" do tọa độ lỗi:`, scene); return; } // 2. Logic lọc Ảnh mẹ: Sửa lỗi typo coordKey (dùng latNum 2 lần) const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`; if (seenCoordinates.has(coordKey)) return; seenCoordinates.add(coordKey); // 3. Truy cập Asset an toàn const assetId = scene.assetId?._id || scene.assetId; if (!assetId) return; // Bỏ qua nếu không có ảnh liên kết const sceneName = scene.name || scene.title || "Untitled Scene"; let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`; if (token) thumbUrl += `?token=${token}`; else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`; const calloutIcon = L.divIcon({ className: 'custom-scene-marker', html: `
${sceneName}
`, iconSize: [64, 64], iconAnchor: [32, 76] // Căn giữa ngang, đáy mũi tên tại tọa độ lat/lng }); const marker = L.marker([latNum, lngNum], { icon: calloutIcon, title: sceneName }); // Tạo nội dung thông tin khi Hover (Tooltip) const createdDate = formatSystemDate(scene.assetId?.createdAt); const tooltipContent = `
${sceneName}
${scene.description ? `${scene.description}
` : ''} 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' }); // Sự kiện Click chuột trái: Vào thẳng trình xem 360 marker.on('click', () => { openScene(scene._id, scene.privacy, scene.shareToken || ''); }); marker.on('contextmenu', (e) => { if (e.originalEvent) { L.DomEvent.stop(e.originalEvent); } const currentUserId = localStorage.getItem('userId'); const userRole = localStorage.getItem('role'); // Hỗ trợ cả schema cũ (owner) và mới (createdBy) const ownerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner; // Phân quyền: Admin hoặc Chủ sở hữu Scene const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin'; if (isAdmin || (currentUserId && ownerId && ownerId.toString() === currentUserId.toString())) { handleEditDeleteScene(scene); } else { alert("Bạn không có quyền chỉnh sửa scene này."); } }); markersToAdd.push(marker); }); // Thêm danh sách marker đã lọc vào group markerClusterGroup.addLayers(markersToAdd); } catch (error) { console.error('Error loading scenes:', error); } } /** * Handles Edit/Delete options for a scene */ async function handleEditDeleteScene(scene) { const modal = document.getElementById('action-choice-modal'); const title = document.getElementById('action-modal-title'); const editBtn = document.getElementById('btn-edit-action'); const deleteBtn = document.getElementById('btn-delete-action'); title.innerText = `Scene: ${scene.title}`; modal.style.display = 'flex'; // Gán sự kiện cho nút Sửa editBtn.onclick = () => { closeActionModal(); openEditSceneModal(scene); }; // Gán sự kiện cho nút Xóa deleteBtn.onclick = async () => { if (confirm(`Cảnh báo: Thao tác này sẽ xóa vĩnh viễn scene "${scene.title}" và tệp tin ảnh 360 liên quan. Bạn có chắc chắn?`)) { closeActionModal(); await deleteScene(scene._id); } }; } /** * Closes the Action Choice Modal */ function closeActionModal() { document.getElementById('action-choice-modal').style.display = 'none'; } /** * Opens the modal in Edit mode */ function openEditSceneModal(scene) { document.getElementById('modal-scene-id').value = scene._id; // Cập nhật để hỗ trợ cả cấu trúc cũ và mới (gps.lat/name) document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat; document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng; const sceneName = scene.name || scene.title; document.getElementById('modal-title').value = sceneName; document.getElementById('modal-privacy').value = scene.privacy; document.getElementById('modal-panorama').required = false; // Photo update is optional toggleSharedUsers(); document.getElementById('create-scene-modal').style.display = 'flex'; } /** * Deletes a scene via API */ async function deleteScene(sceneId) { if (!confirm('Bạn có chắc chắn muốn xóa scene này?')) return; const token = localStorage.getItem('jwt'); try { const response = await fetch(`${API_BASE_URL}/scenes/${sceneId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error('Failed to delete scene'); alert('Scene deleted successfully'); loadScenes(); if (document.getElementById('tab-my-scenes').classList.contains('active')) loadMyScenes(); } catch (error) { alert(error.message); } } /** * Fetches secure scene details and triggers the Panorama viewer */ async function openScene(sceneId, privacy, shareToken, force = false) { // Nếu đang xem chính scene này và không yêu cầu làm mới (force), không cần nạp lại if (!force && currentSceneId === sceneId && document.getElementById('viewer-container').style.display === 'block') { return; } try { const token = localStorage.getItem('jwt'); const headers = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } console.log(`[Viewer] Đang mở scene: ${sceneId}`); let url = `${API_BASE_URL}/scenes/${sceneId}`; if (privacy === 'shared' && shareToken) { url += `?token=${shareToken}`; } // Lưu trạng thái Scene hiện tại để khôi phục sau khi reload trang localStorage.setItem('activeSceneId', sceneId); localStorage.setItem('activeScenePrivacy', privacy || ''); localStorage.setItem('activeSceneToken', shareToken || ''); // Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng const [sceneRes, hotspotsRes] = await Promise.all([ fetch(url, { method: 'GET', headers }), fetch(`${API_BASE_URL}/hotspots/${sceneId}`, { method: 'GET', headers }) ]); const scene = await sceneRes.json(); const hotspots = await hotspotsRes.json(); if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); // Lấy ID người tạo (createdBy) để phân quyền chuột phải trong viewer const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner; // Tự động focus bản đồ vào vị trí của Scene if (map) { map.flyTo([scene.gps?.lat || scene.lat, scene.gps?.lng || scene.lng], 16); } // Cập nhật tọa độ vào các input ẩn để hỗ trợ GPS inheritance cho hotspot khi tải ảnh mới document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat; document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng; // Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động if (currentSceneId && currentSceneId !== sceneId) { previousSceneId = currentSceneId; } currentSceneId = sceneId; // Kiểm tra an toàn assetId (hỗ trợ cả dạng Object và String ID) const assetId = scene.assetId?._id || scene.assetId; if (!assetId) throw new Error("Dữ liệu hình ảnh của cảnh này bị lỗi hoặc chưa xử lý xong."); let secureImageUrl = `${API_BASE_URL}/assets/view/${assetId}`; // Ưu tiên JWT token nếu đang đăng nhập, nếu không thì dùng shareToken if (token) { secureImageUrl += `?token=${token}`; } else if (privacy === 'shared' && scene.shareToken) { secureImageUrl += `?token=${scene.shareToken}`; } // Initialize 3D Viewer with secure, referer-protected image stream initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId); } catch (error) { localStorage.removeItem('activeSceneId'); localStorage.removeItem('activeScenePrivacy'); localStorage.removeItem('activeSceneToken'); alert(error.message); } } /** * 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'); openScene(savedSceneId, savedPrivacy, savedToken); } } /** * 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) { alert('Vui lòng đăng nhập để thực hiện thao tác này.'); return; } const modal = document.getElementById('hotspot-modal'); const form = document.getElementById('hotspot-form'); // Hiển thị Modal TRƯỚC để các logic UI (như Mini Map) tính toán được kích thước modal.style.display = 'flex'; // Reset form và gán tọa độ form.reset(); document.getElementById('hs-pitch').value = pitch; document.getElementById('hs-yaw').value = yaw; document.getElementById('hs-id').value = existingHotspot ? existingHotspot._id : ''; document.getElementById('hotspot-modal-title').innerText = existingHotspot ? 'Cập nhật điểm điều hướng' : 'Thêm điểm điều hướng mới'; // Reset UI states document.querySelector('input[name="hsLinkType"][value="existing"]').checked = true; window.toggleHSLinkType('existing'); document.querySelector('input[name="hsGPSMode"][value="map"]').checked = true; window.toggleHSGPSMode('map'); // Lấy danh sách Scene có sẵn để đổ vào dropdown try { const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } }); const scenes = await res.json(); const select = document.getElementById('hs-target-id'); select.innerHTML = ''; scenes.forEach(s => { if (s._id !== currentSceneId) { // Không liên kết tới chính nó select.innerHTML += ``; } }); // QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options if (existingHotspot) { document.getElementById('hs-title').value = existingHotspot.title || ''; document.getElementById('hs-desc').value = existingHotspot.description || ''; if (existingHotspot.target_scene_id) { select.value = existingHotspot.target_scene_id; } } } catch (e) { console.error("Lỗi nạp danh sách scene:", e); } // Xử lý sự kiện submit form form.onsubmit = async (e) => { e.preventDefault(); const formData = new FormData(form); const linkType = formData.get('hsLinkType'); if (linkType === 'upload') { const file = document.getElementById('hs-panorama-file').files[0]; if (!file) { alert('Vui lòng chọn ảnh panorama.'); 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) { alert('Vui lòng chọn vị trí GPS.'); return; } } const sceneData = new FormData(); sceneData.append('panorama', file); sceneData.append('title', formData.get('title')); sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại sceneData.append('lng', lng); sceneData.append('privacy', 'public'); uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => { await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id); closeHotspotModal(); }); return; } const finalTargetId = formData.get('targetSceneId'); if (!finalTargetId) { alert('Vui lòng chọn cảnh để liên kết.'); return; } await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id); modal.style.display = 'none'; }; }; /** * Đóng Modal biên tập Hotspot */ function closeHotspotModal() { document.getElementById('hotspot-modal').style.display = 'none'; } /** * Khởi tạo hoặc cập nhật Mini Map trong Hotspot Modal */ function initHSMiniMap() { const pLat = parseFloat(document.getElementById('modal-lat').value) || 21.0285; const pLng = parseFloat(document.getElementById('modal-lng').value) || 105.8542; if (miniMap) { miniMap.setView([pLat, pLng], 15); updateHSMiniMapMarker(pLat, pLng); // Fix lỗi vỡ tiles của Leaflet trong Modal setTimeout(() => miniMap.invalidateSize(), 200); return; } miniMap = L.map('hs-mini-map').setView([pLat, pLng], 15); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(miniMap); miniMap.on('click', (e) => { const { lat, lng } = e.latlng; updateHSMiniMapMarker(lat, lng); }); updateHSMiniMapMarker(pLat, pLng); setTimeout(() => miniMap.invalidateSize(), 200); } function updateHSMiniMapMarker(lat, lng) { if (miniMapMarker) miniMap.removeLayer(miniMapMarker); miniMapMarker = L.marker([lat, lng]).addTo(miniMap); document.getElementById('hs-lat').value = lat.toFixed(6); document.getElementById('hs-lng').value = lng.toFixed(6); } window.toggleHSLinkType = function(type) { document.getElementById('hs-section-existing').style.display = type === 'existing' ? 'block' : 'none'; document.getElementById('hs-section-upload').style.display = type === 'upload' ? 'block' : 'none'; if (type === 'upload') { const gpsMode = document.querySelector('input[name="hsGPSMode"]:checked')?.value || 'map'; window.toggleHSGPSMode(gpsMode); } }; window.toggleHSGPSMode = function(mode) { document.getElementById('hs-map-selector').style.display = mode === 'map' ? 'block' : 'none'; document.getElementById('hs-manual-gps').style.display = mode === 'manual' ? 'block' : 'none'; if (mode === 'map') initHSMiniMap(); }; /** * Chuyển đổi tab cũ (giữ lại để tương thích nếu cần) */ function switchHSTab(tabName) { if (tabName === 'select') { window.toggleHSLinkType('existing'); } else { window.toggleHSLinkType('upload'); } } /** * Ẩn/hiện input nhập GPS thủ công */ function toggleManualGPS() { const mode = document.getElementById('hs-gps-mode').value; const manualDiv = document.getElementById('hs-manual-gps'); manualDiv.style.display = mode === 'manual' ? 'block' : 'none'; } /** * Gửi dữ liệu lưu Hotspot lên Backend */ async function saveHotspotToDB(pitch, yaw, title, description, targetSceneId, hotspotId) { const token = localStorage.getItem('jwt'); // Gọi đúng API create hoặc update tùy vào trạng thái const url = hotspotId ? `${API_BASE_URL}/hotspots/update/${hotspotId}` : `${API_BASE_URL}/hotspots/create`; const method = hotspotId ? 'PUT' : 'POST'; try { const body = { title, description, target_scene_id: targetSceneId, coordinates: { pitch: Number(pitch), yaw: Number(yaw) } }; // Nếu tạo mới, cần gửi kèm ID của scene hiện tại làm parent if (!hotspotId) { body.parent_scene_id = currentSceneId; } const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Lỗi khi lưu hotspot'); alert('Lưu điểm điều hướng thành công!'); // 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); alert(error.message); } } /** * Công cụ dọn dẹp toàn bộ dữ liệu (Chỉ dùng cho nhà phát triển) * Gọi lệnh: systemReset() từ trình duyệt */ window.systemReset = async function() { if (!confirm("CẢNH BÁO: Thao tác này sẽ xóa sạch TOÀN BỘ scene và ảnh trên server. Bạn có chắc chắn?")) return; const token = localStorage.getItem('jwt'); try { const response = await fetch(`${API_BASE_URL}/maintenance/reset-all`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); const data = await response.json(); if (response.ok) { localStorage.clear(); // Xóa sạch token, vị trí map, active scene alert(data.message); location.reload(); } else { throw new Error(data.message); } } catch (e) { alert("Lỗi reset: " + e.message); } }; /** * Mở Menu tùy chọn cho Hotspot (Sửa/Xóa) */ window.openHotspotMenu = function(hotspot) { const modal = document.getElementById('hotspot-action-modal'); const editBtn = document.getElementById('btn-hs-edit'); const deleteBtn = document.getElementById('btn-hs-delete'); modal.style.display = 'flex'; // Hành động Sửa: Mở form biên tập với dữ liệu cũ editBtn.onclick = () => { closeHotspotActionModal(); window.handleHotspotCreation(hotspot.pitch, hotspot.yaw, hotspot); }; // Hành động Xóa: Xác nhận và gọi API xóa deleteBtn.onclick = async () => { if (confirm('Bạn có chắc chắn muốn xóa điểm điều hướng này?')) { closeHotspotActionModal(); await deleteHotspot(hotspot._id); } }; }; /** * Đóng Modal tùy chọn Hotspot */ function closeHotspotActionModal() { document.getElementById('hotspot-action-modal').style.display = 'none'; } /** * Xóa Hotspot thông qua API */ async function deleteHotspot(hotspotId) { const token = localStorage.getItem('jwt'); try { // Gọi đúng API delete hotspot độc lập const response = await fetch(`${API_BASE_URL}/hotspots/delete/${hotspotId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { const err = await response.json(); throw new Error(err.message || 'Lỗi xóa hotspot'); } alert('Đã xóa điểm điều hướng.'); // Refresh lại scene hiện tại để cập nhật viewer openScene(currentSceneId, null, null, true); } catch (e) { alert(e.message); } } /** * Toggles the visibility of the user dropdown menu. */ function toggleDropdown() { document.getElementById('user-dropdown').classList.toggle('show'); } /** * Opens the user dashboard overlay. */ function openDashboard() { const username = localStorage.getItem('username'); const role = localStorage.getItem('role'); if (username) { document.getElementById('sidebar-avatar').innerText = username.charAt(0).toUpperCase(); document.getElementById('sidebar-username').innerText = username; document.getElementById('sidebar-status').innerText = role || 'Thành viên'; } document.getElementById('dashboard-overlay').style.display = 'flex'; document.getElementById('user-dropdown').classList.remove('show'); // Close dropdown // Mở tab profile mặc định khi mở dashboard openDashboardTab('profile'); } /** * Closes the user dashboard overlay. */ function closeDashboard() { document.getElementById('dashboard-overlay').style.display = 'none'; } /** * Tải danh sách scene của chính người dùng đăng nhập */ async function loadMyScenes() { const token = localStorage.getItem('jwt'); const listContainer = document.getElementById('my-scenes-list'); listContainer.innerHTML = '

Đang tải danh sách...

'; try { const res = await fetch(`${API_BASE_URL}/me/scenes`, { headers: { 'Authorization': `Bearer ${token}` } }); const scenes = await res.json(); if (!res.ok) throw new Error(scenes.message); listContainer.innerHTML = ''; if (scenes.length === 0) { listContainer.innerHTML = '

Bạn chưa tạo scene nào.

'; return; } scenes.forEach(scene => { const item = document.createElement('div'); item.className = 'dashboard-item'; item.innerHTML = `
${scene.name || scene.title} Quyền: ${scene.privacy} - Ngày tạo: ${formatSystemDate(scene.createdAt)}
`; listContainer.appendChild(item); // Gán sự kiện sửa bằng code để truyền object scene an toàn document.getElementById(`edit-${scene._id}`).onclick = () => openEditSceneModal(scene); }); } catch (e) { listContainer.innerHTML = `

Lỗi: ${e.message}

`; } } /** * Opens a specific tab within the dashboard. * @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes'). */ function openDashboardTab(tabName) { // Ẩn tất cả các tab pane document.querySelectorAll('.dashboard-tab-pane').forEach(pane => { pane.classList.remove('active'); }); // Bỏ active khỏi tất cả các nút tab document.querySelectorAll('.dashboard-tabs .tab-btn').forEach(btn => { btn.classList.remove('active'); }); // Hiển thị tab pane được chọn const selectedPane = document.getElementById(`tab-${tabName}`); if (selectedPane) { selectedPane.classList.add('active'); // Nếu là tab profile, cập nhật nội dung if (tabName === 'profile') { updateProfileTabContent(); } if (tabName === 'my-scenes') { loadMyScenes(); } } // Đá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(); } }