const API_BASE_URL = '/api'; // Sử dụng đường dẫn tương đối để tránh lỗi CORS/Hostname let map; let tempMarker = null; let markerClusterGroup; let currentSceneId = null; let previousSceneId = null; let miniMap = null; let miniMapMarker = null; let systemSettings = { timezone: 'Asia/Ho_Chi_Minh', language: 'vi' }; let returnToDashboardAfterEdit = false; let assetIdToDelete = null; let sceneIdToDelete = null; let dashboardReturnTab = 'media-library'; let editMiniMap = null; let editMiniMapMarker = null; let currentEditingScene = null; // Lưu object scene đang sửa để quản lý chia sẻ let sharedUsersData = []; // [{id, username, email}] let sharedEmailsData = []; // [email] // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { try { console.log("--- Bắt đầu khởi tạo Frontend ---"); // 0. Kiểm tra tham số URL để truy cập trực tiếp const urlParams = new URLSearchParams(window.location.search); const urlSceneId = urlParams.get('sceneId'); const urlToken = urlParams.get('token'); // Ưu tiên nạp cấu hình hệ thống trước fetchSystemSettings(); if (document.getElementById('map')) { console.log("1. Đang khởi tạo bản đồ Leaflet..."); initMap(); } // Chạy tuần tự để tránh xung đột luồng xử lý checkAuthStatus(); // 2. Kiểm tra đăng nhập // 3. Xử lý logic vào thẳng Scene hoặc khôi phục trang if (urlSceneId) { console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`); openScene(urlSceneId, urlToken ? 'shared' : null, urlToken); } else { restoreActiveScene(); } // Đảm bảo map đã sẵn sàng trước khi nạp data if (map) { // Chỉ nạp danh sách Scene để vẽ marker lên bản đồ loadScenes(); } } catch (error) { console.error("Ứng dụng không thể khởi tạo:", error); } }); /** * Lấy cấu hình hệ thống từ Backend */ async function fetchSystemSettings() { try { const res = await fetch(`${API_BASE_URL}/system/settings`); if (res.ok) { systemSettings = await res.json(); applySystemSettings(); } } catch (e) { console.warn("Không thể nạp cấu hình hệ thống, dùng mặc định."); } } /** * Áp dụng cấu hình Múi giờ và Ngôn ngữ vào UI */ function applySystemSettings() { console.log(`[System] Applying settings: Language=${systemSettings.language}, Timezone=${systemSettings.timezone}`); // 1. Cập nhật nhãn (Labels) dựa trên ngôn ngữ const translations = { vi: { brand: "Bản đồ Tour 3D Ảo", login: "Đăng nhập / Đăng ký", profile: "Quản lý hồ sơ", logout: "Đăng xuất", dashboardTitle: "Bảng điều khiển người dùng", tabProfile: "Hồ sơ", tabScenes: "Quản lí scene", tabMedia: "Quản lí ảnh và media", tabUsers: "Quản lí 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) { const avatar = document.getElementById('profile-avatar-initials'); const userDisplay = document.getElementById('profile-username-display'); const statusDisplay = document.getElementById('profile-status-display'); const userInput = document.getElementById('profile-username'); if (avatar) avatar.innerText = username.charAt(0).toUpperCase(); if (userDisplay) userDisplay.innerText = username; if (statusDisplay) statusDisplay.innerText = role || 'Thành viên'; if (userInput) userInput.value = username; } } /** * 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) { if (authGuest) authGuest.style.display = 'none'; // Hide login form authLoggedIn.style.display = 'block'; // Show welcome message and buttons avatarInitials.innerText = username.charAt(0).toUpperCase(); // Hiển thị các nút dành cho admin (Chủ sở hữu/Admin tối cao) const adminButtons = document.querySelectorAll('.dashboard-tabs .admin-only'); if (role === 'admin' || role === 'Chủ sở hữu') { adminButtons.forEach(btn => btn.style.display = 'block'); } else { adminButtons.forEach(btn => btn.style.display = 'none'); } } else { authGuest.style.display = 'block'; // Show login form authLoggedIn.style.display = 'none'; // Hide welcome message avatarInitials.innerText = '?'; document.getElementById('user-dropdown').classList.remove('show'); // Đóng dropdown nếu không đăng nhập } } /** * Handles user login */ async function handleLogin() { const errorMsg = document.getElementById('login-error-msg'); if (errorMsg) errorMsg.style.display = 'none'; const username = document.getElementById('username-input').value.trim(); const password = document.getElementById('password-input').value.trim(); if (!username || !password) { if (errorMsg) { errorMsg.innerText = 'Vui lòng nhập đầy đủ thông tin'; errorMsg.style.display = 'block'; } return; } try { const response = await fetch(`${API_BASE_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Đăng nhập thất bại'); localStorage.setItem('jwt', data.token); localStorage.setItem('username', data.user.username); localStorage.setItem('role', data.user.role); localStorage.setItem('userId', data.user.id); checkAuthStatus(); toggleDropdown(); // Đóng dropdown sau khi đăng nhập loadScenes(); // Reload scenes to show member/private scenes // Làm sạch form document.getElementById('username-input').value = ''; document.getElementById('password-input').value = ''; } catch (error) { if (errorMsg) { errorMsg.innerText = error.message; errorMsg.style.display = 'block'; } } } /** * Chuyển đổi giữa chế độ Đăng nhập và Đăng ký trong dropdown */ window.switchAuthMode = function(mode) { const loginSection = document.getElementById('login-section'); const registerSection = document.getElementById('register-section'); const loginBtn = document.getElementById('tab-login-btn'); const registerBtn = document.getElementById('tab-register-btn'); if (mode === 'login') { loginSection.style.display = 'block'; registerSection.style.display = 'none'; loginBtn.classList.add('active'); registerBtn.classList.remove('active'); } else { loginSection.style.display = 'none'; registerSection.style.display = 'block'; loginBtn.classList.remove('active'); registerBtn.classList.add('active'); } }; /** * Handles user registration */ async function handleRegister() { const errorMsg = document.getElementById('reg-error-msg'); if (errorMsg) errorMsg.style.display = 'none'; const fullName = document.getElementById('reg-fullname').value.trim(); const email = document.getElementById('reg-email').value.trim(); const username = document.getElementById('reg-username').value.trim(); const password = document.getElementById('reg-password').value; const confirm = document.getElementById('reg-confirm').value; const agree = document.getElementById('reg-agree').checked; // Kiểm tra các trường trống if (!fullName || !email || !username || !password || !confirm) { if (errorMsg) { errorMsg.innerText = 'Vui lòng điền đầy đủ thông tin'; errorMsg.style.display = 'block'; } return; } // Kiểm tra mật khẩu khớp nhau if (password !== confirm) { if (errorMsg) { errorMsg.innerText = 'Mật khẩu xác nhận không khớp'; errorMsg.style.display = 'block'; } return; } // Kiểm tra đồng ý quy định if (!agree) { if (errorMsg) { errorMsg.innerText = 'Bạn phải đồng ý với quy định của trang'; errorMsg.style.display = 'block'; } return; } try { const response = await fetch(`${API_BASE_URL}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fullName, email, username, password, agreedToRules: agree, role: 'Thành viên' }) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Đăng ký thất bại'); showNotification('Đăng ký thành công! Bạn có thể đăng nhập ngay bây giờ.', 'success'); switchAuthMode('login'); // Tự động chuyển về tab đăng nhập sau khi thành công } catch (error) { if (errorMsg) { errorMsg.innerText = error.message; errorMsg.style.display = 'block'; } } } /** * Hiển thị hộp thoại xác nhận đăng xuất */ function showLogoutConfirm() { const modal = document.getElementById('logout-confirm-modal'); if (modal) { closeDashboard(); // Đảm bảo đóng dashboard nếu đang mở modal.style.display = 'flex'; } } /** * Đóng hộp thoại xác nhận đăng xuất */ function closeLogoutConfirm() { const modal = document.getElementById('logout-confirm-modal'); if (modal) { modal.style.display = 'none'; openDashboard(); // Mở lại dashboard sau khi đóng confirm } } /** * Handles user logout */ function handleLogout() { localStorage.removeItem('jwt'); localStorage.removeItem('username'); localStorage.removeItem('role'); localStorage.removeItem('activeSceneId'); localStorage.removeItem('activeScenePrivacy'); localStorage.removeItem('activeSceneToken'); localStorage.removeItem('userId'); // Đảm bảo đóng viewer nếu đang mở if (typeof closeViewer === 'function') { closeViewer(); } checkAuthStatus(); if (document.getElementById('user-dropdown').classList.contains('show')) { toggleDropdown(); } closeLogoutConfirm(); closeDashboard(); loadScenes(); // Reload scenes to filter out private ones } /** * Opens Modal for creating a Scene and sets lat/lng inputs */ function openCreateSceneModal(lat, lng) { returnToDashboardAfterEdit = false; const token = localStorage.getItem('jwt'); if (!token) { showNotification('Please log in first to create a 3D scene.', 'error'); return; } // Place a temporary marker on the map if (tempMarker) map.removeLayer(tempMarker); tempMarker = L.marker([lat, lng]).addTo(map); document.getElementById('create-scene-modal').style.display = 'flex'; document.getElementById('modal-scene-id').value = ''; document.getElementById('modal-lat').value = lat.toFixed(6); document.getElementById('modal-lng').value = lng.toFixed(6); const lang = systemSettings.language || 'vi'; const modalTitle = document.getElementById('create-scene-modal-title'); if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Tạo 3D scene mới" : "Create New 3D Scene"; } /** * Closes the Create Scene Modal and removes temporary marker */ function closeModal() { document.getElementById('create-scene-modal').style.display = 'none'; if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } document.getElementById('create-scene-form').reset(); document.getElementById('shared-with-group').style.display = 'none'; if (returnToDashboardAfterEdit) { const targetTab = dashboardReturnTab; returnToDashboardAfterEdit = false; openDashboard(); openDashboardTab(targetTab); } } /** * Toggles visibility of the shared users input based on privacy selection */ function toggleSharedUsers() { const privacy = document.getElementById('modal-privacy').value; const group = document.getElementById('shared-with-group'); if (privacy === 'shared') { group.style.display = 'block'; } else { group.style.display = 'none'; } } /** * Form submission for Scene creation (multipart/form-data) */ async function submitScene(e) { e.preventDefault(); const form = document.getElementById('create-scene-form'); const formData = new FormData(form); const sceneId = document.getElementById('modal-scene-id').value; const token = localStorage.getItem('jwt'); const url = sceneId ? `${API_BASE_URL}/scenes/${sceneId}` : `${API_BASE_URL}/scenes`; const method = sceneId ? 'PUT' : 'POST'; uploadWithProgress(url, method, formData, token, 'create', () => { showNotification(sceneId ? 'Scene đang được cập nhật ngầm!' : 'Scene đã được tạo! Ảnh đang được xử lý 8K...', 'success'); closeModal(); loadScenes(); }); } /** * Helper function to handle uploads with progress bars */ function uploadWithProgress(url, method, formData, token, prefix, callback) { const xhr = new XMLHttpRequest(); const container = document.getElementById(`${prefix}-progress-container`); const bar = document.getElementById(`${prefix}-progress-bar`); const percentText = document.getElementById(`${prefix}-progress-percent`); const statusText = document.getElementById(`${prefix}-progress-status`); if (container) container.style.display = 'block'; if (statusText) statusText.innerText = "Đang tải ảnh lên..."; xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); if (bar) bar.style.width = percent + '%'; if (percentText) percentText.innerText = percent + '%'; if (percent === 100 && statusText) statusText.innerText = "Tải lên xong! Đang khởi tạo trên server..."; } }); xhr.addEventListener('load', () => { if (container) container.style.display = 'none'; if (xhr.status >= 200 && xhr.status < 300) { callback(JSON.parse(xhr.responseText)); } else { let errorMsg = 'Không thể tải lên'; try { const err = JSON.parse(xhr.responseText); errorMsg = err.message || errorMsg; } catch (e) {} showNotification('Lỗi: ' + errorMsg, 'error'); } }); xhr.addEventListener('error', () => { if (container) container.style.display = 'none'; showNotification('Lỗi kết nối mạng.', 'error'); }); xhr.open(method, url); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.send(formData); } /** * Loads and displays visible Scenes on the map */ async function loadScenes() { try { const token = localStorage.getItem('jwt'); const headers = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } // Thêm timestamp để tránh lỗi 304 hang do cache trình duyệt const timestamp = new Date().getTime(); console.log(`3.1 Đang gửi yêu cầu lấy danh sách Scene (ts: ${timestamp})...`); const response = await fetch(`${API_BASE_URL}/scenes?_=${timestamp}`, { method: 'GET', headers }); console.log(`[API Response] /scenes status: ${response.status}`); if (!response.ok) throw new Error('Failed to load scenes'); const scenes = await response.json(); console.log(`[Data] Nhận được ${scenes.length} scenes từ server`); if (!Array.isArray(scenes)) return; // Xóa sạch các layers cũ trước khi nạp mới markerClusterGroup.clearLayers(); const markersToAdd = []; const activeSceneId = localStorage.getItem('activeSceneId'); const seenCoordinates = new Set(); // Dùng để lọc "Ảnh mẹ" (1 marker per location) // Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ scenes.forEach((scene) => { // 1. Kiểm tra tọa độ an toàn - Ngăn chặn treo map do NaN const latNum = Number(scene.gps?.lat ?? scene.lat); const lngNum = Number(scene.gps?.lng ?? scene.lng); if (isNaN(latNum) || isNaN(lngNum)) { console.error(`Bỏ qua Scene "${scene.name || scene.title}" do tọa độ lỗi:`, scene); return; } // 2. Logic lọc Ảnh mẹ: Sửa lỗi typo coordKey (dùng latNum 2 lần) const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`; if (seenCoordinates.has(coordKey)) return; seenCoordinates.add(coordKey); // 3. Truy cập Asset an toàn const assetId = scene.assetId?._id || scene.assetId; if (!assetId) return; // Bỏ qua nếu không có ảnh liên kết const sceneName = scene.name || scene.title || "Untitled Scene"; let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`; if (token) thumbUrl += `?token=${token}`; else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`; const calloutIcon = L.divIcon({ className: 'custom-scene-marker', html: `
${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 === 'admin' || userRole === 'Chủ sở hữu'; const isOwner = currentUserId && ownerId && ownerId.toString() === currentUserId.toString(); if (isAdmin || isOwner) { handleEditDeleteScene(scene); } else if (scene.privacy === 'public') { // Cho phép bất kỳ ai (kể cả khách) lấy link chia sẻ của scene công khai showShareLink(scene); } else { showNotification("Bạn không có quyền chỉnh sửa scene này.", 'warning'); } }); markersToAdd.push(marker); }); // Thêm danh sách marker đã lọc vào group markerClusterGroup.addLayers(markersToAdd); } catch (error) { console.error('Error loading scenes:', error); } } /** * Handles Edit/Delete options for a scene */ async function handleEditDeleteScene(scene) { const modal = document.getElementById('action-choice-modal'); const title = document.getElementById('action-modal-title'); const editBtn = document.getElementById('btn-edit-action'); const editPrivacyBtn = document.getElementById('btn-edit-privacy-action'); const deleteBtn = document.getElementById('btn-delete-action'); title.innerText = `Scene: ${scene.title}`; modal.style.display = 'flex'; // Hành động Chỉnh sửa privacy editPrivacyBtn.onclick = () => { returnToDashboardAfterEdit = false; closeActionModal(); // Mở modal metadata, false vì ảnh trên map luôn là ảnh mẹ (không phải child) openEditMetadataModal(scene, false); }; // Gán sự kiện cho nút Sửa editBtn.onclick = () => { returnToDashboardAfterEdit = false; closeActionModal(); openEditMetadataModal(scene, false); }; // Gán sự kiện cho nút Xóa deleteBtn.onclick = () => { returnToDashboardAfterEdit = false; // Đảm bảo không mở dashboard nếu xóa từ map closeActionModal(); deleteScene(scene._id); }; } /** * Closes the Action Choice Modal */ function closeActionModal() { document.getElementById('action-choice-modal').style.display = 'none'; } /** * 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 const lang = systemSettings.language || 'vi'; const modalTitle = document.getElementById('create-scene-modal-title'); if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Sửa 3D scene" : "Edit 3D scene"; toggleSharedUsers(); document.getElementById('create-scene-modal').style.display = 'flex'; } /** * Mở modal xác nhận xóa scene */ window.deleteScene = function(sceneId) { sceneIdToDelete = sceneId; document.getElementById('delete-scene-confirm-modal').style.display = 'flex'; }; window.closeDeleteSceneModal = function() { document.getElementById('delete-scene-confirm-modal').style.display = 'none'; sceneIdToDelete = null; if (returnToDashboardAfterEdit) { const targetTab = dashboardReturnTab; returnToDashboardAfterEdit = false; openDashboard(); openDashboardTab(targetTab); } }; window.confirmDeleteScene = async function() { if (!sceneIdToDelete) return; const token = localStorage.getItem('jwt'); try { const response = await fetch(`${API_BASE_URL}/scenes/${sceneIdToDelete}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to delete scene'); closeDeleteSceneModal(); showSuccessModal(data.message || 'Scene đã được xóa vĩnh viễn'); loadScenes(); if (document.getElementById('tab-my-scenes').classList.contains('active')) { loadMyScenes(); } } catch (error) { showNotification("Lỗi khi xóa: " + error.message, 'error'); } }; /** * Fetches secure scene details and triggers the Panorama viewer */ async function openScene(sceneId, privacy, shareToken, force = false, initialPitch = 0, initialYaw = 0) { // Nếu đang xem chính scene này và không yêu cầu làm mới (force), không cần nạp lại if (!force && currentSceneId === sceneId && document.getElementById('viewer-container').style.display === 'block') { return; } try { const token = localStorage.getItem('jwt'); const headers = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } console.log(`[Viewer] Đang mở scene: ${sceneId}`); let url = `${API_BASE_URL}/scenes/${sceneId}`; if (privacy === 'shared' && shareToken) { url += `?token=${shareToken}`; } // Lưu trạng thái Scene hiện tại để khôi phục sau khi reload trang localStorage.setItem('activeSceneId', sceneId); localStorage.setItem('activeScenePrivacy', privacy || ''); localStorage.setItem('activeSceneToken', shareToken || ''); // Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng const [sceneRes, hotspotsRes] = await Promise.all([ fetch(url, { method: 'GET', headers }), fetch(`${API_BASE_URL}/hotspots/${sceneId}`, { method: 'GET', headers }) ]); const scene = await sceneRes.json(); const hotspots = await hotspotsRes.json(); console.log("DEBUG: Hotspots raw data from API:", hotspots); if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); // Lấy ID người tạo (createdBy) để phân quyền chuột phải trong viewer const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner; // Tự động focus bản đồ vào vị trí của Scene if (map) { map.flyTo([scene.gps?.lat || scene.lat, scene.gps?.lng || scene.lng], 16); } // Cập nhật tọa độ vào các input ẩn để hỗ trợ GPS inheritance cho hotspot khi tải ảnh mới document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat; document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng; // Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động if (currentSceneId && currentSceneId !== sceneId) { previousSceneId = currentSceneId; } currentSceneId = sceneId; // Kiểm tra an toàn assetId (hỗ trợ cả dạng Object và String ID) const assetId = scene.assetId?._id || scene.assetId; if (!assetId) throw new Error("Dữ liệu hình ảnh của cảnh này bị lỗi hoặc chưa xử lý xong."); let secureImageUrl = `${API_BASE_URL}/assets/view/${assetId}`; // Ưu tiên JWT token nếu đang đăng nhập, nếu không thì dùng shareToken if (token) { secureImageUrl += `?token=${token}`; } else if (privacy === 'shared' && scene.shareToken) { secureImageUrl += `?token=${scene.shareToken}`; } // Initialize 3D Viewer with secure, referer-protected image stream initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId, initialPitch, initialYaw); // Sau khi mở thành công từ URL trực tiếp, xóa tham số để làm sạch thanh địa chỉ (URL chuyên nghiệp) const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('sceneId')) { window.history.replaceState({}, document.title, "/"); } } catch (error) { if (typeof closeViewer === 'function') closeViewer(); localStorage.removeItem('activeSceneId'); localStorage.removeItem('activeScenePrivacy'); localStorage.removeItem('activeSceneToken'); // Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ) const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('sceneId')) { showNotification("Bạn không có quyền truy cập hoặc liên kết chia sẻ đã hết hạn. Quay về bản đồ công cộng.", 'error'); // Xóa toàn bộ tham số URL và tải lại trang để làm mới trạng thái (về trang chủ dành cho khách) window.history.replaceState({}, document.title, "/"); location.reload(); return; } showNotification(error.message, 'error'); } } /** * Khôi phục Scene đang xem từ localStorage sau khi reload trang */ function restoreActiveScene() { const savedSceneId = localStorage.getItem('activeSceneId'); if (savedSceneId) { const savedPrivacy = localStorage.getItem('activeScenePrivacy'); const savedToken = localStorage.getItem('activeSceneToken'); const savedPitch = parseFloat(localStorage.getItem('activeScenePitch')) || 0; const savedYaw = parseFloat(localStorage.getItem('activeSceneYaw')) || 0; openScene(savedSceneId, savedPrivacy, savedToken, false, savedPitch, savedYaw); } } /** * Xử lý việc tạo hotspot sau khi click chuột phải trong trình xem 360 * @param {number} pitch - Tọa độ dọc (-90 đến 90) * @param {number} yaw - Tọa độ ngang (-180 đến 180) * @param {Object} existingHotspot - Thông tin hotspot cũ nếu có */ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null) { const token = localStorage.getItem('jwt'); if (!token) { showNotification('Vui lòng đăng nhập để thực hiện thao tác này.', 'warning'); return; } const modal = document.getElementById('hotspot-modal'); const form = document.getElementById('hotspot-form'); // Hiển thị Modal TRƯỚC để các logic UI (như Mini Map) tính toán được kích thước modal.style.display = 'flex'; // Reset form và gán tọa độ form.reset(); document.getElementById('hs-pitch').value = pitch; document.getElementById('hs-yaw').value = yaw; document.getElementById('hs-id').value = existingHotspot ? existingHotspot._id : ''; document.getElementById('hotspot-modal-title').innerText = existingHotspot ? 'Cập nhật điểm điều hướng' : 'Thêm điểm điều hướng mới'; // Reset UI states document.querySelector('input[name="hsLinkType"][value="existing"]').checked = true; window.toggleHSLinkType('existing'); document.querySelector('input[name="hsGPSMode"][value="map"]').checked = true; window.toggleHSGPSMode('map'); // Lấy danh sách Scene có sẵn để đổ vào dropdown try { const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } }); const scenes = await res.json(); const select = document.getElementById('hs-target-id'); select.innerHTML = ''; 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) { showNotification('Vui lòng chọn ảnh panorama.', 'warning'); return; } const gpsMode = formData.get('hsGPSMode'); let lat = 0, lng = 0; if (gpsMode === 'inherit') { // Ép kiểu về Number khi lấy từ input lat = Number(document.getElementById('modal-lat').value); lng = Number(document.getElementById('modal-lng').value); } else { lat = Number(document.getElementById('hs-lat').value); lng = Number(document.getElementById('hs-lng').value); if (!lat || !lng) { showNotification('Vui lòng chọn vị trí GPS.', 'warning'); return; } } const sceneData = new FormData(); sceneData.append('panorama', file); sceneData.append('title', formData.get('title')); sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại sceneData.append('lng', lng); sceneData.append('privacy', 'public'); uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => { await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id); closeHotspotModal(); }); return; } const finalTargetId = formData.get('targetSceneId'); if (!finalTargetId) { showNotification('Vui lòng chọn cảnh để liên kết.', 'warning'); return; } await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id); modal.style.display = 'none'; }; }; /** * Đóng Modal biên tập Hotspot */ function closeHotspotModal() { document.getElementById('hotspot-modal').style.display = 'none'; } /** * Khởi tạo hoặc cập nhật Mini Map trong Hotspot Modal */ function initHSMiniMap() { const pLat = parseFloat(document.getElementById('modal-lat').value) || 21.0285; const pLng = parseFloat(document.getElementById('modal-lng').value) || 105.8542; if (miniMap) { miniMap.setView([pLat, pLng], 15); updateHSMiniMapMarker(pLat, pLng); // Fix lỗi vỡ tiles của Leaflet trong Modal setTimeout(() => miniMap.invalidateSize(), 200); return; } miniMap = L.map('hs-mini-map').setView([pLat, pLng], 15); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(miniMap); miniMap.on('click', (e) => { const { lat, lng } = e.latlng; updateHSMiniMapMarker(lat, lng); }); updateHSMiniMapMarker(pLat, pLng); setTimeout(() => miniMap.invalidateSize(), 200); } function updateHSMiniMapMarker(lat, lng) { if (miniMapMarker) miniMap.removeLayer(miniMapMarker); miniMapMarker = L.marker([lat, lng]).addTo(miniMap); document.getElementById('hs-lat').value = lat.toFixed(6); document.getElementById('hs-lng').value = lng.toFixed(6); } window.toggleHSLinkType = function(type) { document.getElementById('hs-section-existing').style.display = type === 'existing' ? 'block' : 'none'; document.getElementById('hs-section-upload').style.display = type === 'upload' ? 'block' : 'none'; if (type === 'upload') { const gpsMode = document.querySelector('input[name="hsGPSMode"]:checked')?.value || 'map'; window.toggleHSGPSMode(gpsMode); } }; window.toggleHSGPSMode = function(mode) { document.getElementById('hs-map-selector').style.display = mode === 'map' ? 'block' : 'none'; document.getElementById('hs-manual-gps').style.display = mode === 'manual' ? 'block' : 'none'; if (mode === 'map') initHSMiniMap(); }; /** * Chuyển đổi tab cũ (giữ lại để tương thích nếu cần) */ function switchHSTab(tabName) { if (tabName === 'select') { window.toggleHSLinkType('existing'); } else { window.toggleHSLinkType('upload'); } } /** * Ẩn/hiện input nhập GPS thủ công */ function toggleManualGPS() { const mode = document.getElementById('hs-gps-mode').value; const manualDiv = document.getElementById('hs-manual-gps'); manualDiv.style.display = mode === 'manual' ? 'block' : 'none'; } /** * Gửi dữ liệu lưu Hotspot lên Backend */ async function saveHotspotToDB(pitch, yaw, title, description, targetSceneId, hotspotId) { const token = localStorage.getItem('jwt'); // Gọi đúng API create hoặc update tùy vào trạng thái const url = hotspotId ? `${API_BASE_URL}/hotspots/update/${hotspotId}` : `${API_BASE_URL}/hotspots/create`; const method = hotspotId ? 'PUT' : 'POST'; try { const body = { title, description, target_scene_id: targetSceneId, coordinates: { pitch: Number(pitch), yaw: Number(yaw) } }; // Nếu tạo mới, cần gửi kèm ID của scene hiện tại làm parent if (!hotspotId) { body.parent_scene_id = currentSceneId; } const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Lỗi khi lưu hotspot'); showNotification('Lưu điểm điều hướng thành công!', 'success'); // Buộc nạp lại để cập nhật danh sách hotspot mới openScene(currentSceneId, localStorage.getItem('activeScenePrivacy'), localStorage.getItem('activeSceneToken'), true); } catch (error) { console.error(error); showNotification(error.message, 'error'); } } /** * Công cụ dọn dẹp toàn bộ dữ liệu (Chỉ dùng cho nhà phát triển) * Gọi lệnh: systemReset() từ trình duyệt */ window.systemReset = async function() { if (!confirm("CẢNH BÁO: Thao tác này sẽ xóa sạch TOÀN BỘ scene và ảnh trên server. Bạn có chắc chắn?")) return; const token = localStorage.getItem('jwt'); try { const response = await fetch(`${API_BASE_URL}/maintenance/reset-all`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); const data = await response.json(); if (response.ok) { localStorage.clear(); // Xóa sạch token, vị trí map, active scene showNotification(data.message, 'success'); location.reload(); } else { throw new Error(data.message); } } catch (e) { showNotification("Lỗi reset: " + e.message, 'error'); } }; /** * Mở Menu tùy chọn cho Hotspot (Sửa/Xóa) */ window.openHotspotMenu = function(hotspot) { const modal = document.getElementById('hotspot-action-modal'); const editBtn = document.getElementById('btn-hs-edit'); const deleteBtn = document.getElementById('btn-hs-delete'); modal.style.display = 'flex'; // Hành động Sửa: Mở form biên tập với dữ liệu cũ editBtn.onclick = () => { closeHotspotActionModal(); window.handleHotspotCreation(hotspot.pitch, hotspot.yaw, hotspot); }; // Hành động Xóa: Xác nhận và gọi API xóa deleteBtn.onclick = async () => { if (confirm('Bạn có chắc chắn muốn xóa điểm điều hướng này?')) { closeHotspotActionModal(); await deleteHotspot(hotspot._id); } }; }; /** * Đóng Modal tùy chọn Hotspot */ function closeHotspotActionModal() { document.getElementById('hotspot-action-modal').style.display = 'none'; } /** * Xóa Hotspot thông qua API */ async function deleteHotspot(hotspotId) { const token = localStorage.getItem('jwt'); try { // Gọi đúng API delete hotspot độc lập const response = await fetch(`${API_BASE_URL}/hotspots/delete/${hotspotId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { const err = await response.json(); throw new Error(err.message || 'Lỗi xóa hotspot'); } showNotification('Đã xóa điểm điều hướng.', 'success'); // Refresh lại scene hiện tại để cập nhật viewer openScene(currentSceneId, null, null, true); } catch (e) { showNotification(e.message, 'error'); } } /** * Toggles the visibility of the user dropdown menu. */ function toggleDropdown() { document.getElementById('user-dropdown').classList.toggle('show'); } /** * Opens the user dashboard overlay. */ function openDashboard() { const username = localStorage.getItem('username'); const role = localStorage.getItem('role'); if (username) { document.getElementById('sidebar-avatar').innerText = username.charAt(0).toUpperCase(); document.getElementById('sidebar-username').innerText = username; document.getElementById('sidebar-status').innerText = role || 'Thành viên'; } document.getElementById('dashboard-overlay').style.display = 'flex'; document.getElementById('user-dropdown').classList.remove('show'); // Close dropdown // Mở tab profile mặc định khi mở dashboard openDashboardTab('profile'); } /** * Closes the user dashboard overlay. */ function closeDashboard() { document.getElementById('dashboard-overlay').style.display = 'none'; } /** * Tải danh sách scene của chính người dùng đăng nhập */ async function loadMyScenes() { const token = localStorage.getItem('jwt'); const listContainer = document.getElementById('my-scenes-list'); // Chuyển sang grid để đồng bộ với media library listContainer.className = 'dashboard-grid'; listContainer.innerHTML = '

Đ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 assetId = scene.assetId?._id || scene.assetId; let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`; if (token) thumbUrl += `?token=${token}`; const card = document.createElement('div'); card.className = 'scene-card'; card.style.backgroundImage = `url('${thumbUrl}')`; card.innerHTML = `
${scene.name || scene.title}

${scene.description || 'Không có mô tả'}

🔒 ${scene.privacy} 👤 ${scene.createdBy?.username || 'Bạn'} 📅 ${formatSystemDate(scene.createdAt)}
`; listContainer.appendChild(card); // Xử lý nút Sửa: Logic đóng dashboard -> mở modal -> quay lại dashboard document.getElementById(`edit-scene-${scene._id}`).onclick = () => { dashboardReturnTab = 'my-scenes'; returnToDashboardAfterEdit = true; closeDashboard(); // Mặc định truyền false cho isChild, logic backend sẽ xử lý cascade privacy sau openEditMetadataModal(scene, false); }; // Xử lý nút Xóa (Sẽ được hoàn thiện ở Bước 4) document.getElementById(`delete-scene-${scene._id}`).onclick = () => { dashboardReturnTab = 'my-scenes'; returnToDashboardAfterEdit = true; closeDashboard(); deleteScene(scene._id); }; }); } catch (e) { listContainer.innerHTML = `

Lỗi: ${e.message}

`; } } /** * Tải danh sách người dùng dành cho Admin tối cao */ async function loadAdminUsers() { const token = localStorage.getItem('jwt'); const container = document.getElementById('admin-users-list'); if (!container) return; container.innerHTML = '

Đang tải danh sách người dùng...

'; try { const res = await fetch(`${API_BASE_URL}/admin/users`, { headers: { 'Authorization': `Bearer ${token}` } }); const users = await res.json(); if (!res.ok) throw new Error(users.message); let html = ` `; users.forEach(user => { html += ` `; }); html += '
Họ tên Username Email Quyền hạn Reset Password Thao tác
${user.username}
'; container.innerHTML = html; } catch (e) { container.innerHTML = `

Lỗi: ${e.message}

`; } } window.updateUserByAdmin = async function(userId) { const token = localStorage.getItem('jwt'); const payload = { fullName: document.getElementById(`adm-fn-${userId}`).value, email: document.getElementById(`adm-em-${userId}`).value, role: document.getElementById(`adm-role-${userId}`).value, password: document.getElementById(`adm-pw-${userId}`).value }; try { const res = await fetch(`${API_BASE_URL}/admin/users/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(payload) }); const data = await res.json(); if (!res.ok) throw new Error(data.message); showNotification(data.message, 'success'); loadAdminUsers(); } catch (e) { showNotification(e.message, 'error'); } }; window.deleteUserByAdmin = async function(userId) { if (!confirm('Xóa vĩnh viễn người dùng này?')) return; const token = localStorage.getItem('jwt'); try { const res = await fetch(`${API_BASE_URL}/admin/users/${userId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); showNotification(data.message, 'success'); loadAdminUsers(); } catch (e) { showNotification(e.message, 'error'); } }; /** * Tải và hiển thị kho ảnh/media của người dùng */ async function loadMyAssets() { const token = localStorage.getItem('jwt'); const gridContainer = document.getElementById('media-library-list'); const currentUserId = localStorage.getItem('userId'); const userRole = localStorage.getItem('role'); gridContainer.innerHTML = '

Đang tải kho ảnh...

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

Kho ảnh trống.

'; return; } assets.forEach(asset => { const scene = asset.linkedScene; const isTrash = !scene; const parentNames = asset.parentScenes?.map(p => p.name || p.title).join(', '); const card = document.createElement('div'); card.className = `media-card ${isTrash ? 'trash-item' : ''}`; // Build inner HTML without the onclick for edit/delete buttons let innerHtml = `
Thumbnail ${isTrash ? 'Ảnh rác' : ''}
${scene ? (scene.name || scene.title) : 'Chưa gắn Scene'}

${scene?.description || 'Không có mô tả'}

${parentNames ? `` : ''} Tải lên: ${formatSystemDate(asset.createdAt)}
`; card.innerHTML = innerHtml; // Add edit button and its event listener separately if (scene && asset.uploadedBy === currentUserId) { const editButton = document.createElement('button'); editButton.className = 'edit-btn-small'; editButton.innerText = 'Sửa Scene'; dashboardReturnTab = 'media-library'; const isChild = asset.parentScenes && asset.parentScenes.length > 0; editButton.addEventListener('click', () => openEditFromMedia(scene, isChild)); card.querySelector('.media-actions').appendChild(editButton); } // Add delete button and its event listener if (asset.uploadedBy === currentUserId || (isTrash && (userRole === 'admin' || userRole === 'Chủ sở hữu'))) { const deleteButton = document.createElement('button'); deleteButton.className = 'delete-btn-small'; deleteButton.innerText = 'Xóa'; deleteButton.addEventListener('click', () => deleteAsset(asset._id)); card.querySelector('.media-actions').appendChild(deleteButton); } gridContainer.appendChild(card); }); } catch (e) { gridContainer.innerHTML = `

Lỗi nạp media: ${e.message}

`; } } /** * Xóa ảnh khỏi kho media */ window.deleteAsset = function(assetId) { assetIdToDelete = assetId; document.getElementById('delete-asset-confirm-modal').style.display = 'flex'; }; window.closeDeleteAssetModal = function() { document.getElementById('delete-asset-confirm-modal').style.display = 'none'; assetIdToDelete = null; }; window.confirmDeleteAsset = async function() { if (!assetIdToDelete) return; const token = localStorage.getItem('jwt'); try { const res = await fetch(`${API_BASE_URL}/assets/${assetIdToDelete}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); let data; const contentType = res.headers.get("content-type"); if (contentType && contentType.includes("application/json")) { data = await res.json(); } if (!res.ok) { throw new Error(data?.message || `Lỗi máy chủ (${res.status})`); } closeDeleteAssetModal(); showNotification(data.message || "Đã xóa thành công", 'success'); loadMyAssets(); // Nạp lại kho ảnh loadScenes(); // Nạp lại bản đồ nếu có scene bị xóa kèm theo } catch (e) { showNotification("Lỗi khi xóa: " + e.message, 'error'); } }; /** * Hiển thị Modal thông báo thành công */ window.showSuccessModal = function(message, icon = '✓') { const modal = document.getElementById('success-modal'); const msgElem = document.getElementById('success-modal-message'); const iconElem = document.getElementById('success-modal-icon'); if (modal && msgElem && iconElem) { msgElem.innerText = message; iconElem.innerText = icon; modal.style.display = 'flex'; // Tự động ẩn sau 3 giây setTimeout(() => closeSuccessModal(), 3000); } }; /** * Đóng Modal thành công (hỗ trợ click ra ngoài) */ window.closeSuccessModal = function(e) { const modal = document.getElementById('success-modal'); if (!modal) return; // Nếu nhấn từ code (không có e) hoặc click trúng overlay thì đóng if (e && e.target !== modal) return; modal.style.display = 'none'; }; /** * Hiển thị Modal thông báo lỗi hoặc cảnh báo */ window.showErrorModal = function(message, title = "Thông báo", icon = '⚠️') { const modal = document.getElementById('error-modal'); const msgElem = document.getElementById('error-modal-message'); const titleElem = document.getElementById('error-modal-title'); const iconElem = document.getElementById('error-modal-icon'); if (modal && msgElem && iconElem) { msgElem.innerText = message; iconElem.innerText = icon; if (titleElem) titleElem.innerText = title; modal.style.display = 'flex'; } }; /** * Đóng Modal lỗi (hỗ trợ click ra ngoài) */ window.closeErrorModal = function(e) { const modal = document.getElementById('error-modal'); if (!modal) return; // Nếu nhấn từ code (không có e) hoặc click trúng overlay thì đóng if (e && e.target !== modal) return; modal.style.display = 'none'; }; /** * Hàm thông báo dùng chung thay thế alert() */ window.showNotification = function(message, type = 'success') { if (type === 'success') { showSuccessModal(message, '✓'); } else if (type === 'warning') { showErrorModal(message, 'Cảnh báo', '⚠️'); } else { showErrorModal(message, 'Lỗi', '❌'); } }; window.openEditFromMedia = function(scene, isChild = false) { if (!scene || !scene._id) { showNotification("Không thể chỉnh sửa: Ảnh này không được gắn với một Scene hợp lệ.", 'error'); return; } dashboardReturnTab = 'media-library'; returnToDashboardAfterEdit = true; closeDashboard(); openEditMetadataModal(scene, isChild); }; /** * Mở Modal sửa thông tin Metadata chuyên biệt */ window.openEditMetadataModal = function(scene, isChild = false) { currentEditingScene = scene; // Lưu lại để dùng cho chia sẻ // Load dữ liệu chia sẻ hiện tại sharedUsersData = scene.sharedWith || []; sharedEmailsData = scene.sharedEmails || []; document.getElementById('edit-modal-scene-id').value = scene._id; document.getElementById('edit-modal-title').value = scene.name || scene.title || ''; document.getElementById('edit-modal-description').value = scene.description || ''; const lat = scene.gps?.lat || scene.lat; const lng = scene.gps?.lng || scene.lng; document.getElementById('edit-modal-lat').value = lat; document.getElementById('edit-modal-lng').value = lng; // Xử lý logic Privacy cho Cảnh con const privacySelect = document.getElementById('edit-modal-privacy'); const childInfo = document.getElementById('edit-child-privacy-info'); if (isChild) { privacySelect.value = scene.privacy; privacySelect.disabled = true; childInfo.style.display = 'block'; } else { privacySelect.value = scene.privacy; privacySelect.disabled = false; childInfo.style.display = 'none'; } handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng document.getElementById('edit-scene-metadata-modal').style.display = 'flex'; // Khởi tạo Mini Map tại vị trí hiện tại của Scene setTimeout(() => initEditSceneMiniMap(lat, lng), 100); }; function closeEditMetadataModal() { document.getElementById('edit-scene-metadata-modal').style.display = 'none'; if (returnToDashboardAfterEdit) { const targetTab = dashboardReturnTab; returnToDashboardAfterEdit = false; openDashboard(); openDashboardTab(targetTab); } } function initEditSceneMiniMap(lat, lng) { if (editMiniMap) { editMiniMap.setView([lat, lng], 16); if (editMiniMapMarker) editMiniMapMarker.setLatLng([lat, lng]); editMiniMap.invalidateSize(); return; } editMiniMap = L.map('edit-mini-map').setView([lat, lng], 16); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(editMiniMap); editMiniMapMarker = L.marker([lat, lng], { draggable: true }).addTo(editMiniMap); editMiniMap.on('click', (e) => { const { lat, lng } = e.latlng; editMiniMapMarker.setLatLng([lat, lng]); document.getElementById('edit-modal-lat').value = lat.toFixed(6); document.getElementById('edit-modal-lng').value = lng.toFixed(6); }); } /** * Xử lý khi thay đổi Dropdown Privacy trong Modal sửa */ window.handleEditPrivacyChange = function() { const privacy = document.getElementById('edit-modal-privacy').value; const settingsBtn = document.getElementById('btn-edit-privacy-settings'); const isChild = document.getElementById('edit-modal-privacy').disabled; if (!isChild && (privacy === 'member' || privacy === 'shared')) { settingsBtn.style.display = 'block'; } else { settingsBtn.style.display = 'none'; } }; /** * Mở modal cài đặt chi tiết dựa trên loại Privacy */ window.openPrivacySettingsModal = function() { const privacy = document.getElementById('edit-modal-privacy').value; if (privacy === 'member') { renderSharedList(); document.getElementById('share-member-modal').style.display = 'flex'; } else if (privacy === 'shared' || privacy === 'public') { showShareLink(currentEditingScene); } }; /** * Hiển thị modal lấy link chia sẻ (dùng cho cả khách và chủ sở hữu) * @param {Object} scene - Đối tượng Scene cần lấy link */ window.showShareLink = function(scene) { if (!scene) return; // Lưu lại scene đang tương tác để các logic phụ trợ hoạt động đồng bộ currentEditingScene = scene; // Trỏ link vào endpoint /api/share/ để hỗ trợ Open Graph (ảnh thumbnail Facebook/Zalo) const baseUrl = window.location.origin + '/api/share/'; const token = scene.shareToken || ''; document.getElementById('shared-link-input').value = `${baseUrl}${scene._id}${token ? '?token=' + token : ''}`; document.getElementById('share-link-modal').style.display = 'flex'; }; /** * Tìm kiếm người dùng để chia sẻ */ window.searchUsersToShare = async function(query) { const dropdown = document.getElementById('search-results-dropdown'); if (!query || query.length < 2) { dropdown.style.display = 'none'; return; } const token = localStorage.getItem('jwt'); try { const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { headers: { 'Authorization': `Bearer ${token}` } }); const users = await res.json(); dropdown.innerHTML = ''; if (users.length > 0) { users.forEach(user => { const item = document.createElement('div'); item.className = 'search-item'; item.innerText = `${user.username} (${user.email})`; item.onclick = () => addMemberToShare(user); dropdown.appendChild(item); }); dropdown.style.display = 'block'; } else { // Nếu không tìm thấy user, cho phép thêm email thủ công if (query.includes('@')) { dropdown.innerHTML = `
Thêm email: ${query}
`; dropdown.style.display = 'block'; } else { dropdown.style.display = 'none'; } } } catch (e) { console.error(e); } }; function addMemberToShare(user) { if (!sharedUsersData.some(u => (u._id || u) === user._id)) { sharedUsersData.push(user); renderSharedList(); } document.getElementById('share-user-search').value = ''; document.getElementById('search-results-dropdown').style.display = 'none'; } window.addEmailToShare = function(email) { if (!sharedEmailsData.includes(email)) { sharedEmailsData.push(email); renderSharedList(); } document.getElementById('share-user-search').value = ''; document.getElementById('search-results-dropdown').style.display = 'none'; }; function renderSharedList() { const list = document.getElementById('current-shared-list'); list.innerHTML = ''; sharedUsersData.forEach(user => { const name = user.username || 'User'; list.innerHTML += `
👤 ${name} ×
`; }); sharedEmailsData.forEach(email => { list.innerHTML += `
📧 ${email} ×
`; }); } window.removeShared = function(type, id) { if (type === 'user') sharedUsersData = sharedUsersData.filter(u => (u._id || u) !== id); else sharedEmailsData = sharedEmailsData.filter(e => e !== id); renderSharedList(); }; window.closeShareMemberModal = () => document.getElementById('share-member-modal').style.display = 'none'; window.closeShareLinkModal = () => document.getElementById('share-link-modal').style.display = 'none'; /** * Copy link chia sẻ và đóng modal */ window.copySharedLink = function() { const linkInput = document.getElementById('shared-link-input'); linkInput.select(); navigator.clipboard.writeText(linkInput.value).then(() => { showSuccessModal("Đã sao chép liên kết vào bộ nhớ!"); closeShareLinkModal(); }); }; async function submitEditScene(e) { e.preventDefault(); const id = document.getElementById('edit-modal-scene-id').value; const token = localStorage.getItem('jwt'); // Sử dụng FormData vì API Backend hiện tại đang dùng Multer const formData = new FormData(); formData.append('title', document.getElementById('edit-modal-title').value); formData.append('description', document.getElementById('edit-modal-description').value); formData.append('lat', document.getElementById('edit-modal-lat').value); formData.append('lng', document.getElementById('edit-modal-lng').value); formData.append('privacy', document.getElementById('edit-modal-privacy').value); formData.append('shareExpireDays', document.getElementById('share-link-expire').value); // Đính kèm dữ liệu chia sẻ nâng cao formData.append('sharedWithUsers', JSON.stringify(sharedUsersData.map(u => u._id || u))); formData.append('sharedEmails', JSON.stringify(sharedEmailsData)); try { const res = await fetch(`${API_BASE_URL}/scenes/${id}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); const data = await res.json(); if (!res.ok) throw new Error(data.message); showNotification("Đã cập nhật thông tin cảnh thành công!", 'success'); closeEditMetadataModal(); loadScenes(); } catch (err) { showNotification("Lỗi cập nhật: " + err.message, 'error'); } } /** * Opens a specific tab within the dashboard. * @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes'). */ function openDashboardTab(tabName) { // Ẩn tất cả các tab pane document.querySelectorAll('.dashboard-tab-pane').forEach(pane => { pane.classList.remove('active'); }); // Bỏ active khỏi tất cả các nút tab document.querySelectorAll('.dashboard-tabs .tab-btn').forEach(btn => { btn.classList.remove('active'); }); // Hiển thị tab pane được chọn const selectedPane = document.getElementById(`tab-${tabName}`); if (selectedPane) { selectedPane.classList.add('active'); // Nếu là tab profile, cập nhật nội dung if (tabName === 'profile') { updateProfileTabContent(); } if (tabName === 'my-scenes') { loadMyScenes(); } if (tabName === 'media-library') { loadMyAssets(); } if (tabName === 'user-management') { loadAdminUsers(); } } // Đánh dấu nút tab được chọn là active const selectedTabButton = document.querySelector(`.dashboard-tabs .tab-btn[onclick="openDashboardTab('${tabName}')"]`); if (selectedTabButton) { selectedTabButton.classList.add('active'); } // Cập nhật avatar initials khi mở dashboard const username = localStorage.getItem('username'); if (username) { document.getElementById('avatar-initials').innerText = username.charAt(0).toUpperCase(); } }