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; // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { try { console.log("--- Bắt đầu khởi tạo Frontend ---"); 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) { loadScenes().then(() => { console.log("4. Đang chuẩn bị khôi phục Scene cũ (nếu có)..."); // Chỉ khôi phục khi bản đồ đã nạp xong các marker setTimeout(restoreActiveScene, 500); }); } } catch (error) { console.error("Ứng dụng không thể khởi tạo:", error); } }); /** * 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; map = L.map('map', { zoomControl: true }).setView([startLat, startLng], startZoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, 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 { // Thay vì tạo object mới phức tạp, lấy HTML của marker đầu tiên const firstIcon = childMarkers[0].options.icon; if (firstIcon) return firstIcon; } catch (e) {} // Fallback an toàn nếu có lỗi 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(); }); 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; } 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'); if (token && username) { authGuest.style.display = 'none'; authLoggedIn.style.display = 'block'; document.getElementById('logged-username').innerText = username; document.getElementById('logged-role').innerText = role || 'Thành viên'; } else { authGuest.style.display = 'block'; authLoggedIn.style.display = 'none'; } } /** * Handles user login */ async function handleLogin() { 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/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 || 'Login failed'); localStorage.setItem('jwt', data.token); localStorage.setItem('username', data.user.username); localStorage.setItem('role', data.user.role); localStorage.setItem('userId', data.user.id); checkAuthStatus(); loadScenes(); // Reload scenes to show member/private scenes alert('Logged in successfully!'); } catch (error) { alert(error.message); } } /** * 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); } } /** * 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(); loadScenes(); // Reload scenes to filter out private ones alert('Logged out successfully'); } /** * 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) => { const latNum = parseFloat(scene.lat); const lngNum = parseFloat(scene.lng); if (isNaN(latNum) || isNaN(lngNum)) return; // Logic lọc Ảnh mẹ: Mỗi tọa độ GPS chỉ tạo duy nhất 1 Marker đại diện const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`; if (seenCoordinates.has(coordKey)) return; // Bỏ qua nếu tọa độ này đã có Marker seenCoordinates.add(coordKey); // Kiểm tra an toàn dữ liệu từ MongoDB trước khi truy cập if (!scene.assetId || !scene.assetId._id) { console.warn(`Scene "${scene.title}" thiếu dữ liệu ảnh (AssetId), bỏ qua.`); return; } let thumbUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`; 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: `
${scene.title}
`, 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: scene.title // Tooltip khi di chuột qua }); // Tạo nội dung thông tin khi Hover (Tooltip) const createdDate = scene.assetId?.createdAt ? new Date(scene.assetId.createdAt).toLocaleDateString('vi-VN') : 'N/A'; const tooltipContent = `
${scene.title}
${scene.description ? `${scene.description}
` : ''} Người tạo: ${scene.owner ? scene.owner.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 ownerId = scene.owner?._id || scene.owner; if (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; document.getElementById('modal-lat').value = scene.lat; document.getElementById('modal-lng').value = scene.lng; document.getElementById('modal-title').value = scene.title; 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) { 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(); } 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 || ''); const response = await fetch(url, { method: 'GET', headers }); const scene = await response.json(); if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details'); // Tự động focus bản đồ vào vị trí của Scene if (map) { map.flyTo([scene.lat, 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.lat; document.getElementById('modal-lng').value = 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; // Construct secure image URL passing shareToken if applicable let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`; // Ư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, scene.hotspots || []); } 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'); // Reset form và gán tọa độ form.reset(); switchHSTab('select'); // Luôn mặc định về tab chọn ảnh có sẵn khi mở modal 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'; // 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.text || ''; document.getElementById('hs-desc').value = existingHotspot.description || ''; if (existingHotspot.targetSceneId) { select.value = existingHotspot.targetSceneId; } } } catch (e) { console.error("Lỗi nạp danh sách scene:", e); } modal.style.display = 'flex'; // Xử lý sự kiện submit form form.onsubmit = async (e) => { e.preventDefault(); const formData = new FormData(form); // Logic: Nếu chọn upload file mới, tạo Scene trước let finalTargetId = formData.get('targetSceneId'); const file = document.getElementById('hs-panorama-file').files[0]; if (file) { const sceneData = new FormData(); sceneData.append('panorama', file); sceneData.append('title', formData.get('title')); const gpsMode = document.getElementById('hs-gps-mode').value; if (gpsMode === 'manual') { sceneData.append('lat', document.getElementById('hs-lat').value); sceneData.append('lng', document.getElementById('hs-lng').value); } else if (gpsMode === 'inherit') { sceneData.append('lat', document.getElementById('modal-lat').value); sceneData.append('lng', document.getElementById('modal-lng').value); } 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; // Dừng luồng cũ vì uploadWithProgress đã tiếp quản } // Lưu Hotspot 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'; } /** * Chuyển đổi giữa tab Chọn ảnh có sẵn và Tải ảnh mới */ function switchHSTab(tabName) { const selectTab = document.getElementById('hs-tab-select'); const uploadTab = document.getElementById('hs-tab-upload'); const btns = document.querySelectorAll('.tab-btn'); btns.forEach(btn => btn.classList.remove('active')); if (tabName === 'select') { selectTab.style.display = 'block'; uploadTab.style.display = 'none'; btns[0].classList.add('active'); document.getElementById('hs-panorama-file').value = ''; // Reset file input } else { selectTab.style.display = 'none'; uploadTab.style.display = 'block'; btns[1].classList.add('active'); document.getElementById('hs-target-id').value = ''; // Reset select } } /** * Ẩ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, text, description, targetSceneId, hotspotId) { const token = localStorage.getItem('jwt'); try { const response = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}/hotspots`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ hotspotId, pitch, yaw, text, description, targetSceneId }) }); 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!'); // Refresh lại scene hiện tại để cập nhật viewer // Chúng ta cần lấy lại thông tin scene để có assetId mới nếu có const res = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}`, { headers: { 'Authorization': `Bearer ${token}` } }); const updatedScene = await res.json(); let secureImageUrl = `${API_BASE_URL}/assets/view/${updatedScene.assetId._id}?token=${token}`; // Buộc nạp lại để cập nhật danh sách hotspot mới openScene(currentSceneId, updatedScene.privacy, updatedScene.shareToken || '', 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); } };