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í người dùng", 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; } /** * Hàm định dạng dung lượng file cho Frontend */ function formatBytes(bytes, decimals = 2) { if (!bytes || bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } /** * Tải và hiển thị thống kê các tệp tin lớn nhất */ async function loadMediaStats() { const token = localStorage.getItem('jwt'); const statsContainer = document.getElementById('media-library-stats'); if (!statsContainer) return; try { const res = await fetch(`${API_BASE_URL}/me/assets/top-large`, { headers: { 'Authorization': `Bearer ${token}` } }); const topFiles = await res.json(); if (topFiles && topFiles.length > 0) { let html = `

Tệp tin chiếm dụng lớn nhất

`; topFiles.forEach(file => { const fileName = file.scene?.name || file.scene?.title || 'Ảnh chưa gắn Scene'; html += `
● ${fileName} ${formatBytes(file.fileSize)}
`; }); html += `
`; statsContainer.innerHTML = html; } else { statsContainer.innerHTML = ''; } } catch (e) { console.warn("Không thể nạp thống kê media:", e); } } /** * Cập nhật nội dung tab Hồ sơ với thông tin người dùng */ async function updateProfileTabContent() { const token = localStorage.getItem('jwt'); if (!token) return; // Các phần tử hiển thị chung const topAvatar = document.getElementById('avatar-initials'); const sidebarAvatar = document.getElementById('sidebar-avatar'); const userDisplay = document.getElementById('profile-username-display'); const statusDisplay = document.getElementById('profile-status-display'); const sidebarUser = document.getElementById('sidebar-username'); const sidebarStatus = document.getElementById('sidebar-status'); // Các phần tử trong Form const fullNameInput = document.getElementById('profile-fullname'); const emailInput = document.getElementById('profile-email'); const userInput = document.getElementById('profile-username'); const avatarPreview = document.getElementById('profile-avatar-preview'); const avatarPlaceholder = document.getElementById('profile-avatar-placeholder'); try { const res = await fetch(`${API_BASE_URL}/me/profile`, { headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (data && res.ok) { // 1. Cập nhật thông tin text an toàn if (fullNameInput) fullNameInput.value = data.fullName || ''; if (emailInput) emailInput.value = data.email || ''; if (userInput) userInput.value = data.username || ''; if (userDisplay) userDisplay.innerText = data.username || 'N/A'; if (statusDisplay) statusDisplay.innerText = data.role || 'Thành viên'; if (sidebarUser) sidebarUser.innerText = data.username || 'N/A'; if (sidebarStatus) sidebarStatus.innerText = data.role || 'Thành viên'; // Cập nhật lại localStorage để đồng bộ trạng thái if (data.username) localStorage.setItem('username', data.username); if (data.role) localStorage.setItem('role', data.role); // 2. Xử lý Ảnh đại diện (Avatar) if (data.avatarUrl) { const fullAvatarUrl = data.avatarUrl; if (avatarPreview) { avatarPreview.src = fullAvatarUrl; avatarPreview.style.display = 'block'; } if (avatarPlaceholder) avatarPlaceholder.style.display = 'none'; // Cập nhật ảnh đại diện ở sidebar nếu có if (sidebarAvatar) { sidebarAvatar.innerHTML = ``; } } else { // Fallback về chữ cái đầu nếu không có ảnh const initial = (data.username || "?").charAt(0).toUpperCase(); if (avatarPreview) avatarPreview.style.display = 'none'; if (avatarPlaceholder) { avatarPlaceholder.style.display = 'flex'; avatarPlaceholder.innerText = initial; } if (topAvatar) topAvatar.innerText = initial; if (sidebarAvatar) sidebarAvatar.innerText = initial; } // 3. Xử lý thông tin dung lượng if (data.storage) { const { used, quota } = data.storage; const progress = document.getElementById('storage-progress-bar'); const text = document.getElementById('storage-text'); if (progress && text) { const usedMB = (used / (1024 * 1024)).toFixed(1); const quotaMB = quota === -1 ? '∞' : (quota / (1024 * 1024)).toFixed(0); text.innerText = `${usedMB} MB / ${quotaMB} MB`; if (quota !== -1 && quota > 0) { const percent = Math.min((used / quota) * 100, 100); progress.style.width = percent + '%'; if (percent > 90) progress.style.background = '#dc3545'; else if (percent > 75) progress.style.background = '#ffc107'; else progress.style.background = '#28a745'; } else { progress.style.width = '100%'; progress.style.background = '#007bff'; } } } } } catch (e) { console.warn("Không thể tải thông tin dung lượng:", e); } } /** * Xem trước ảnh đại diện khi chọn file */ window.previewAvatar = function(input) { if (input.files && input.files[0]) { const reader = new FileReader(); reader.onload = function(e) { const preview = document.getElementById('profile-avatar-preview'); const placeholder = document.getElementById('profile-avatar-placeholder'); preview.src = e.target.result; preview.style.display = 'block'; placeholder.style.display = 'none'; }; reader.readAsDataURL(input.files[0]); } }; /** * Cập nhật hồ sơ người dùng */ async function updateProfile(e) { e.preventDefault(); const token = localStorage.getItem('jwt'); const form = document.getElementById('profile-form'); const formData = new FormData(form); // Xóa các trường không cần thiết khi cập nhật hồ sơ cá nhân formData.delete('agreedToRules'); // Không cần khi cập nhật formData.delete('role'); // Role chỉ được thay đổi bởi Admin try { const res = await fetch(`${API_BASE_URL}/me/profile`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); const data = await res.json(); if (!res.ok) throw new Error(data.message); showNotification("Hồ sơ đã được cập nhật thành công!", 'success'); // Cập nhật lại localStorage nếu username thay đổi if (data.user && data.user.username) { localStorage.setItem('username', data.user.username); } updateProfileTabContent(); // Tải lại thông tin mới } catch (err) { showNotification("Lỗi cập nhật: " + err.message, 'error'); } } /** * Hàm bổ trợ định dạng ngày tháng theo múi giờ hệ thống */ function formatSystemDate(dateString) { if (!dateString) return 'N/A'; const date = new Date(dateString); try { return new Intl.DateTimeFormat(systemSettings.language === 'vi' ? 'vi-VN' : 'en-US', { timeZone: systemSettings.timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date); } catch (e) { // Fallback nếu timezone không hợp lệ return date.toLocaleDateString(); } } /** * 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'); const shareBtn = document.getElementById('btn-share-action'); title.innerText = `Scene: ${scene.title}`; modal.style.display = 'flex'; // Hành động Lấy link chia sẻ trực tiếp shareBtn.onclick = () => { closeActionModal(); showShareLink(scene); }; // Hành động Chỉnh sửa privacy editPrivacyBtn.onclick = () => { returnToDashboardAfterEdit = false; closeActionModal(); // 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'; } /** * 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}')`; // Logic hiển thị badge trạng thái let statusBadge = ''; if (scene.status === 'processing') { statusBadge = '⏳ Đang xử lý 8K...'; } else if (scene.status === 'failed') { statusBadge = '❌ Lỗi xử lý'; } card.innerHTML = `
${scene.name || scene.title}

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

🔒 ${scene.privacy} 👤 ${scene.createdBy?.username || 'Bạn'} 📅 ${formatSystemDate(scene.createdAt)}
${statusBadge}
`; 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(page = 1) { const token = localStorage.getItem('jwt'); const container = document.getElementById('admin-users-list'); const paginationContainer = document.getElementById('admin-users-pagination'); if (!container) return; container.innerHTML = '

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

'; if (paginationContainer) paginationContainer.innerHTML = ''; try { const res = await fetch(`${API_BASE_URL}/admin/users?page=${page}&limit=10`, { headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); const { users, totalPages, currentPage, totalUsers } = data; let html = ` `; users.forEach(user => { const isRootAdmin = user.role === 'admin'; html += ` `; }); html += '
Họ tên Username Email Quyền hạn Reset Password Thao tác
${user.username} ${isRootAdmin ? '' : ``}
'; container.innerHTML = html; // Render Pagination UI if (paginationContainer && totalPages > 1) { paginationContainer.innerHTML = ` Trang ${currentPage} / ${totalPages} (${totalUsers} người dùng) `; } } 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); // Hiển thị thông báo thành công kèm báo cáo dọn dẹp nếu có let successMsg = data.message; if (data.report) { successMsg += `\n(Đã dọn dẹp: ${data.report.scenesDeleted} Scenes, ${data.report.filesRemoved} Files)`; } showNotification(successMsg, 'success'); loadAdminUsers(); } catch (e) { showNotification(e.message, 'error'); } }; /** * Giai đoạn 2 & 3: Các hàm xử lý bảo trì cho Admin tối cao */ window.openManualCleanupConfirm = function() { const modal = document.getElementById('maintenance-confirm-modal'); document.getElementById('maintenance-confirm-title').innerText = "Dọn dẹp hệ thống"; document.getElementById('maintenance-confirm-desc').innerText = "Hệ thống sẽ quét và xóa bỏ tất cả các Scene, Hotspot và Asset không còn liên kết hợp lệ. Thao tác này không thể hoàn tác."; document.getElementById('maintenance-action-type').value = 'cleanup'; document.getElementById('maintenance-confirm-btn').onclick = runManualCleanup; modal.style.display = 'flex'; }; window.closeMaintenanceConfirm = () => { document.getElementById('maintenance-confirm-modal').style.display = 'none'; }; async function runManualCleanup() { const token = localStorage.getItem('jwt'); closeMaintenanceConfirm(); try { const res = await fetch(`${API_BASE_URL}/admin/maintenance/cleanup`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); const report = data.report; showSuccessModal(`Dọn dẹp hoàn tất!\n- Scenes xóa: ${report.scenesDeleted}\n- Files xóa: ${report.filesRemoved}`); } catch (e) { showNotification(e.message, 'error'); } } window.checkStrayFiles = async function() { const token = localStorage.getItem('jwt'); try { const res = await fetch(`${API_BASE_URL}/admin/maintenance/stray-files`, { headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); if (data.count === 0) { showSuccessModal("Tuyệt vời! Không tìm thấy tệp tin rác nào trong hệ thống.", '✨'); } else { const modal = document.getElementById('maintenance-confirm-modal'); document.getElementById('maintenance-confirm-title').innerText = "Phát hiện tệp tin rác"; document.getElementById('maintenance-confirm-desc').innerText = `Tìm thấy ${data.count} file trong thư mục uploads không có bản ghi trong Database. Bạn có muốn xóa chúng để tiết kiệm dung lượng?`; document.getElementById('maintenance-action-type').value = 'stray'; document.getElementById('maintenance-confirm-btn').onclick = deleteStrayFiles; modal.style.display = 'flex'; } } catch (e) { showNotification(e.message, 'error'); } }; async function deleteStrayFiles() { const token = localStorage.getItem('jwt'); closeMaintenanceConfirm(); try { const res = await fetch(`${API_BASE_URL}/admin/maintenance/cleanup?deleteStray=true`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); showSuccessModal(`Đã dọn dẹp sạch sẽ ${data.report.filesRemoved} tệp tin rác khỏi máy chủ.`); loadMediaStats(); // Cập nhật lại dung lượng hiển thị } catch (e) { showNotification(e.message, 'error'); } } /** * Tải và hiển thị kho ảnh/media của người dùng */ async function loadMyAssets() { const token = localStorage.getItem('jwt'); const gridContainer = document.getElementById('media-library-list'); const currentUserId = localStorage.getItem('userId'); const userRole = localStorage.getItem('role'); gridContainer.innerHTML = '

Đ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' : ''}`; let statusBadge = ''; if (scene?.status === 'processing') { statusBadge = '⏳ Đang nén 8K...'; } else if (scene?.status === 'failed') { statusBadge = '❌ Lỗi'; } // 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 ? `` : ''} ${statusBadge} 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'; if (scene.status === 'processing') editButton.disabled = true; dashboardReturnTab = 'media-library'; const isChild = asset.parentScenes && asset.parentScenes.length > 0; editButton.addEventListener('click', () => openEditFromMedia(scene, isChild)); card.querySelector('.media-actions').appendChild(editButton); } // Add delete button and its event listener if (asset.uploadedBy === currentUserId || (isTrash && (userRole === 'admin' || userRole === 'Chủ sở hữu'))) { const deleteButton = document.createElement('button'); deleteButton.className = 'delete-btn-small'; deleteButton.innerText = 'Xóa'; deleteButton.addEventListener('click', () => deleteAsset(asset._id)); card.querySelector('.media-actions').appendChild(deleteButton); } gridContainer.appendChild(card); }); } catch (e) { gridContainer.innerHTML = `

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' || privacy === 'public')) { settingsBtn.style.display = 'block'; } else { settingsBtn.style.display = 'none'; } }; /** * Mở modal cài đặt chi tiết dựa trên loại Privacy */ window.openPrivacySettingsModal = function() { const privacy = document.getElementById('edit-modal-privacy').value; if (privacy === 'member') { renderSharedList(); document.getElementById('share-member-modal').style.display = 'flex'; } else if (privacy === 'shared' || privacy === 'public') { showShareLink(currentEditingScene); } }; /** * Hiển thị modal lấy link chia sẻ (dùng cho cả khách và chủ sở hữu) * @param {Object} scene - Đối tượng Scene cần lấy link */ window.showShareLink = function(scene) { if (!scene) return; // Lưu lại scene đang tương tác để các logic phụ trợ hoạt động đồng bộ currentEditingScene = scene; // Trỏ link vào endpoint /api/share/ để hỗ trợ Open Graph (ảnh thumbnail Facebook/Zalo) const baseUrl = window.location.origin + '/api/share/'; const token = scene.shareToken || ''; document.getElementById('shared-link-input').value = `${baseUrl}${scene._id}${token ? '?token=' + token : ''}`; document.getElementById('share-link-modal').style.display = 'flex'; }; /** * Tìm kiếm người dùng để chia sẻ */ window.searchUsersToShare = async function(query) { const dropdown = document.getElementById('search-results-dropdown'); if (!query || query.length < 2) { dropdown.style.display = 'none'; return; } const token = localStorage.getItem('jwt'); try { const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { headers: { 'Authorization': `Bearer ${token}` } }); const users = await res.json(); dropdown.innerHTML = ''; if (users.length > 0) { users.forEach(user => { const item = document.createElement('div'); item.className = 'search-item'; item.innerText = `${user.username} (${user.email})`; item.onclick = () => addMemberToShare(user); dropdown.appendChild(item); }); dropdown.style.display = 'block'; } else { // Nếu không tìm thấy user, cho phép thêm email thủ công if (query.includes('@')) { dropdown.innerHTML = `
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'); } } /** * Xử lý tải xuống bản sao lưu */ async function handleBackup() { const token = localStorage.getItem('jwt'); try { showNotification("Đang chuẩn bị bản sao lưu...", "success"); const response = await fetch(`${API_BASE_URL}/admin/backup`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error("Lỗi khi tạo backup"); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `backup_3dtour_${new Date().toISOString().slice(0,10)}.zip`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); showNotification("Đã tải xuống bản sao lưu!", "success"); } catch (e) { showNotification(e.message, "error"); } } /** * Xử lý khôi phục dữ liệu từ file zip */ async function handleRestore(input) { if (!input.files || !input.files[0]) return; if (!confirm("CẢNH BÁO: Khôi phục dữ liệu sẽ xóa sạch dữ liệu hiện tại và thay thế bằng dữ liệu từ bản sao lưu. Bạn có chắc chắn?")) { input.value = ''; return; } const token = localStorage.getItem('jwt'); const formData = new FormData(); formData.append('backupFile', input.files[0]); try { showNotification("Đang khôi phục... Vui lòng không đóng trình duyệt.", "success"); const response = await fetch(`${API_BASE_URL}/admin/restore`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); const data = await response.json(); if (!response.ok) throw new Error(data.message); showNotification("Khôi phục thành công! Hệ thống sẽ tải lại.", "success"); setTimeout(() => location.reload(), 2000); } catch (e) { showNotification(e.message, "error"); input.value = ''; } } /** * Cập nhật cấu hình hệ thống */ async function updateSystemSettings(e) { e.preventDefault(); const token = localStorage.getItem('jwt'); const timezone = document.getElementById('sys-timezone').value; const language = document.getElementById('sys-language').value; try { const res = await fetch(`${API_BASE_URL}/system/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ timezone, language }) }); if (res.ok) { showNotification("Cấu hình hệ thống đã được lưu!", "success"); // Tải lại cài đặt để áp dụng ngôn ngữ mới ngay lập tức fetchSystemSettings(); } } catch (err) { showNotification("Lỗi lưu cấu hình: " + err.message, "error"); } } /** * 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') { loadMediaStats(); 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(); } }