20260607 - login, add scene, add hotspot
This commit is contained in:
@@ -169,3 +169,47 @@ html, body {
|
||||
#close-viewer-btn:hover {
|
||||
background: white;
|
||||
}
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 90%; max-width: 500px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; font-weight: bold; margin-bottom: 5px; }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tab-container { display: flex; gap: 10px; margin-bottom: 10px; }
|
||||
.tab-btn {
|
||||
flex: 1; padding: 8px; cursor: pointer; border: 1px solid #007bff;
|
||||
background: #fff; color: #007bff; border-radius: 4px;
|
||||
}
|
||||
.tab-btn.active { background: #007bff; color: #fff; }
|
||||
|
||||
.divider { border-top: 1px dashed #ccc; padding-top: 15px; }
|
||||
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-btn { background: #28a745; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
|
||||
.cancel-btn { background: #6c757d; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
|
||||
|
||||
+82
-1
@@ -39,6 +39,8 @@
|
||||
<span class="close-btn" onclick="closeModal()">×</span>
|
||||
<h2>Create New 3D Scene</h2>
|
||||
<form id="create-scene-form" onsubmit="submitScene(event)">
|
||||
<!-- Hidden field for editing existing scene -->
|
||||
<input type="hidden" id="modal-scene-id" name="sceneId">
|
||||
<div class="form-group">
|
||||
<label>Selected Coordinates:</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
@@ -73,10 +75,89 @@
|
||||
</div>
|
||||
|
||||
<!-- 3D Panorama Viewer Container -->
|
||||
<div id="viewer-container" style="display: none;">
|
||||
<div id="viewer-container" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 2000; background: #000;">
|
||||
<div id="panorama-viewer"></div>
|
||||
<button id="close-viewer-btn" onclick="closeViewer()">Close 3D View</button>
|
||||
</div>
|
||||
<!-- Hotspot Editor Modal -->
|
||||
<div id="hotspot-modal" class="modal-overlay" style="display: none; z-index: 3000;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="hotspot-modal-title">Biên tập điểm điều hướng</h3>
|
||||
<span class="close-btn" onclick="closeHotspotModal()">×</span>
|
||||
</div>
|
||||
|
||||
<form id="hotspot-form">
|
||||
<!-- Tọa độ ẩn để xử lý GPS và vị trí -->
|
||||
<input type="hidden" id="hs-pitch">
|
||||
<input type="hidden" id="hs-yaw">
|
||||
<input type="hidden" id="hs-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hs-title">Tiêu đề (Label)</label>
|
||||
<input type="text" id="hs-title" name="title" placeholder="Ví dụ: Cổng vào, Phòng khách..." required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hs-desc">Mô tả chi tiết</label>
|
||||
<textarea id="hs-desc" name="description" rows="3" placeholder="Thông tin thêm về vị trí này..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group divider">
|
||||
<label>Liên kết tới ảnh 3D khác:</label>
|
||||
<div class="tab-container">
|
||||
<button type="button" class="tab-btn active" onclick="switchHSTab('select')">Chọn ảnh có sẵn</button>
|
||||
<button type="button" class="tab-btn" onclick="switchHSTab('upload')">Tải ảnh mới lên</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Chọn ảnh có sẵn -->
|
||||
<div id="hs-tab-select" class="tab-content">
|
||||
<label for="hs-target-id">Danh sách Scene của bạn:</label>
|
||||
<select id="hs-target-id" name="targetSceneId">
|
||||
<option value="">-- Chọn một cảnh để liên kết --</option>
|
||||
<!-- Sẽ được fill bằng JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Tải ảnh mới -->
|
||||
<div id="hs-tab-upload" class="tab-content" style="display: none;">
|
||||
<label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label>
|
||||
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
|
||||
|
||||
<div class="gps-inheritance">
|
||||
<label>Xử lý GPS cho ảnh mới:</label>
|
||||
<select id="hs-gps-mode" onchange="toggleManualGPS()">
|
||||
<option value="auto">Đọc từ EXIF ảnh (Mặc định)</option>
|
||||
<option value="inherit">Lấy GPS của cảnh hiện tại</option>
|
||||
<option value="manual">Nhập thủ công</option>
|
||||
</select>
|
||||
|
||||
<div id="hs-manual-gps" style="display: none; margin-top: 10px;">
|
||||
<input type="number" step="any" id="hs-lat" placeholder="Latitude">
|
||||
<input type="number" step="any" id="hs-lng" placeholder="Longitude">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar for Hotspot Upload -->
|
||||
<div id="hs-progress-container" style="display: none; margin-bottom: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 5px;">
|
||||
<span id="hs-progress-status">Uploading image...</span>
|
||||
<span id="hs-progress-percent">0%</span>
|
||||
</div>
|
||||
<div style="width: 100%; height: 8px; background: #eee; border-radius: 4px; overflow: hidden;">
|
||||
<div id="hs-progress-bar" style="width: 0%; height: 100%; background: #007bff; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" onclick="closeHotspotModal()">Hủy</button>
|
||||
<button type="submit" class="save-btn">Lưu điểm điều hướng</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
|
||||
+362
-26
@@ -2,28 +2,55 @@ const API_BASE_URL = 'http://localhost:5000/api';
|
||||
|
||||
let map;
|
||||
let tempMarker = null;
|
||||
let currentSceneId = null;
|
||||
let previousSceneId = null;
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMap();
|
||||
checkAuthStatus();
|
||||
loadScenes();
|
||||
restoreActiveScene();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the full-screen Leaflet Map
|
||||
*/
|
||||
function initMap() {
|
||||
// Center of map defaults to Hanoi
|
||||
map = L.map('map').setView([21.0285, 105.8542], 13);
|
||||
// Đọ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');
|
||||
|
||||
// Nếu có dữ liệu cũ thì dùng, không thì mặc định là Hà Nội
|
||||
const startLat = savedLat ? parseFloat(savedLat) : 21.0285;
|
||||
const startLng = savedLng ? parseFloat(savedLng) : 105.8542;
|
||||
const startZoom = savedZoom ? parseInt(savedZoom) : 13;
|
||||
|
||||
map = L.map('map').setView([startLat, startLng], startZoom);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Lưu vị trí bản đồ mỗi khi người dùng di chuyển hoặc zoom xong
|
||||
map.on('moveend', () => {
|
||||
const center = map.getCenter();
|
||||
localStorage.setItem('map-lat', center.lat);
|
||||
localStorage.setItem('map-lng', center.lng);
|
||||
localStorage.setItem('map-zoom', map.getZoom());
|
||||
});
|
||||
|
||||
// Event listener for right-click on map to open modal
|
||||
map.on('contextmenu', (e) => {
|
||||
// Nếu viewer 3D đang hiển thị, không thực hiện tạo scene trên bản đồ
|
||||
const viewerContainer = document.getElementById('viewer-container');
|
||||
// Kiểm tra z-index và display để chắc chắn viewer đang ẩn
|
||||
if (viewerContainer && viewerContainer.style.display !== 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
openCreateSceneModal(lat, lng);
|
||||
});
|
||||
@@ -76,6 +103,7 @@ async function handleLogin() {
|
||||
localStorage.setItem('jwt', data.token);
|
||||
localStorage.setItem('username', data.user.username);
|
||||
localStorage.setItem('role', data.user.role);
|
||||
localStorage.setItem('userId', data.user.id);
|
||||
|
||||
checkAuthStatus();
|
||||
loadScenes(); // Reload scenes to show member/private scenes
|
||||
@@ -120,6 +148,10 @@ function handleLogout() {
|
||||
localStorage.removeItem('jwt');
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('activeSceneId');
|
||||
localStorage.removeItem('activeScenePrivacy');
|
||||
localStorage.removeItem('activeSceneToken');
|
||||
localStorage.removeItem('userId');
|
||||
checkAuthStatus();
|
||||
loadScenes(); // Reload scenes to filter out private ones
|
||||
alert('Logged out successfully');
|
||||
@@ -139,6 +171,7 @@ function openCreateSceneModal(lat, lng) {
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||
|
||||
document.getElementById('modal-scene-id').value = '';
|
||||
document.getElementById('modal-lat').value = lat.toFixed(6);
|
||||
document.getElementById('modal-lng').value = lng.toFixed(6);
|
||||
document.getElementById('create-scene-modal').style.display = 'flex';
|
||||
@@ -175,34 +208,61 @@ function toggleSharedUsers() {
|
||||
*/
|
||||
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');
|
||||
if (!token) {
|
||||
alert('Please log in to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/scenes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
const url = sceneId ? `${API_BASE_URL}/scenes/${sceneId}` : `${API_BASE_URL}/scenes`;
|
||||
const method = sceneId ? 'PUT' : 'POST';
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Failed to save scene');
|
||||
|
||||
alert('Scene created successfully!');
|
||||
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();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
|
||||
container.style.display = 'block';
|
||||
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);
|
||||
bar.style.width = percent + '%';
|
||||
percentText.innerText = percent + '%';
|
||||
if (percent === 100) statusText.innerText = "Tải lên xong! Đang khởi tạo trên server...";
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
container.style.display = 'none';
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
callback(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
const err = JSON.parse(xhr.responseText);
|
||||
alert('Lỗi: ' + (err.message || 'Không thể tải lên'));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +284,9 @@ async function loadScenes() {
|
||||
if (!response.ok) throw new Error('Failed to load scenes');
|
||||
const scenes = await response.json();
|
||||
|
||||
const activeSceneId = localStorage.getItem('activeSceneId');
|
||||
const currentUserId = localStorage.getItem('userId');
|
||||
|
||||
// Clear existing markers (excluding tempMarker)
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker && layer !== tempMarker) {
|
||||
@@ -245,6 +308,19 @@ async function loadScenes() {
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
// Nếu đây là scene đang xem, tự động mở popup
|
||||
if (activeSceneId && scene._id === activeSceneId) {
|
||||
marker.openPopup();
|
||||
}
|
||||
|
||||
// Right-click on marker to Edit/Delete (Owner only)
|
||||
marker.on('contextmenu', (e) => {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
if (currentUserId && scene.owner && scene.owner._id === currentUserId) {
|
||||
handleEditDeleteScene(scene);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -252,10 +328,65 @@ async function loadScenes() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Edit/Delete options for a scene
|
||||
*/
|
||||
async function handleEditDeleteScene(scene) {
|
||||
const action = confirm(`Bạn muốn làm gì với scene "${scene.title}"?\n\n- Nhấn OK để CHỈNH SỬA\n- Nhấn Cancel để XÓA`);
|
||||
|
||||
if (action) {
|
||||
// EDIT MODE
|
||||
openEditSceneModal(scene);
|
||||
} else {
|
||||
// DELETE MODE
|
||||
if (confirm(`Bạn có chắc chắn muốn xóa vĩnh viễn scene "${scene.title}"?`)) {
|
||||
await deleteScene(scene._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the modal in Edit mode
|
||||
*/
|
||||
function openEditSceneModal(scene) {
|
||||
document.getElementById('modal-scene-id').value = scene._id;
|
||||
document.getElementById('modal-lat').value = scene.lat;
|
||||
document.getElementById('modal-lng').value = scene.lng;
|
||||
document.getElementById('modal-title').value = scene.title;
|
||||
document.getElementById('modal-privacy').value = scene.privacy;
|
||||
document.getElementById('modal-panorama').required = false; // Photo update is optional
|
||||
|
||||
toggleSharedUsers();
|
||||
document.getElementById('create-scene-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a scene via API
|
||||
*/
|
||||
async function deleteScene(sceneId) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/scenes/${sceneId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete scene');
|
||||
alert('Scene deleted successfully');
|
||||
loadScenes();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches secure scene details and triggers the Panorama viewer
|
||||
*/
|
||||
async function openScene(sceneId, privacy, shareToken) {
|
||||
async function openScene(sceneId, privacy, shareToken, force = false) {
|
||||
// Nếu đang xem chính scene này và không yêu cầu làm mới (force), không cần nạp lại
|
||||
if (!force && currentSceneId === sceneId && document.getElementById('viewer-container').style.display === 'block') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const headers = {};
|
||||
@@ -268,6 +399,11 @@ async function openScene(sceneId, privacy, shareToken) {
|
||||
url += `?token=${shareToken}`;
|
||||
}
|
||||
|
||||
// Lưu trạng thái Scene hiện tại để khôi phục sau khi reload trang
|
||||
localStorage.setItem('activeSceneId', sceneId);
|
||||
localStorage.setItem('activeScenePrivacy', privacy || '');
|
||||
localStorage.setItem('activeSceneToken', shareToken || '');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers
|
||||
@@ -276,16 +412,216 @@ async function openScene(sceneId, privacy, shareToken) {
|
||||
const scene = await response.json();
|
||||
if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
||||
|
||||
// Tự động focus bản đồ vào vị trí của Scene
|
||||
if (map) {
|
||||
map.flyTo([scene.lat, scene.lng], 16);
|
||||
}
|
||||
|
||||
// Cập nhật tọa độ vào các input ẩn để hỗ trợ GPS inheritance cho hotspot khi tải ảnh mới
|
||||
document.getElementById('modal-lat').value = scene.lat;
|
||||
document.getElementById('modal-lng').value = scene.lng;
|
||||
|
||||
// Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động
|
||||
if (currentSceneId && currentSceneId !== sceneId) {
|
||||
previousSceneId = currentSceneId;
|
||||
}
|
||||
currentSceneId = sceneId;
|
||||
|
||||
// Construct secure image URL passing shareToken if applicable
|
||||
let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
||||
if (privacy === 'shared' && scene.shareToken) {
|
||||
|
||||
// Ư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);
|
||||
initPanoramaViewer(secureImageUrl, scene.hotspots || []);
|
||||
|
||||
} catch (error) {
|
||||
localStorage.removeItem('activeSceneId');
|
||||
localStorage.removeItem('activeScenePrivacy');
|
||||
localStorage.removeItem('activeSceneToken');
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Khôi phục Scene đang xem từ localStorage sau khi reload trang
|
||||
*/
|
||||
function restoreActiveScene() {
|
||||
const savedSceneId = localStorage.getItem('activeSceneId');
|
||||
if (savedSceneId) {
|
||||
const savedPrivacy = localStorage.getItem('activeScenePrivacy');
|
||||
const savedToken = localStorage.getItem('activeSceneToken');
|
||||
openScene(savedSceneId, savedPrivacy, savedToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Xử lý việc tạo hotspot sau khi click chuột phải trong trình xem 360
|
||||
* @param {number} pitch - Tọa độ dọc (-90 đến 90)
|
||||
* @param {number} yaw - Tọa độ ngang (-180 đến 180)
|
||||
* @param {Object} existingHotspot - Thông tin hotspot cũ nếu có
|
||||
*/
|
||||
window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) {
|
||||
alert('Vui lòng đăng nhập để thực hiện thao tác này.');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('hotspot-modal');
|
||||
const form = document.getElementById('hotspot-form');
|
||||
|
||||
// Reset form và gán tọa độ
|
||||
form.reset();
|
||||
switchHSTab('select'); // Luôn mặc định về tab chọn ảnh có sẵn khi mở modal
|
||||
document.getElementById('hs-pitch').value = pitch;
|
||||
document.getElementById('hs-yaw').value = yaw;
|
||||
document.getElementById('hs-id').value = existingHotspot ? existingHotspot._id : '';
|
||||
document.getElementById('hotspot-modal-title').innerText = existingHotspot ? 'Cập nhật điểm điều hướng' : 'Thêm điểm điều hướng mới';
|
||||
|
||||
// Lấy danh sách Scene có sẵn để đổ vào dropdown
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const scenes = await res.json();
|
||||
const select = document.getElementById('hs-target-id');
|
||||
select.innerHTML = '<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.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.text || '';
|
||||
document.getElementById('hs-desc').value = existingHotspot.description || '';
|
||||
if (existingHotspot.targetSceneId) {
|
||||
select.value = existingHotspot.targetSceneId;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
|
||||
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Xử lý sự kiện submit form
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Logic: Nếu chọn upload file mới, tạo Scene trước
|
||||
let finalTargetId = formData.get('targetSceneId');
|
||||
const file = document.getElementById('hs-panorama-file').files[0];
|
||||
|
||||
if (file) {
|
||||
const sceneData = new FormData();
|
||||
sceneData.append('panorama', file);
|
||||
sceneData.append('title', formData.get('title'));
|
||||
const gpsMode = document.getElementById('hs-gps-mode').value;
|
||||
if (gpsMode === 'manual') {
|
||||
sceneData.append('lat', document.getElementById('hs-lat').value);
|
||||
sceneData.append('lng', document.getElementById('hs-lng').value);
|
||||
} else if (gpsMode === 'inherit') {
|
||||
sceneData.append('lat', document.getElementById('modal-lat').value);
|
||||
sceneData.append('lng', document.getElementById('modal-lng').value);
|
||||
}
|
||||
|
||||
uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => {
|
||||
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id);
|
||||
closeHotspotModal();
|
||||
});
|
||||
return; // Dừng luồng cũ vì uploadWithProgress đã tiếp quản
|
||||
}
|
||||
|
||||
// Lưu Hotspot
|
||||
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id);
|
||||
modal.style.display = 'none';
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Đóng Modal biên tập Hotspot
|
||||
*/
|
||||
function closeHotspotModal() {
|
||||
document.getElementById('hotspot-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chuyển đổi giữa tab Chọn ảnh có sẵn và Tải ảnh mới
|
||||
*/
|
||||
function switchHSTab(tabName) {
|
||||
const selectTab = document.getElementById('hs-tab-select');
|
||||
const uploadTab = document.getElementById('hs-tab-upload');
|
||||
const btns = document.querySelectorAll('.tab-btn');
|
||||
|
||||
btns.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
if (tabName === 'select') {
|
||||
selectTab.style.display = 'block';
|
||||
uploadTab.style.display = 'none';
|
||||
btns[0].classList.add('active');
|
||||
document.getElementById('hs-panorama-file').value = ''; // Reset file input
|
||||
} else {
|
||||
selectTab.style.display = 'none';
|
||||
uploadTab.style.display = 'block';
|
||||
btns[1].classList.add('active');
|
||||
document.getElementById('hs-target-id').value = ''; // Reset select
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ẩn/hiện input nhập GPS thủ công
|
||||
*/
|
||||
function toggleManualGPS() {
|
||||
const mode = document.getElementById('hs-gps-mode').value;
|
||||
const manualDiv = document.getElementById('hs-manual-gps');
|
||||
manualDiv.style.display = mode === 'manual' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gửi dữ liệu lưu Hotspot lên Backend
|
||||
*/
|
||||
async function saveHotspotToDB(pitch, yaw, text, description, targetSceneId, hotspotId) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}/hotspots`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hotspotId,
|
||||
pitch,
|
||||
yaw,
|
||||
text,
|
||||
description,
|
||||
targetSceneId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Lỗi khi lưu hotspot');
|
||||
|
||||
alert('Lưu điểm điều hướng thành công!');
|
||||
|
||||
// Refresh lại scene hiện tại để cập nhật viewer
|
||||
// Chúng ta cần lấy lại thông tin scene để có assetId mới nếu có
|
||||
const res = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const updatedScene = await res.json();
|
||||
|
||||
let secureImageUrl = `${API_BASE_URL}/assets/view/${updatedScene.assetId._id}?token=${token}`;
|
||||
// Buộc nạp lại để cập nhật danh sách hotspot mới
|
||||
openScene(currentSceneId, updatedScene.privacy, updatedScene.shareToken || '', true);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
let activeViewer = null;
|
||||
let currentHotspots = [];
|
||||
|
||||
/**
|
||||
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
|
||||
* @param {string} imageUrl - Authorized URL to fetch the secure image stream
|
||||
* @param {Array} hotspots - List of hotspots from the database
|
||||
*/
|
||||
function initPanoramaViewer(imageUrl) {
|
||||
function initPanoramaViewer(imageUrl, hotspots = []) {
|
||||
currentHotspots = hotspots;
|
||||
const container = document.getElementById('viewer-container');
|
||||
container.style.display = 'block';
|
||||
|
||||
@@ -14,6 +17,21 @@ function initPanoramaViewer(imageUrl) {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Chuyển đổi dữ liệu hotspots từ DB sang định dạng Pannellum
|
||||
const pannellumHotspots = hotspots.map(h => ({
|
||||
pitch: h.pitch,
|
||||
yaw: h.yaw,
|
||||
type: "info",
|
||||
text: h.text || "Điểm điều hướng",
|
||||
id: h._id,
|
||||
clickHandlerFunc: () => {
|
||||
if (h.targetSceneId) {
|
||||
// Gọi hàm openScene từ main_map.js
|
||||
openScene(h.targetSceneId);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Initialize Pannellum Equirectangular viewer
|
||||
activeViewer = pannellum.viewer('panorama-viewer', {
|
||||
"type": "equirectangular",
|
||||
@@ -22,7 +40,9 @@ function initPanoramaViewer(imageUrl) {
|
||||
"showControls": true,
|
||||
"compass": false,
|
||||
"mouseZoom": true,
|
||||
"keyboardZoom": true
|
||||
"keyboardZoom": true,
|
||||
"crossOrigin": "anonymous",
|
||||
"hotSpots": pannellumHotspots
|
||||
});
|
||||
|
||||
// Security constraints inside the viewer
|
||||
@@ -34,6 +54,12 @@ function initPanoramaViewer(imageUrl) {
|
||||
*/
|
||||
function closeViewer() {
|
||||
document.getElementById('viewer-container').style.display = 'none';
|
||||
|
||||
// Xóa trạng thái Scene đang hoạt động khi đóng viewer
|
||||
localStorage.removeItem('activeSceneId');
|
||||
localStorage.removeItem('activeScenePrivacy');
|
||||
localStorage.removeItem('activeSceneToken');
|
||||
|
||||
if (activeViewer) {
|
||||
try {
|
||||
activeViewer.destroy();
|
||||
@@ -46,16 +72,44 @@ function closeViewer() {
|
||||
* Appends event listeners to block right-clicks and common image saving shortcuts.
|
||||
*/
|
||||
function applyViewerSecurity() {
|
||||
const viewer = document.getElementById('viewer-container');
|
||||
// Target the actual viewer element where Pannellum renders
|
||||
const container = document.getElementById('viewer-container');
|
||||
const panoramaViewer = document.getElementById('panorama-viewer');
|
||||
|
||||
// Block right-clicks inside the 3D Viewer Container
|
||||
viewer.addEventListener('contextmenu', (e) => {
|
||||
const handleContextMenu = (e) => {
|
||||
e.preventDefault();
|
||||
alert('Security Alert: Direct image downloading or copying is disabled on this asset.');
|
||||
});
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
// Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
|
||||
if (activeViewer) {
|
||||
// Lấy tọa độ cầu (Pitch/Yaw) từ điểm click chuột
|
||||
const coords = activeViewer.mouseEventToCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
const pitch = coords[0];
|
||||
const yaw = coords[1];
|
||||
|
||||
// Kiểm tra xem có hotspot nào gần điểm click không (ngưỡng 2 độ)
|
||||
const existing = currentHotspots.find(h =>
|
||||
Math.abs(h.pitch - pitch) < 2 && Math.abs(h.yaw - yaw) < 2
|
||||
);
|
||||
|
||||
if (typeof window.handleHotspotCreation === 'function') {
|
||||
window.handleHotspotCreation(pitch, yaw, existing);
|
||||
} else {
|
||||
console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum
|
||||
container.addEventListener('contextmenu', handleContextMenu, true);
|
||||
panoramaViewer.addEventListener('contextmenu', handleContextMenu, true);
|
||||
|
||||
// Block drag and drop
|
||||
viewer.addEventListener('dragstart', (e) => {
|
||||
container.addEventListener('dragstart', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user