20260607 - login, add scene, add hotspot
@@ -0,0 +1,239 @@
|
|||||||
|
# 3D Virtual Tour Map - Architecture Diagram
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Runtime**: Node.js
|
||||||
|
- **Framework**: Express.js
|
||||||
|
- **Database**: MongoDB (Mongoose ODM)
|
||||||
|
- **Authentication**: JWT (jsonwebtoken) + bcrypt
|
||||||
|
- **Image Processing**: Sharp (resize), exifr (read EXIF), piexifjs (write EXIF)
|
||||||
|
- **File Upload**: Multer
|
||||||
|
- **Security**: CORS, referer verification, no-cache headers
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Map**: Leaflet.js (OpenStreetMap tiles)
|
||||||
|
- **3D Viewer**: Pannellum.js (360° panorama viewer)
|
||||||
|
- **UI**: Vanilla HTML/CSS/JavaScript
|
||||||
|
- **State**: localStorage for JWT tokens
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Client (Browser)"
|
||||||
|
UI[HTML/CSS UI]
|
||||||
|
MAP[Leaflet Map]
|
||||||
|
VIEWER[Pannellum 3D Viewer]
|
||||||
|
AUTH[Auth Panel]
|
||||||
|
MODAL[Scene Creation Modal]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend (Node.js/Express)"
|
||||||
|
SERVER[server.js]
|
||||||
|
ROUTES[API Routes]
|
||||||
|
MIDDLEWARES[Security & Auth Middlewares]
|
||||||
|
UTILS[Image & EXIF Utils]
|
||||||
|
MODELS[Mongoose Models]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Database (MongoDB)"
|
||||||
|
USERS[Users Collection]
|
||||||
|
SCENES[Scenes Collection]
|
||||||
|
ASSETS[Assets Collection]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "File System"
|
||||||
|
UPLOADS[uploads/ Directory]
|
||||||
|
TEMP[temp/ Directory]
|
||||||
|
end
|
||||||
|
|
||||||
|
UI --> MAP
|
||||||
|
UI --> AUTH
|
||||||
|
UI --> MODAL
|
||||||
|
UI --> VIEWER
|
||||||
|
|
||||||
|
MAP -->|Right-click| MODAL
|
||||||
|
MAP -->|Load Scenes| ROUTES
|
||||||
|
MAP -->|Click Marker| VIEWER
|
||||||
|
|
||||||
|
AUTH -->|Login/Register| ROUTES
|
||||||
|
AUTH -->|Store JWT| UI
|
||||||
|
|
||||||
|
MODAL -->|Upload Image| ROUTES
|
||||||
|
|
||||||
|
VIEWER -->|Request Image| ROUTES
|
||||||
|
|
||||||
|
ROUTES --> MIDDLEWARES
|
||||||
|
MIDDLEWARES -->|Verify JWT| AUTH
|
||||||
|
MIDDLEWARES -->|Verify Referer| UI
|
||||||
|
MIDDLEWARES -->|Privacy Check| SCENES
|
||||||
|
|
||||||
|
ROUTES --> MODELS
|
||||||
|
MODELS --> USERS
|
||||||
|
MODELS --> SCENES
|
||||||
|
MODELS --> ASSETS
|
||||||
|
|
||||||
|
ROUTES --> UTILS
|
||||||
|
UTILS -->|Resize| UPLOADS
|
||||||
|
UTILS -->|Read/Write EXIF| UPLOADS
|
||||||
|
UTILS -->|Temp Storage| TEMP
|
||||||
|
|
||||||
|
SCENES --> ASSETS
|
||||||
|
ASSETS --> UPLOADS
|
||||||
|
|
||||||
|
SERVER --> ROUTES
|
||||||
|
SERVER -->|Serve Static| UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### 1. User Registration/Login
|
||||||
|
```
|
||||||
|
Client → POST /api/auth/register or /api/auth/login
|
||||||
|
→ authRoutes.js
|
||||||
|
→ User model (bcrypt hash/compare)
|
||||||
|
→ JWT generation
|
||||||
|
→ Response with token
|
||||||
|
→ Client stores token in localStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Scene Creation (Upload 360° Image)
|
||||||
|
```
|
||||||
|
Client → Right-click on map → Open modal with lat/lng
|
||||||
|
→ POST /api/scenes (with multipart/form-data)
|
||||||
|
→ authMiddleware.protect (verify JWT)
|
||||||
|
→ Multer saves to temp/
|
||||||
|
→ imageHelper.resizeTo8K (resize to 8192x4096)
|
||||||
|
→ exifHelper.getGPSCoordinates (read original GPS)
|
||||||
|
→ exifHelper.injectGPSCoordinates (inject map lat/lng)
|
||||||
|
→ Asset model saved to DB
|
||||||
|
→ Scene model saved to DB (with privacy settings)
|
||||||
|
→ Delete temp file
|
||||||
|
→ Response with scene data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Load Scenes on Map
|
||||||
|
```
|
||||||
|
Client → GET /api/scenes (with optional JWT)
|
||||||
|
→ authMiddleware.optionalAuth
|
||||||
|
→ Scene.find() with privacy filter:
|
||||||
|
- Guests: public + shared scenes
|
||||||
|
- Logged-in: public + member + owned + shared-with-me
|
||||||
|
→ Populate owner and asset data
|
||||||
|
→ Response with scene list
|
||||||
|
→ Client adds markers to Leaflet map
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. View 3D Panorama
|
||||||
|
```
|
||||||
|
Client → Click marker → GET /api/scenes/:id (with token if shared)
|
||||||
|
→ authMiddleware.optionalAuth
|
||||||
|
→ Privacy verification
|
||||||
|
→ Response with scene details
|
||||||
|
→ Client constructs secure image URL: /api/assets/view/:assetId?token=...
|
||||||
|
→ GET /api/assets/view/:assetId
|
||||||
|
→ securityMiddleware.verifyReferer (anti-hotlinking)
|
||||||
|
→ securityMiddleware.setNoCacheHeaders
|
||||||
|
→ Privacy verification again
|
||||||
|
→ Stream image file from disk
|
||||||
|
→ Pannellum viewer displays 360° panorama
|
||||||
|
→ Client-side security: block right-click, drag, keyboard shortcuts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Layers
|
||||||
|
|
||||||
|
### Backend Security
|
||||||
|
1. **JWT Authentication**: Required for creating scenes, optional for viewing
|
||||||
|
2. **Privacy Model**: Four levels (public, private, member, shared)
|
||||||
|
3. **Referer Verification**: Prevents direct URL access to images
|
||||||
|
4. **No-Cache Headers**: Prevents browser caching of protected images
|
||||||
|
5. **Share Tokens**: For shared scenes, token required for access
|
||||||
|
|
||||||
|
### Frontend Security
|
||||||
|
1. **Right-click Blocking**: Prevents image saving in viewer
|
||||||
|
2. **Drag Prevention**: Blocks drag-and-drop of images
|
||||||
|
3. **Keyboard Restrictions**: Blocks F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I
|
||||||
|
4. **Token-based Access**: Share tokens passed in URLs for shared content
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### User Model
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
username: String (unique, required),
|
||||||
|
password: String (bcrypt hashed),
|
||||||
|
role: Enum ['Chủ sở hữu', 'Thành viên'],
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scene Model
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
title: String (required),
|
||||||
|
assetId: ObjectId (ref: Asset),
|
||||||
|
lat: Number (required),
|
||||||
|
lng: Number (required),
|
||||||
|
owner: ObjectId (ref: User),
|
||||||
|
privacy: Enum ['public', 'private', 'shared', 'member'],
|
||||||
|
shareToken: String (unique, sparse),
|
||||||
|
sharedWith: [ObjectId] (ref: User),
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asset Model
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
filePath: String (required),
|
||||||
|
uploadedBy: ObjectId (ref: User),
|
||||||
|
coordinates: { lat: Number, lng: Number },
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
3dtours/
|
||||||
|
├── backend/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── db.js # MongoDB connection
|
||||||
|
│ ├── middlewares/
|
||||||
|
│ │ ├── authMiddleware.js # JWT verification
|
||||||
|
│ │ └── securityMiddleware.js # Referer check, cache control
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── User.js # User schema
|
||||||
|
│ │ ├── Scene.js # Scene schema
|
||||||
|
│ │ └── Asset.js # Asset schema
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── authRoutes.js # Login/register endpoints
|
||||||
|
│ │ └── apiRoutes.js # Scenes/assets endpoints
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── imageHelper.js # Sharp resize to 8K
|
||||||
|
│ │ └── exifHelper.js # GPS read/write
|
||||||
|
│ ├── uploads/ # Processed images
|
||||||
|
│ │ └── temp/ # Temporary upload storage
|
||||||
|
│ ├── server.js # Express app entry point
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── .env # Environment variables
|
||||||
|
└── frontend/
|
||||||
|
├── css/
|
||||||
|
│ └── style.css # UI styling
|
||||||
|
├── js/
|
||||||
|
│ ├── main_map.js # Map logic, auth, scene loading
|
||||||
|
│ └── viewer360.js # Pannellum viewer + security
|
||||||
|
└── index.html # Main UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
1. **Interactive Map**: Leaflet-based map with scene markers
|
||||||
|
2. **3D Panorama Viewer**: Pannellum for 360° image viewing
|
||||||
|
3. **User Authentication**: Registration, login with role-based access
|
||||||
|
4. **Privacy Controls**: Public, private, member-only, and shared scenes
|
||||||
|
5. **Image Processing**: Automatic resize to 8K (8192x4096) for consistency
|
||||||
|
6. **GPS Handling**: Extract original EXIF GPS, inject map coordinates
|
||||||
|
7. **Security**: Multi-layer protection against unauthorized access
|
||||||
|
8. **Share Links**: Token-based sharing for restricted content
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const path = require('path');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
// Tự động tìm và nạp file .env nằm cùng thư mục với folder config (tức là trong backend/.env)
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
const connectDB = async () => {
|
const connectDB = async () => {
|
||||||
try {
|
try {
|
||||||
const conn = await mongoose.connect(process.env.MONGODB_URI);
|
const dbURI = process.env.MONGODB_URI;
|
||||||
|
|
||||||
|
if (!dbURI) {
|
||||||
|
throw new Error('MONGODB_URI is not defined in .env file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await mongoose.connect(dbURI);
|
||||||
console.log(`MongoDB Connected: ${conn.connection.host}`);
|
console.log(`MongoDB Connected: ${conn.connection.host}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error connecting to MongoDB: ${error.message}`);
|
console.error(`Error connecting to MongoDB: ${error.message}`);
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ const User = require('../models/User');
|
|||||||
*/
|
*/
|
||||||
const protect = async (req, res, next) => {
|
const protect = async (req, res, next) => {
|
||||||
let token;
|
let token;
|
||||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
if (
|
||||||
|
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
|
||||||
|
req.query.token
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
token = req.headers.authorization.split(' ')[1];
|
token = req.headers.authorization
|
||||||
|
? req.headers.authorization.split(' ')[1]
|
||||||
|
: req.query.token;
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
req.user = await User.findById(decoded.id).select('-password');
|
req.user = await User.findById(decoded.id).select('-password');
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
@@ -28,9 +33,14 @@ const protect = async (req, res, next) => {
|
|||||||
* but allows the request to proceed as a guest if no token is found.
|
* but allows the request to proceed as a guest if no token is found.
|
||||||
*/
|
*/
|
||||||
const optionalAuth = async (req, res, next) => {
|
const optionalAuth = async (req, res, next) => {
|
||||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
if (
|
||||||
|
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
|
||||||
|
req.query.token
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization.split(' ')[1];
|
const token = req.headers.authorization
|
||||||
|
? req.headers.authorization.split(' ')[1]
|
||||||
|
: req.query.token;
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
req.user = await User.findById(decoded.id).select('-password');
|
req.user = await User.findById(decoded.id).select('-password');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ const verifyReferer = (req, res, next) => {
|
|||||||
const isMatch = (headerValue) => {
|
const isMatch = (headerValue) => {
|
||||||
if (!headerValue) return false;
|
if (!headerValue) return false;
|
||||||
try {
|
try {
|
||||||
return new URL(headerValue).origin === allowedOrigin;
|
const urlObj = new URL(headerValue);
|
||||||
|
const incomingOrigin = urlObj.origin;
|
||||||
|
// Cho phép nếu khớp hoàn toàn origin
|
||||||
|
if (incomingOrigin === allowedOrigin) return true;
|
||||||
|
// Trong môi trường development, cho phép localhost với bất kỳ port nào
|
||||||
|
const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
|
||||||
|
if (process.env.NODE_ENV !== 'production' && isLocal) return true;
|
||||||
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return headerValue.startsWith(allowedOrigin);
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ const sceneSchema = new mongoose.Schema({
|
|||||||
sharedWith: [{
|
sharedWith: [{
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'User'
|
ref: 'User'
|
||||||
|
}],
|
||||||
|
hotspots: [{
|
||||||
|
pitch: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
yaw: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
targetSceneId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Scene'
|
||||||
|
}
|
||||||
}]
|
}]
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
|
|||||||
@@ -22,16 +22,15 @@ const userSchema = new mongoose.Schema({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hash password before saving
|
// Hash password before saving
|
||||||
userSchema.pre('save', async function (next) {
|
userSchema.pre('save', async function () {
|
||||||
if (!this.isModified('password')) {
|
if (!this.isModified('password')) {
|
||||||
return next();
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const salt = await bcrypt.genSalt(10);
|
const salt = await bcrypt.genSalt(10);
|
||||||
this.password = await bcrypt.hash(this.password, salt);
|
this.password = await bcrypt.hash(this.password, salt);
|
||||||
next();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,25 +69,29 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => {
|
|||||||
const processedFileName = `processed_${req.file.filename}.jpg`;
|
const processedFileName = `processed_${req.file.filename}.jpg`;
|
||||||
const processedFilePath = path.join(uploadDir, processedFileName);
|
const processedFilePath = path.join(uploadDir, processedFileName);
|
||||||
|
|
||||||
// 1. Process and resize image to 8K JPEG (8192x4096)
|
// Lấy tọa độ GPS gốc từ ảnh vừa upload trước khi nén/xử lý
|
||||||
await resizeTo8K(tempFilePath, processedFilePath);
|
|
||||||
|
|
||||||
// 2. Analyze EXIF GPS from original file
|
|
||||||
const originalGPS = await getGPSCoordinates(tempFilePath);
|
const originalGPS = await getGPSCoordinates(tempFilePath);
|
||||||
|
|
||||||
// 3. Inject Map Lat/Lng coordinates into processed 8K file binary EXIF
|
// BACKGROUND PROCESSING: Thực hiện song song không chặn response
|
||||||
await injectGPSCoordinates(processedFilePath, latitude, longitude);
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
// 4. Remove original temp file
|
// 1. Resize to 8K
|
||||||
if (fs.existsSync(tempFilePath)) {
|
await resizeTo8K(tempFilePath, processedFilePath);
|
||||||
fs.unlinkSync(tempFilePath);
|
// 2. Inject GPS
|
||||||
}
|
await injectGPSCoordinates(processedFilePath, latitude, longitude);
|
||||||
|
// 3. Cleanup temp file
|
||||||
|
if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath);
|
||||||
|
console.log(`Background processing finished for: ${processedFileName}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Background Image processing failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 5. Save Asset to DB
|
// 5. Save Asset to DB
|
||||||
const asset = new Asset({
|
const asset = new Asset({
|
||||||
filePath: processedFilePath,
|
filePath: processedFilePath,
|
||||||
uploadedBy: req.user._id,
|
uploadedBy: req.user._id,
|
||||||
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : undefined
|
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude }
|
||||||
});
|
});
|
||||||
await asset.save();
|
await asset.save();
|
||||||
|
|
||||||
@@ -120,7 +124,7 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => {
|
|||||||
});
|
});
|
||||||
await scene.save();
|
await scene.save();
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(202).json({
|
||||||
message: 'Scene created successfully',
|
message: 'Scene created successfully',
|
||||||
scene
|
scene
|
||||||
});
|
});
|
||||||
@@ -181,7 +185,7 @@ router.get('/scenes', optionalAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const scene = await Scene.findById(req.id || req.params.id)
|
const scene = await Scene.findById(req.params.id)
|
||||||
.populate('owner', 'username')
|
.populate('owner', 'username')
|
||||||
.populate('assetId');
|
.populate('assetId');
|
||||||
|
|
||||||
@@ -206,12 +210,68 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/scenes/:id/hotspots
|
||||||
|
* @desc Add a new hotspot to a scene
|
||||||
|
* @access Private (Owner only)
|
||||||
|
*/
|
||||||
|
router.post('/scenes/:id/hotspots', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body;
|
||||||
|
const scene = await Scene.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!scene) {
|
||||||
|
return res.status(404).json({ message: 'Scene not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chỉ chủ sở hữu mới có quyền chỉnh sửa hotspots
|
||||||
|
if (scene.owner.toString() !== req.user._id.toString()) {
|
||||||
|
return res.status(403).json({ message: 'Access denied: Only the owner can add hotspots' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hotspotId) {
|
||||||
|
// CẬP NHẬT HOTSPOT HIỆN CÓ
|
||||||
|
const hs = scene.hotspots.id(hotspotId);
|
||||||
|
if (!hs) return res.status(404).json({ message: 'Hotspot not found' });
|
||||||
|
|
||||||
|
hs.pitch = parseFloat(pitch) ?? hs.pitch;
|
||||||
|
hs.yaw = parseFloat(yaw) ?? hs.yaw;
|
||||||
|
hs.text = text ?? hs.text;
|
||||||
|
hs.description = description ?? hs.description;
|
||||||
|
hs.targetSceneId = targetSceneId ?? hs.targetSceneId;
|
||||||
|
} else {
|
||||||
|
// THÊM MỚI HOTSPOT
|
||||||
|
const newHotspot = {
|
||||||
|
pitch: parseFloat(pitch),
|
||||||
|
yaw: parseFloat(yaw),
|
||||||
|
text: text || '',
|
||||||
|
description: description || '',
|
||||||
|
targetSceneId: targetSceneId || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNaN(newHotspot.pitch) || isNaN(newHotspot.yaw)) {
|
||||||
|
return res.status(400).json({ message: 'Invalid coordinates' });
|
||||||
|
}
|
||||||
|
scene.hotspots.push(newHotspot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await scene.save();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Hotspot added successfully',
|
||||||
|
hotspots: scene.hotspots
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/assets/view/:assetId
|
* @route GET /api/assets/view/:assetId
|
||||||
* @desc Securely stream panorama images (prevents direct link / unauthorized downloads)
|
* @desc Securely stream panorama images (prevents direct link / unauthorized downloads)
|
||||||
* @access Public / Private + Referer Verification + Token validation
|
* @access Public / Private + Referer Verification + Token validation
|
||||||
*/
|
*/
|
||||||
router.get('/assets/view/:assetId', verifyReferer, setNoCacheHeaders, optionalAuth, async (req, res) => {
|
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const asset = await Asset.findById(req.params.assetId);
|
const asset = await Asset.findById(req.params.assetId);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -242,12 +302,98 @@ router.get('/assets/view/:assetId', verifyReferer, setNoCacheHeaders, optionalAu
|
|||||||
return res.status(404).json({ message: 'Physical file not found on disk' });
|
return res.status(404).json({ message: 'Physical file not found on disk' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream file securely
|
const resolvedPath = path.resolve(asset.filePath);
|
||||||
res.sendFile(path.resolve(asset.filePath));
|
|
||||||
|
// Sử dụng res.sendFile để tối ưu hóa việc truyền tải file lớn và hỗ trợ Caching (ETag)
|
||||||
|
res.sendFile(resolvedPath, {
|
||||||
|
maxAge: 2592000000, // 30 ngày (tính bằng ms)
|
||||||
|
lastModified: true,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Content-Disposition': 'inline; filename="panorama.jpg"',
|
||||||
|
'Cache-Control': 'public, max-age=2592000, immutable' // Buộc trình duyệt lấy từ cache mà không cần hỏi lại server
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/scenes/:id
|
||||||
|
* @desc Update an existing scene
|
||||||
|
* @access Private (Owner only)
|
||||||
|
*/
|
||||||
|
router.put('/scenes/:id', protect, upload.single('panorama'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
|
||||||
|
const scene = await Scene.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
|
||||||
|
return res.status(403).json({ message: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update basic info
|
||||||
|
scene.title = title || scene.title;
|
||||||
|
scene.privacy = privacy || scene.privacy;
|
||||||
|
scene.lat = lat ? parseFloat(lat) : scene.lat;
|
||||||
|
scene.lng = lng ? parseFloat(lng) : scene.lng;
|
||||||
|
|
||||||
|
if (privacy === 'shared' && !scene.shareToken) {
|
||||||
|
scene.shareToken = crypto.randomBytes(24).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update image if new one is uploaded
|
||||||
|
if (req.file) {
|
||||||
|
const processedFileName = `processed_${req.file.filename}.jpg`;
|
||||||
|
const processedFilePath = path.join(uploadDir, processedFileName);
|
||||||
|
|
||||||
|
await resizeTo8K(req.file.path, processedFilePath);
|
||||||
|
await injectGPSCoordinates(processedFilePath, scene.lat, scene.lng);
|
||||||
|
|
||||||
|
const asset = new Asset({
|
||||||
|
filePath: processedFilePath,
|
||||||
|
uploadedBy: req.user._id
|
||||||
|
});
|
||||||
|
await asset.save();
|
||||||
|
scene.assetId = asset._id;
|
||||||
|
|
||||||
|
if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await scene.save();
|
||||||
|
res.json({ message: 'Scene updated', scene });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/scenes/:id
|
||||||
|
* @desc Delete a scene and its assets
|
||||||
|
* @access Private (Owner only)
|
||||||
|
*/
|
||||||
|
router.delete('/scenes/:id', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const scene = await Scene.findById(req.params.id);
|
||||||
|
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
|
||||||
|
return res.status(403).json({ message: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete physical file if exists
|
||||||
|
const asset = await Asset.findById(scene.assetId);
|
||||||
|
if (asset && fs.existsSync(asset.filePath)) {
|
||||||
|
fs.unlinkSync(asset.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Asset.findByIdAndDelete(scene.assetId);
|
||||||
|
await Scene.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
res.json({ message: 'Scene deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const dotenv = require('dotenv');
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const connectDB = require('./config/db');
|
const connectDB = require('./config/db');
|
||||||
const authRoutes = require('./routes/authRoutes');
|
const authRoutes = require('./routes/authRoutes');
|
||||||
@@ -16,7 +12,34 @@ connectDB();
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Standard middlewares
|
// Standard middlewares
|
||||||
app.use(cors());
|
const corsOptions = {
|
||||||
|
origin: function (origin, callback) {
|
||||||
|
// Cho phép các request không có origin (như Postman hoặc khi render phía server)
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
|
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
|
||||||
|
let allowedOrigin;
|
||||||
|
try {
|
||||||
|
allowedOrigin = new URL(systemHost).origin;
|
||||||
|
} catch (e) {
|
||||||
|
allowedOrigin = systemHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trong môi trường dev, cho phép localhost với bất kỳ port nào
|
||||||
|
const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('::1');
|
||||||
|
if (process.env.NODE_ENV !== 'production' && isLocal) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin === allowedOrigin) return callback(null, true);
|
||||||
|
|
||||||
|
console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`);
|
||||||
|
callback(new Error('Not allowed by CORS'));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
maxAge: 86400 // Cho phép trình duyệt cache kết quả preflight OPTIONS trong 24 giờ
|
||||||
|
};
|
||||||
|
app.use(cors(corsOptions));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
@@ -35,6 +58,6 @@ app.use((req, res) => {
|
|||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server is running in security mode on port ${PORT}`);
|
console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||||
console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`);
|
console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`);
|
||||||
});
|
});
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.7 MiB |
|
After Width: | Height: | Size: 7.7 MiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 14 MiB |
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 13 MiB |
@@ -48,18 +48,30 @@ const degToDmsRational = (deg) => {
|
|||||||
*/
|
*/
|
||||||
const injectGPSCoordinates = async (filePath, lat, lng) => {
|
const injectGPSCoordinates = async (filePath, lat, lng) => {
|
||||||
try {
|
try {
|
||||||
const jpegBinary = fs.readFileSync(filePath).toString('binary');
|
const data = fs.readFileSync(filePath);
|
||||||
|
|
||||||
let exifObj = { "0th": {}, "Exif": {}, "GPS": {} };
|
// Kiểm tra marker SOI (Start of Image) trực tiếp trên Buffer
|
||||||
|
if (data[0] !== 0xFF || data[1] !== 0xD8) {
|
||||||
|
throw new Error("Tệp tin không phải là định dạng JPEG hợp lệ (thiếu SOI marker).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const jpegBinary = data.toString('binary');
|
||||||
|
let exifObj;
|
||||||
try {
|
try {
|
||||||
exifObj = piexif.load(jpegBinary);
|
exifObj = piexif.load(jpegBinary);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No existing EXIF, start clean
|
// Nếu không có EXIF hoặc lỗi khi nạp, khởi tạo đối tượng sạch
|
||||||
|
exifObj = { "0th": {}, "Exif": {}, "GPS": {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Đảm bảo các cấu trúc IFD tồn tại trước khi ghi đè
|
||||||
|
exifObj["GPS"] = exifObj["GPS"] || {};
|
||||||
|
|
||||||
const latRef = lat >= 0 ? 'N' : 'S';
|
const latRef = lat >= 0 ? 'N' : 'S';
|
||||||
const lngRef = lng >= 0 ? 'E' : 'W';
|
const lngRef = lng >= 0 ? 'E' : 'W';
|
||||||
|
|
||||||
|
// Thêm Version ID (Bắt buộc để một số trình đọc nhận diện khối GPS)
|
||||||
|
exifObj["GPS"][piexif.GPSIFD.GPSVersionID] = [2, 2, 0, 0];
|
||||||
exifObj["GPS"][piexif.GPSIFD.GPSLatitudeRef] = latRef;
|
exifObj["GPS"][piexif.GPSIFD.GPSLatitudeRef] = latRef;
|
||||||
exifObj["GPS"][piexif.GPSIFD.GPSLatitude] = degToDmsRational(lat);
|
exifObj["GPS"][piexif.GPSIFD.GPSLatitude] = degToDmsRational(lat);
|
||||||
exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef;
|
exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef;
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ const sharp = require('sharp');
|
|||||||
const resizeTo8K = async (inputPath, outputPath) => {
|
const resizeTo8K = async (inputPath, outputPath) => {
|
||||||
try {
|
try {
|
||||||
await sharp(inputPath)
|
await sharp(inputPath)
|
||||||
|
.rotate() // Tự động xoay ảnh dựa trên EXIF orientation
|
||||||
.resize(8192, 4096, {
|
.resize(8192, 4096, {
|
||||||
fit: 'fill' // Ensures the output is exactly 8192x4096
|
fit: 'fill' // Ensures the output is exactly 8192x4096
|
||||||
})
|
})
|
||||||
.jpeg({ quality: 90 })
|
.jpeg({
|
||||||
|
quality: 85,
|
||||||
|
progressive: false, // Tắt progressive để header đơn giản hơn cho piexifjs
|
||||||
|
chromaSubsampling: '4:2:0'
|
||||||
|
})
|
||||||
|
// Loại bỏ .withMetadata() để Sharp tạo ra file JPEG sạch nhất.
|
||||||
|
// Điều này giúp piexifjs không bị lỗi "Given data is not jpeg".
|
||||||
.toFile(outputPath);
|
.toFile(outputPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Sharp image processing failed: ${error.message}`);
|
throw new Error(`Sharp image processing failed: ${error.message}`);
|
||||||
|
|||||||
@@ -169,3 +169,47 @@ html, body {
|
|||||||
#close-viewer-btn:hover {
|
#close-viewer-btn:hover {
|
||||||
background: white;
|
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; }
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
<span class="close-btn" onclick="closeModal()">×</span>
|
<span class="close-btn" onclick="closeModal()">×</span>
|
||||||
<h2>Create New 3D Scene</h2>
|
<h2>Create New 3D Scene</h2>
|
||||||
<form id="create-scene-form" onsubmit="submitScene(event)">
|
<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">
|
<div class="form-group">
|
||||||
<label>Selected Coordinates:</label>
|
<label>Selected Coordinates:</label>
|
||||||
<div style="display: flex; gap: 10px;">
|
<div style="display: flex; gap: 10px;">
|
||||||
@@ -73,10 +75,89 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3D Panorama Viewer Container -->
|
<!-- 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>
|
<div id="panorama-viewer"></div>
|
||||||
<button id="close-viewer-btn" onclick="closeViewer()">Close 3D View</button>
|
<button id="close-viewer-btn" onclick="closeViewer()">Close 3D View</button>
|
||||||
</div>
|
</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 -->
|
<!-- Leaflet JS -->
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
|||||||
@@ -2,28 +2,55 @@ const API_BASE_URL = 'http://localhost:5000/api';
|
|||||||
|
|
||||||
let map;
|
let map;
|
||||||
let tempMarker = null;
|
let tempMarker = null;
|
||||||
|
let currentSceneId = null;
|
||||||
|
let previousSceneId = null;
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initMap();
|
initMap();
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
loadScenes();
|
loadScenes();
|
||||||
|
restoreActiveScene();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the full-screen Leaflet Map
|
* Initializes the full-screen Leaflet Map
|
||||||
*/
|
*/
|
||||||
function initMap() {
|
function initMap() {
|
||||||
// Center of map defaults to Hanoi
|
// Đọc vị trí và zoom đã lưu từ localStorage
|
||||||
map = L.map('map').setView([21.0285, 105.8542], 13);
|
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', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(map);
|
}).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
|
// Event listener for right-click on map to open modal
|
||||||
map.on('contextmenu', (e) => {
|
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;
|
const { lat, lng } = e.latlng;
|
||||||
openCreateSceneModal(lat, lng);
|
openCreateSceneModal(lat, lng);
|
||||||
});
|
});
|
||||||
@@ -76,6 +103,7 @@ async function handleLogin() {
|
|||||||
localStorage.setItem('jwt', data.token);
|
localStorage.setItem('jwt', data.token);
|
||||||
localStorage.setItem('username', data.user.username);
|
localStorage.setItem('username', data.user.username);
|
||||||
localStorage.setItem('role', data.user.role);
|
localStorage.setItem('role', data.user.role);
|
||||||
|
localStorage.setItem('userId', data.user.id);
|
||||||
|
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
loadScenes(); // Reload scenes to show member/private scenes
|
loadScenes(); // Reload scenes to show member/private scenes
|
||||||
@@ -120,6 +148,10 @@ function handleLogout() {
|
|||||||
localStorage.removeItem('jwt');
|
localStorage.removeItem('jwt');
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
localStorage.removeItem('role');
|
localStorage.removeItem('role');
|
||||||
|
localStorage.removeItem('activeSceneId');
|
||||||
|
localStorage.removeItem('activeScenePrivacy');
|
||||||
|
localStorage.removeItem('activeSceneToken');
|
||||||
|
localStorage.removeItem('userId');
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
loadScenes(); // Reload scenes to filter out private ones
|
loadScenes(); // Reload scenes to filter out private ones
|
||||||
alert('Logged out successfully');
|
alert('Logged out successfully');
|
||||||
@@ -139,6 +171,7 @@ function openCreateSceneModal(lat, lng) {
|
|||||||
if (tempMarker) map.removeLayer(tempMarker);
|
if (tempMarker) map.removeLayer(tempMarker);
|
||||||
tempMarker = L.marker([lat, lng]).addTo(map);
|
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||||
|
|
||||||
|
document.getElementById('modal-scene-id').value = '';
|
||||||
document.getElementById('modal-lat').value = lat.toFixed(6);
|
document.getElementById('modal-lat').value = lat.toFixed(6);
|
||||||
document.getElementById('modal-lng').value = lng.toFixed(6);
|
document.getElementById('modal-lng').value = lng.toFixed(6);
|
||||||
document.getElementById('create-scene-modal').style.display = 'flex';
|
document.getElementById('create-scene-modal').style.display = 'flex';
|
||||||
@@ -175,34 +208,61 @@ function toggleSharedUsers() {
|
|||||||
*/
|
*/
|
||||||
async function submitScene(e) {
|
async function submitScene(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const form = document.getElementById('create-scene-form');
|
const form = document.getElementById('create-scene-form');
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
const sceneId = document.getElementById('modal-scene-id').value;
|
||||||
const token = localStorage.getItem('jwt');
|
const token = localStorage.getItem('jwt');
|
||||||
if (!token) {
|
|
||||||
alert('Please log in to submit');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const url = sceneId ? `${API_BASE_URL}/scenes/${sceneId}` : `${API_BASE_URL}/scenes`;
|
||||||
const response = await fetch(`${API_BASE_URL}/scenes`, {
|
const method = sceneId ? 'PUT' : 'POST';
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
uploadWithProgress(url, method, formData, token, 'create', () => {
|
||||||
if (!response.ok) throw new Error(data.message || 'Failed to save scene');
|
alert(sceneId ? 'Scene đang được cập nhật ngầm!' : 'Scene đã được tạo! Ảnh đang được xử lý 8K...');
|
||||||
|
|
||||||
alert('Scene created successfully!');
|
|
||||||
closeModal();
|
closeModal();
|
||||||
loadScenes();
|
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');
|
if (!response.ok) throw new Error('Failed to load scenes');
|
||||||
const scenes = await response.json();
|
const scenes = await response.json();
|
||||||
|
|
||||||
|
const activeSceneId = localStorage.getItem('activeSceneId');
|
||||||
|
const currentUserId = localStorage.getItem('userId');
|
||||||
|
|
||||||
// Clear existing markers (excluding tempMarker)
|
// Clear existing markers (excluding tempMarker)
|
||||||
map.eachLayer((layer) => {
|
map.eachLayer((layer) => {
|
||||||
if (layer instanceof L.Marker && layer !== tempMarker) {
|
if (layer instanceof L.Marker && layer !== tempMarker) {
|
||||||
@@ -245,6 +308,19 @@ async function loadScenes() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
marker.bindPopup(popupContent);
|
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) {
|
} 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
|
* 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 {
|
try {
|
||||||
const token = localStorage.getItem('jwt');
|
const token = localStorage.getItem('jwt');
|
||||||
const headers = {};
|
const headers = {};
|
||||||
@@ -268,6 +399,11 @@ async function openScene(sceneId, privacy, shareToken) {
|
|||||||
url += `?token=${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, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers
|
headers
|
||||||
@@ -276,16 +412,216 @@ async function openScene(sceneId, privacy, shareToken) {
|
|||||||
const scene = await response.json();
|
const scene = await response.json();
|
||||||
if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
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
|
// Construct secure image URL passing shareToken if applicable
|
||||||
let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
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}`;
|
secureImageUrl += `?token=${scene.shareToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize 3D Viewer with secure, referer-protected image stream
|
// Initialize 3D Viewer with secure, referer-protected image stream
|
||||||
initPanoramaViewer(secureImageUrl);
|
initPanoramaViewer(secureImageUrl, scene.hotspots || []);
|
||||||
|
|
||||||
} catch (error) {
|
} 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);
|
alert(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
let activeViewer = null;
|
let activeViewer = null;
|
||||||
|
let currentHotspots = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
|
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
|
||||||
* @param {string} imageUrl - Authorized URL to fetch the secure image stream
|
* @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');
|
const container = document.getElementById('viewer-container');
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
|
|
||||||
@@ -14,6 +17,21 @@ function initPanoramaViewer(imageUrl) {
|
|||||||
} catch (e) {}
|
} 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
|
// Initialize Pannellum Equirectangular viewer
|
||||||
activeViewer = pannellum.viewer('panorama-viewer', {
|
activeViewer = pannellum.viewer('panorama-viewer', {
|
||||||
"type": "equirectangular",
|
"type": "equirectangular",
|
||||||
@@ -22,7 +40,9 @@ function initPanoramaViewer(imageUrl) {
|
|||||||
"showControls": true,
|
"showControls": true,
|
||||||
"compass": false,
|
"compass": false,
|
||||||
"mouseZoom": true,
|
"mouseZoom": true,
|
||||||
"keyboardZoom": true
|
"keyboardZoom": true,
|
||||||
|
"crossOrigin": "anonymous",
|
||||||
|
"hotSpots": pannellumHotspots
|
||||||
});
|
});
|
||||||
|
|
||||||
// Security constraints inside the viewer
|
// Security constraints inside the viewer
|
||||||
@@ -34,6 +54,12 @@ function initPanoramaViewer(imageUrl) {
|
|||||||
*/
|
*/
|
||||||
function closeViewer() {
|
function closeViewer() {
|
||||||
document.getElementById('viewer-container').style.display = 'none';
|
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) {
|
if (activeViewer) {
|
||||||
try {
|
try {
|
||||||
activeViewer.destroy();
|
activeViewer.destroy();
|
||||||
@@ -46,16 +72,44 @@ function closeViewer() {
|
|||||||
* Appends event listeners to block right-clicks and common image saving shortcuts.
|
* Appends event listeners to block right-clicks and common image saving shortcuts.
|
||||||
*/
|
*/
|
||||||
function applyViewerSecurity() {
|
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
|
const handleContextMenu = (e) => {
|
||||||
viewer.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
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
|
// Block drag and drop
|
||||||
viewer.addEventListener('dragstart', (e) => {
|
container.addEventListener('dragstart', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||