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(); // Sử dụng thuộc tính isChildScene từ backend để quyết định quyền chỉnh sửa openEditMetadataModal(scene, scene.isChildScene); }; // Gán sự kiện cho nút Sửa editBtn.onclick = () => { returnToDashboardAfterEdit = false; closeActionModal(); openEditMetadataModal(scene, scene.isChildScene); }; // 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 = async function(sceneId, sceneData = null) { // Thêm sceneData để tránh fetch lại sceneIdToDelete = sceneId; const confirmModal = document.getElementById('delete-scene-confirm-modal'); const confirmMessageElem = document.getElementById('delete-scene-confirm-message'); // Giả định có element này trong HTML if (!confirmModal || !confirmMessageElem) { console.error("Delete confirmation modal elements not found."); return; } const token = localStorage.getItem('jwt'); if (!token) { showNotification('Vui lòng đăng nhập để thực hiện thao tác này.', 'warning'); return; } let sceneToConfirm = sceneData; if (!sceneToConfirm) { try { // Fetch scene details nếu chưa có sẵn (ví dụ: xóa từ bản đồ) const response = await fetch(`${API_BASE_URL}/scenes/${sceneId}`, { headers: { 'Authorization': `Bearer ${token}` } }); sceneToConfirm = await response.json(); if (!response.ok) throw new Error(sceneToConfirm.message || 'Failed to fetch scene details for deletion.'); } catch (error) { showNotification("Không thể chuẩn bị xóa scene: " + error.message, 'error'); return; } } let message = ''; if (sceneToConfirm.isChildScene) { message = `Bạn đang xóa cảnh "${sceneToConfirm.name || sceneToConfirm.title}". Cảnh này là một phần của tour khác. Việc xóa sẽ chỉ gỡ bỏ cảnh này và các liên kết đến nó. Các cảnh cha sẽ không bị ảnh hưởng. Bạn có chắc chắn muốn xóa?`; } else { message = `Bạn đang xóa cảnh "${sceneToConfirm.name || sceneToConfirm.title}". Cảnh này có thể là cảnh gốc hoặc cảnh không có liên kết đến. Việc xóa sẽ gỡ bỏ cảnh này VÀ TẤT CẢ CÁC CẢNH CON liên kết với nó trong tour. Thao tác này không thể hoàn tác. Bạn có chắc chắn muốn xóa?`; } confirmMessageElem.innerText = message; confirmModal.style.display = 'flex'; }; window.closeDeleteSceneModal = function() { document.getElementById('delete-scene-confirm-modal').style.display = 'none'; sceneIdToDelete = null; if (returnToDashboardAfterEdit) { const targetTab = dashboardReturnTab; returnToDashboardAfterEdit = false; openDashboard(); openDashboardTab(targetTab); } }; window.confirmDeleteScene = async function() { if (!sceneIdToDelete) return; const token = localStorage.getItem('jwt'); try { const response = await fetch(`${API_BASE_URL}/scenes/${sceneIdToDelete}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to delete scene'); closeDeleteSceneModal(); showSuccessModal(data.message || 'Scene đã được xóa vĩnh viễn'); loadScenes(); if (document.getElementById('tab-my-scenes').classList.contains('active')) { 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)} 👁️ ${scene.views || 0} lượt xem
${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(); openEditMetadataModal(scene, scene.isChildScene); }; // Xử lý nút Xóa document.getElementById(`delete-scene-${scene._id}`).onclick = async () => { await deleteScene(scene._id, scene); // Truyền đối tượng scene đầy đủ }; // Xử lý nút Thống kê document.getElementById(`view-stats-${scene._id}`).onclick = () => { showViewStatsModal(scene._id, scene.name || scene.title); }; }); } 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, isChildArg = null) { // Ưu tiên isChildScene từ object scene, hoặc giá trị truyền vào thủ công const isChild = isChildArg !== null ? isChildArg : (!!scene.isChildScene); 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"); } } let viewStatsChartInstance = null; // Biến để lưu instance của Chart.js /** * Mở modal hiển thị biểu đồ thống kê lượt xem */ async function showViewStatsModal(sceneId, sceneTitle) { const token = localStorage.getItem('jwt'); const modal = document.getElementById('view-stats-modal'); const titleElem = document.getElementById('view-stats-modal-title'); const chartCanvas = document.getElementById('view-stats-chart'); if (titleElem) titleElem.innerText = `Thống kê lượt xem: ${sceneTitle}`; modal.style.display = 'flex'; try { const res = await fetch(`${API_BASE_URL}/me/scenes/${sceneId}/view-stats`, { headers: { 'Authorization': `Bearer ${token}` } }); const viewHistory = await res.json(); if (!res.ok) throw new Error(viewHistory.message); // Chuẩn bị dữ liệu cho biểu đồ const labels = []; const data = []; const today = new Date(); today.setHours(0, 0, 0, 0); // Lấy dữ liệu 30 ngày gần nhất for (let i = 29; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); labels.push(d.toLocaleDateString(systemSettings.language === 'vi' ? 'vi-VN' : 'en-US', { day: '2-digit', month: '2-digit' })); const entry = viewHistory.find(vh => new Date(vh.date).setHours(0,0,0,0) === d.getTime()); data.push(entry ? entry.count : 0); } // Nếu có instance cũ, hủy nó đi trước khi tạo mới if (viewStatsChartInstance) { viewStatsChartInstance.destroy(); } viewStatsChartInstance = new Chart(chartCanvas, { type: 'line', data: { labels: labels, datasets: [{ label: 'Lượt xem', data: data, borderColor: '#007bff', backgroundColor: 'rgba(0, 123, 255, 0.2)', fill: true, tension: 0.3 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }); } catch (e) { showNotification("Không thể tải thống kê lượt xem: " + e.message, 'error'); closeViewStatsModal(); } } /** * Đóng modal thống kê lượt xem */ window.closeViewStatsModal = function() { document.getElementById('view-stats-modal').style.display = 'none'; if (viewStatsChartInstance) { viewStatsChartInstance.destroy(); // Hủy biểu đồ để giải phóng bộ nhớ viewStatsChartInstance = null; } }; /** * 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(); } }