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