Khởi tạo dự án 3dtours

This commit is contained in:
2026-06-07 16:55:00 +07:00
commit 10d2e07297
18 changed files with 3333 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
/* Floating panels on top of the map */
.floating-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
padding: 15px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
z-index: 1000;
width: 280px;
}
.floating-panel h3 {
margin-bottom: 10px;
font-size: 16px;
color: #333;
}
.floating-panel input {
width: 100%;
padding: 8px;
margin-bottom: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.btn-group {
display: flex;
gap: 10px;
}
.btn-group button, .floating-panel button {
flex: 1;
padding: 8px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
.btn-group button:hover, .floating-panel button:hover {
background-color: #0056b3;
}
/* Modal Styling */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2000;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 25px;
border-radius: 8px;
width: 100%;
max-width: 450px;
position: relative;
box-shadow: 0 5px 25px rgba(0,0,0,0.3);
}
.close-btn {
position: absolute;
top: 15px;
right: 20px;
font-size: 24px;
cursor: pointer;
color: #666;
}
.close-btn:hover {
color: #000;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #333;
}
.form-group input, .form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.submit-btn {
width: 100%;
padding: 12px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 16px;
}
.submit-btn:hover {
background-color: #218838;
}
/* 3D Viewer Overlays (Full Screen) */
#viewer-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 3000;
background: black;
}
#panorama-viewer {
width: 100%;
height: 100%;
}
#close-viewer-btn {
position: absolute;
top: 20px;
left: 20px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 5px;
font-weight: bold;
cursor: pointer;
z-index: 3001;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
#close-viewer-btn:hover {
background: white;
}
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual 3D Tour Map</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- Pannellum (3D Viewer) CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/>
<!-- Custom Style -->
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Map container -->
<div id="map"></div>
<!-- Login/User Control Panel -->
<div id="user-panel" class="floating-panel">
<div id="auth-guest">
<h3>Login / Register</h3>
<input type="text" id="username-input" placeholder="Username">
<input type="password" id="password-input" placeholder="Password">
<div class="btn-group">
<button onclick="handleLogin()">Login</button>
<button onclick="handleRegister()">Register</button>
</div>
</div>
<div id="auth-logged-in" style="display: none;">
<p>Welcome, <strong id="logged-username"></strong> (<span id="logged-role"></span>)</p>
<button onclick="handleLogout()">Logout</button>
</div>
</div>
<!-- Modal for Creating Scene -->
<div id="create-scene-modal" class="modal">
<div class="modal-content">
<span class="close-btn" onclick="closeModal()">&times;</span>
<h2>Create New 3D Scene</h2>
<form id="create-scene-form" onsubmit="submitScene(event)">
<div class="form-group">
<label>Selected Coordinates:</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="modal-lat" name="lat" readonly>
<input type="text" id="modal-lng" name="lng" readonly>
</div>
</div>
<div class="form-group">
<label for="modal-title">Scene Title:</label>
<input type="text" id="modal-title" name="title" required placeholder="My Awesome Room">
</div>
<div class="form-group">
<label for="modal-panorama">Upload 360° Panorama Image:</label>
<input type="file" id="modal-panorama" name="panorama" accept="image/*" required>
</div>
<div class="form-group">
<label for="modal-privacy">Privacy:</label>
<select id="modal-privacy" name="privacy" onchange="toggleSharedUsers()">
<option value="public">Public (Everyone)</option>
<option value="private">Private (Only Me)</option>
<option value="member">Members (Logged-in Only)</option>
<option value="shared">Shared via Link/Token</option>
</select>
</div>
<div class="form-group" id="shared-with-group" style="display: none;">
<label for="modal-shared-users">Shared with User IDs (JSON Array):</label>
<input type="text" id="modal-shared-users" name="sharedWithUsers" placeholder='["60c72b2f9b1d8a41c8888888"]'>
</div>
<button type="submit" class="submit-btn">Save Scene</button>
</form>
</div>
</div>
<!-- 3D Panorama Viewer Container -->
<div id="viewer-container" style="display: none;">
<div id="panorama-viewer"></div>
<button id="close-viewer-btn" onclick="closeViewer()">Close 3D View</button>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Pannellum JS -->
<script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script>
<!-- Custom Scripts -->
<script src="js/viewer360.js"></script>
<script src="js/main_map.js"></script>
</body>
</html>
+291
View File
@@ -0,0 +1,291 @@
const API_BASE_URL = 'http://localhost:5000/api';
let map;
let tempMarker = null;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
initMap();
checkAuthStatus();
loadScenes();
});
/**
* Initializes the full-screen Leaflet Map
*/
function initMap() {
// Center of map defaults to Hanoi
map = L.map('map').setView([21.0285, 105.8542], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Event listener for right-click on map to open modal
map.on('contextmenu', (e) => {
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');
if (token && username) {
authGuest.style.display = 'none';
authLoggedIn.style.display = 'block';
document.getElementById('logged-username').innerText = username;
document.getElementById('logged-role').innerText = role || 'Thành viên';
} else {
authGuest.style.display = 'block';
authLoggedIn.style.display = 'none';
}
}
/**
* Handles user login
*/
async function handleLogin() {
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/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 || 'Login failed');
localStorage.setItem('jwt', data.token);
localStorage.setItem('username', data.user.username);
localStorage.setItem('role', data.user.role);
checkAuthStatus();
loadScenes(); // Reload scenes to show member/private scenes
alert('Logged in successfully!');
} catch (error) {
alert(error.message);
}
}
/**
* 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);
}
}
/**
* Handles user logout
*/
function handleLogout() {
localStorage.removeItem('jwt');
localStorage.removeItem('username');
localStorage.removeItem('role');
checkAuthStatus();
loadScenes(); // Reload scenes to filter out private ones
alert('Logged out successfully');
}
/**
* 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('modal-lat').value = lat.toFixed(6);
document.getElementById('modal-lng').value = lng.toFixed(6);
document.getElementById('create-scene-modal').style.display = 'flex';
}
/**
* 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 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 data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to save scene');
alert('Scene created successfully!');
closeModal();
loadScenes();
} catch (error) {
alert(error.message);
}
}
/**
* 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}`;
}
const response = await fetch(`${API_BASE_URL}/scenes`, {
method: 'GET',
headers
});
if (!response.ok) throw new Error('Failed to load scenes');
const scenes = await response.json();
// Clear existing markers (excluding tempMarker)
map.eachLayer((layer) => {
if (layer instanceof L.Marker && layer !== tempMarker) {
map.removeLayer(layer);
}
});
// Add Markers for each scene
scenes.forEach((scene) => {
const marker = L.marker([scene.lat, scene.lng]).addTo(map);
// Generate Popup content with a View button
let popupContent = `
<div class="popup-box">
<h4>${scene.title}</h4>
<p>Owner: <strong>${scene.owner ? scene.owner.username : 'Unknown'}</strong></p>
<p>Privacy: <span class="badge">${scene.privacy.toUpperCase()}</span></p>
<button class="view-btn" onclick="openScene('${scene._id}', '${scene.privacy}', '${scene.shareToken || ''}')">View 360° Panorama</button>
</div>
`;
marker.bindPopup(popupContent);
});
} catch (error) {
console.error('Error loading scenes:', error);
}
}
/**
* Fetches secure scene details and triggers the Panorama viewer
*/
async function openScene(sceneId, privacy, shareToken) {
try {
const token = localStorage.getItem('jwt');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let url = `${API_BASE_URL}/scenes/${sceneId}`;
if (privacy === 'shared' && shareToken) {
url += `?token=${shareToken}`;
}
const response = await fetch(url, {
method: 'GET',
headers
});
const scene = await response.json();
if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details');
// Construct secure image URL passing shareToken if applicable
let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
if (privacy === 'shared' && scene.shareToken) {
secureImageUrl += `?token=${scene.shareToken}`;
}
// Initialize 3D Viewer with secure, referer-protected image stream
initPanoramaViewer(secureImageUrl);
} catch (error) {
alert(error.message);
}
}
+78
View File
@@ -0,0 +1,78 @@
let activeViewer = null;
/**
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
* @param {string} imageUrl - Authorized URL to fetch the secure image stream
*/
function initPanoramaViewer(imageUrl) {
const container = document.getElementById('viewer-container');
container.style.display = 'block';
if (activeViewer) {
try {
activeViewer.destroy();
} catch (e) {}
}
// Initialize Pannellum Equirectangular viewer
activeViewer = pannellum.viewer('panorama-viewer', {
"type": "equirectangular",
"panorama": imageUrl,
"autoLoad": true,
"showControls": true,
"compass": false,
"mouseZoom": true,
"keyboardZoom": true
});
// Security constraints inside the viewer
applyViewerSecurity();
}
/**
* Closes and destroys the active panorama viewer.
*/
function closeViewer() {
document.getElementById('viewer-container').style.display = 'none';
if (activeViewer) {
try {
activeViewer.destroy();
} catch (e) {}
activeViewer = null;
}
}
/**
* Appends event listeners to block right-clicks and common image saving shortcuts.
*/
function applyViewerSecurity() {
const viewer = document.getElementById('viewer-container');
// Block right-clicks inside the 3D Viewer Container
viewer.addEventListener('contextmenu', (e) => {
e.preventDefault();
alert('Security Alert: Direct image downloading or copying is disabled on this asset.');
});
// Block drag and drop
viewer.addEventListener('dragstart', (e) => {
e.preventDefault();
});
}
// Global safety shortcut listeners (F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I)
document.addEventListener('keydown', (e) => {
// Only enforce when viewer is active
if (document.getElementById('viewer-container').style.display === 'block') {
const isCtrlS = e.ctrlKey && (e.key === 's' || e.key === 'S');
const isCtrlU = e.ctrlKey && (e.key === 'u' || e.key === 'U');
const isF12 = e.key === 'F12';
const isCtrlShiftI = e.ctrlKey && e.shiftKey && (e.key === 'i' || e.key === 'I');
if (isCtrlS || isCtrlU || isF12 || isCtrlShiftI) {
e.preventDefault();
alert('Security Alert: Inspection and saving functions are restricted on this viewer.');
return false;
}
}
});